diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 98f47fc..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[patch.crates-io] -crossterm = { git = "https://github.com/m1ngsama/crossterm.git", rev = "0e7e884bafd39135bf926fe60e9ae70852f304a8" } diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 0da9aec..98d3d99 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -4,8 +4,6 @@ on: push: branches: [main] pull_request: - schedule: - - cron: "17 3 * * 1" workflow_dispatch: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a63df..464cf96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ tag does not describe the present program or its compatibility. ## Unreleased +### Added + +- Bounded EPUB 2 and EPUB 3 reading through a semantic UTF-8 projection of the + linear spine, including paragraphs, lists, tables, preformatted text, and + image alternative text. +- EPUB book chrome, an 80-cell centered reading column, heading hierarchy, and + bounded section navigation from EPUB 2 NCX, EPUB 3 nav, or XHTML headings. + +### Changed + +- Replaced Crossterm and its maintenance fork with a small in-tree Unix + terminal adapter limited to TUT's input, raw-mode, and ANSI rendering needs. +- Security audits now run for pull requests, main-branch pushes, or explicit + manual requests instead of on a recurring schedule. + +### Fixed + +- Keyboard suspension now preserves terminal-generated signals in raw input + mode, so the configured suspend character restores the terminal before the + process stops and redraws it after continuation. + ## 0.0.6 - 2026-08-13 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68886fe..82ecb8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,9 +30,8 @@ cargo audit --file Cargo.lock ``` CI runs this RustSec vulnerability check for every pull request and main-branch -push, and weekly so newly published advisories are detected without a dependency -change. Informational warnings remain visible for review but do not fail the -gate. +push. Maintainers can also start it manually. Informational warnings remain +visible for review but do not fail the gate. Before requesting review, also run the release and distribution checks when the change can affect arithmetic, packaging, installation, or terminal behavior: diff --git a/Cargo.lock b/Cargo.lock index d2bee33..96ea046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -65,39 +71,12 @@ dependencies = [ ] [[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "crossterm" -version = "0.29.0" -source = "git+https://github.com/m1ngsama/crossterm.git?rev=0e7e884bafd39135bf926fe60e9ae70852f304a8#0e7e884bafd39135bf926fe60e9ae70852f304a8" -dependencies = [ - "bitflags", - "crossterm_winapi", - "derive_more", - "document-features", - "filedescriptor", - "mio", - "parking_lot", - "rustix", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" +name = "crc32fast" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ - "winapi", + "cfg-if", ] [[package]] @@ -140,37 +119,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "either" version = "1.17.0" @@ -200,14 +148,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "filedescriptor" -version = "0.8.3" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", + "miniz_oxide", + "zlib-rs", ] [[package]] @@ -261,6 +208,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -306,7 +263,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror", ] [[package]] @@ -336,27 +293,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - [[package]] name = "lru" version = "0.18.2" @@ -367,15 +303,19 @@ dependencies = [ ] [[package]] -name = "mio" -version = "1.2.2" +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "libc", - "log", - "wasi", - "windows-sys", + "adler2", + "simd-adler32", ] [[package]] @@ -442,27 +382,10 @@ dependencies = [ ] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "portable-atomic" @@ -485,6 +408,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -508,7 +440,6 @@ checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", - "ratatui-crossterm", "ratatui-widgets", "serde", ] @@ -528,24 +459,12 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror", "unicode-segmentation", "unicode-truncate", "unicode-width", ] -[[package]] -name = "ratatui-crossterm" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - [[package]] name = "ratatui-widgets" version = "0.3.2" @@ -567,21 +486,16 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" +name = "rbook" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "663ec1a8b0a945c8bb9c9912b1f8b328ba698a05165a81072e16604be019f45d" dependencies = [ - "semver", + "indexmap", + "percent-encoding", + "quick-xml", + "thiserror", + "zip", ] [[package]] @@ -609,18 +523,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -652,41 +554,10 @@ dependencies = [ ] [[package]] -name = "signal-hook" -version = "0.3.18" +name = "simd-adler32" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "static_assertions" @@ -756,33 +627,13 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl 2.0.19", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "thiserror-impl", ] [[package]] @@ -821,15 +672,24 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" name = "tut" version = "0.0.6" dependencies = [ - "crossterm", "libc", + "percent-encoding", + "quick-xml", "ratatui", + "rbook", "rustix", "tempfile", "unicode-segmentation", "unicode-width", + "zip", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -859,34 +719,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" @@ -901,3 +733,22 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" diff --git a/Cargo.toml b/Cargo.toml index 6bf3f39..64ab108 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,16 +8,19 @@ license = "MIT" repository = "https://github.com/m1ngsama/TUT" homepage = "https://github.com/m1ngsama/TUT" readme = "README.md" -description = "A local plain-text reader for the terminal" +description = "A local text and EPUB reader for the terminal" [dependencies] -crossterm = { version = "0.29", features = ["use-dev-tty"] } libc = "0.2" -ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] } +percent-encoding = "2" +quick-xml = { version = "0.41", features = ["escape-html"] } +ratatui = { version = "0.30.2", default-features = false, features = ["std"] } +rbook = { version = "0.7.10", default-features = false } rustix = { version = "1", features = ["fs"] } tempfile = "3" unicode-segmentation = "1.13" unicode-width = "0.2" +zip = { version = "8.6", default-features = false, features = ["deflate-flate2-zlib-rs"] } [dev-dependencies] -rustix = { version = "1", features = ["fs", "process", "pty", "termios"] } +rustix = { version = "1", features = ["fs", "process", "pty", "stdio", "termios"] } diff --git a/README.md b/README.md index 70024b7..a33bd47 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ [![CI](https://github.com/m1ngsama/TUT/actions/workflows/ci.yml/badge.svg)](https://github.com/m1ngsama/TUT/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -TUT is a focused plain-text reader for Unix terminals. +TUT is a focused document reader for Unix terminals. -It reads one finite UTF-8 document, presents it safely, and gets out of the -way. TUT does not edit text, interpret document control sequences, or try to -replace the programs that fetch, filter, decompress, and convert content. Those +It reads one finite UTF-8 text document or reflowable EPUB book, presents it +safely, and gets out of the way. TUT does not edit content, interpret document +control sequences, fetch remote resources, or execute book scripts. Other programs compose with TUT through standard input. TUT is under active pre-1.0 development. The current product is deliberately @@ -16,6 +16,7 @@ narrow: a reliable local reader, not yet a complete replacement for `less`. ## What it provides - UTF-8 and Unicode-grapheme-aware display with automatic soft wrapping. +- EPUB 2 and EPUB 3 main-reading-order projection for DRM-free reflowable books. - Vim- and pager-style line, page, half-page, and document navigation. - Exact, case-sensitive literal search with highlighting and match navigation. - Bounded numeric prefixes such as `12j`, `3 Space`, and `5n`. @@ -63,6 +64,12 @@ Read a regular file: tut notes.txt ``` +Read an EPUB book: + +```sh +tut book.epub +``` + Read finite output from another Unix program: ```sh @@ -78,14 +85,14 @@ stream follower. ```text Usage: tut [OPTION]... FILE -Read UTF-8 text from FILE in the terminal. +Read UTF-8 text or EPUB books in the terminal. -h, --help display this help and exit -V, --version output version information and exit --log-file=FILE append typed session events to FILE -With FILE -, read standard input. +With FILE -, read UTF-8 text from standard input. Use -- before a FILE whose name begins with '-'. For a file named '-', use ./-. TUT_LOG_FILE is used when --log-file is not specified. @@ -116,6 +123,7 @@ Press `F1` in TUT for a guide adapted to the current terminal size. | `Ctrl-D`, `Ctrl-U` | Half page down or up | | `g`, Home | Start of document | | `G`, End | End of document | +| `[`, `]` | Previous or next EPUB section | | `/` | Enter search | | `n`, `N` | Next or previous match | | `1`-`9999` before a relative motion | Repeat the motion | @@ -124,10 +132,11 @@ Press `F1` in TUT for a guide adapted to the current terminal size. | `F1` | Open or close help | | `q` | Quit | | `Ctrl-C` | Interrupt | +| `Ctrl-Z` | Suspend through Unix job control | -Counts apply to line, page, half-page, and match movement. A count starts with -`1`-`9`; subsequent digits may include `0`. A digit that would raise the count -above 9999 is rejected while the existing prefix is preserved. +Counts apply to line, page, half-page, EPUB-section, and match movement. A count +starts with `1`-`9`; subsequent digits may include `0`. A digit that would raise +the count above 9999 is rejected while the existing prefix is preserved. ### Search input @@ -151,10 +160,13 @@ from search input closes only with Esc or `F1`. | Property | Current behavior | | --- | --- | -| Input | One regular file or one finite standard-input snapshot | -| Encoding | Valid UTF-8; an initial UTF-8 BOM is accepted and hidden | -| Maximum input size | 32 MiB, including standard input | -| Layout | Automatic soft wrap; no horizontal scrolling or no-wrap mode | +| Input | One regular UTF-8 text or `.epub` file, or one finite UTF-8 standard-input snapshot | +| Text encoding | Valid UTF-8; an initial UTF-8 BOM is accepted and hidden | +| Maximum input size | 32 MiB, including EPUB archives and standard input | +| EPUB expansion | 4096 entries, 8 MiB per entry, and 256 MiB total | +| EPUB linear spine | Source content across all linear-spine chapters is limited to 128 MiB combined | +| EPUB projected text | The UTF-8 text produced from an EPUB is limited to 32 MiB | +| Layout | Automatic soft wrap; EPUB reading columns are centered and capped at 80 cells | | Line endings | LF, CRLF, and CR are recognized | | Tabs | Expanded to four-column tab stops | | Control sequences | Displayed inertly; ANSI styling is not interpreted | @@ -167,10 +179,18 @@ from search input closes only with Esc or `F1`. Windows is not supported. Other Unix systems may work, but are not currently covered by the terminal and signal test matrix. +EPUB XHTML is projected to paragraphs, lists, table cells, preformatted text, +captions, and useful image alternative text before terminal layout. EPUB 2 NCX +and EPUB 3 navigation documents provide section movement; XHTML headings are +the fallback. Books open at their first projected heading, while `g` reveals +front matter. The reader follows only the linear spine and does not render CSS, +bitmap images, fixed-layout pages, fonts, DRM-protected content, link activation, +or a table-of-contents browser. + TUT does not currently follow or reload growing files, switch between multiple documents, run regular-expression searches, or provide mouse and configuration -systems. Convert other document formats to finite UTF-8 plain text before -piping them to TUT. +systems. Convert formats other than EPUB to finite UTF-8 text before piping +them to TUT. ## Session logging diff --git a/docs/PROJECT.md b/docs/PROJECT.md index 1f95968..0128ed3 100644 --- a/docs/PROJECT.md +++ b/docs/PROJECT.md @@ -1,9 +1,10 @@ # TUT project principles -TUT is a terminal reader for finite, local, UTF-8 plain text. Its job is to -make text comfortable to read without compromising the terminal that hosts it. -It is intentionally a focused Unix tool rather than an editor, formatter, -terminal emulator, or general document platform. +TUT is a terminal reader for finite, local UTF-8 text and DRM-free, reflowable +EPUB books. Its job is to make documents comfortable to read without +compromising the terminal that hosts it. It is intentionally a focused Unix +tool rather than an editor, formatter, terminal emulator, or general document +platform. These principles are part of the project's product contract. They guide both implementation decisions and the acceptance of new features. diff --git a/docs/tut.1 b/docs/tut.1 index f15c2db..2c468b1 100644 --- a/docs/tut.1 +++ b/docs/tut.1 @@ -1,12 +1,12 @@ -.TH TUT 1 "August 11, 2026" "TUT" "User Commands" +.TH TUT 1 "August 31, 2026" "TUT" "User Commands" .SH NAME -tut \- read UTF-8 text in the terminal +tut \- read UTF-8 text and EPUB books in the terminal .SH SYNOPSIS .B tut .RI [ OPTION ]... " FILE" .SH DESCRIPTION .B tut -is a full-screen reader for local, plain UTF-8 text. +is a full-screen reader for local UTF-8 text and reflowable EPUB books. It displays the document with Unicode-aware soft wrapping and provides line, page, document, and literal-search navigation. .PP @@ -22,6 +22,19 @@ The file is checked while it is read; if it changes during the session, .B tut reports an error instead of combining inconsistent contents. .PP +An EPUB path is admitted as a bounded ZIP archive and its linear spine is +projected to UTF-8 text before terminal layout. +Paragraphs, lists, table cells, preformatted text, captions, and useful image +alternative text are retained. +EPUB 2 NCX and EPUB 3 navigation documents provide section movement, with +XHTML headings as a fallback. +Books initially open at the first projected heading; +.B g +also exposes earlier front matter. +The reading column is centered and limited to 80 terminal cells. +CSS layout, bitmap images, fixed-layout pages, fonts, protected content, link +activation, and a table-of-contents browser are not rendered. +.PP When .I FILE is @@ -59,7 +72,9 @@ Use this before a pathname that begins with a hyphen. .SH OPERANDS .TP .I FILE -The regular UTF-8 text file to read. +The regular UTF-8 text or +.B .epub +file to read. Specify .B \- to read standard input. @@ -104,6 +119,9 @@ Move to the start of the document. .TP .BR G ", " End Move to the end of the document. +.TP +.BR [ ", " ] +Move to the previous or next EPUB section. .SS Search Searches find literal, case-sensitive, non-overlapping UTF-8 substrings. Match navigation wraps at the ends of the document. @@ -150,6 +168,10 @@ reader. .B Ctrl-C Request interruption. After successful session logging and terminal cleanup, exit with status 130. +.TP +.B Ctrl-Z +Suspend through Unix terminal job control. +The terminal is restored before stopping and rebuilt after continuation. .SH ENVIRONMENT .TP .B TUT_LOG_FILE @@ -167,8 +189,18 @@ read from nonterminal standard input. does not read a configuration file and does not create a default log file. .SH LIMITS .IP \(bu 2 -The input document or standard-input snapshot may contain at most 33,554,432 -bytes (32 MiB) and must be valid UTF-8. +The input file or standard-input snapshot may contain at most 33,554,432 bytes +(32 MiB). +Plain text and standard input must be valid UTF-8. +.IP \(bu 2 +An EPUB may contain at most 4096 ZIP entries, each expanding to at most 8 MiB, +with no more than 256 MiB expanded in total. +.IP \(bu 2 +The source content across all chapters in an EPUB's linear spine may total at +most 134,217,728 bytes (128 MiB). +.IP \(bu 2 +The UTF-8 text projected from an EPUB may contain at most 33,554,432 bytes +(32 MiB). .IP \(bu 2 Search queries may contain at most 4096 UTF-8 bytes. .IP \(bu 2 diff --git a/src/app.rs b/src/app.rs index 0fd90d5..0275760 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,7 @@ use std::{mem::size_of, num::NonZeroU16, ops::Range}; use unicode_segmentation::UnicodeSegmentation; use crate::{ - document::{Document, DocumentCache}, + document::{BookHeading, BookSection, Document, DocumentCache}, error::TutError, layout::{ BodyHeight, ContentWidth, DOTTED_CIRCLE, DisplayColumn, DisplayProjection, GraphemeRange, @@ -25,6 +25,9 @@ pub(super) const MIN_TERMINAL_ROWS: u16 = 4; pub(super) const SEARCH_DRAFT_LIMIT_BYTES: usize = MAX_SEARCH_QUERY_BYTES; pub(super) const MAX_REPEAT_COUNT: u16 = 9_999; const CHROME_ROWS: u16 = 3; +const BOOK_BODY_MIN_COLUMNS: u16 = 40; +const BOOK_BODY_MARGIN_COLUMNS: u16 = 2; +const BOOK_BODY_MAX_COLUMNS: u16 = 80; const MAX_VISIBLE_RENDER_BYTES: usize = 32 * 1024 * 1024; const MAX_TRANSIENT_RENDER_TEXT_BYTES: usize = (u16::MAX as usize + 1) * (MAX_RENDER_GRAPHEME_BYTES + DOTTED_CIRCLE.len()); @@ -52,6 +55,23 @@ impl Geometry { } } + const fn book_content_width(self) -> Option { + if !self.is_usable() { + return None; + } + let columns = if self.columns >= BOOK_BODY_MIN_COLUMNS { + let available = self.columns.saturating_sub(BOOK_BODY_MARGIN_COLUMNS * 2); + if available < BOOK_BODY_MAX_COLUMNS { + available + } else { + BOOK_BODY_MAX_COLUMNS + } + } else { + self.columns + }; + ContentWidth::new(columns) + } + pub(super) const fn body_height(self) -> Option { if self.is_usable() { BodyHeight::new(self.rows - CHROME_ROWS) @@ -103,6 +123,8 @@ pub(super) enum Action { HalfPageUp, DocumentStart, DocumentEnd, + NextSection, + PreviousSection, BeginSearch, SearchInsert(char), SearchBackspace, @@ -589,6 +611,8 @@ impl<'a> RenderRowsView<'a> { pub(super) struct RenderState<'a> { pub filename: &'a str, pub path: &'a str, + pub book: Option>, + pub body_width: u16, pub rows: RenderRowsView<'a>, pub progress: u8, pub current_line: Option, @@ -603,15 +627,27 @@ pub(super) struct RenderState<'a> { pub(super) struct PendingState<'a> { pub filename: &'a str, pub path: &'a str, + pub book: Option>, + pub body_width: u16, pub status: SearchStatus<'a>, pub repeat: Option, } +#[derive(Debug, Clone, Copy)] +pub(super) struct BookState<'a> { + pub title: Option<&'a str>, + pub creator: Option<&'a str>, + pub section_title: Option<&'a str>, + pub section_ordinal: Option, + pub section_total: usize, + pub heading_ranges: &'a [BookHeading], +} + #[derive(Debug)] pub(super) enum ViewState<'a> { Reader(RenderState<'a>), Pending(PendingState<'a>), - Help { q_closes: bool }, + Help { q_closes: bool, book: bool }, } struct RenderViewportCache { @@ -679,6 +715,9 @@ enum ViewportRequest { Search { target: SourceOffset, }, + Section { + target: SourceOffset, + }, End, } @@ -689,9 +728,20 @@ struct BackgroundSchedule { } impl ViewportRequest { + fn locator_height(self, viewport_height: BodyHeight) -> BodyHeight { + if matches!(self, Self::Section { .. }) { + BodyHeight::new(1).expect("section locators use one row") + } else { + viewport_height + } + } + const fn target(self, source_end: SourceOffset) -> SourceOffset { match self { - Self::Reflow { target } | Self::Move { target, .. } | Self::Search { target } => target, + Self::Reflow { target } + | Self::Move { target, .. } + | Self::Search { target } + | Self::Section { target } => target, Self::End => source_end, } } @@ -705,7 +755,7 @@ impl ViewportRequest { let delta = match self { Self::Move { delta, .. } => delta, Self::Search { .. } => RowDelta::Backward(usize::from(height.get() / 2)), - Self::Reflow { .. } | Self::End => RowDelta::Forward(0), + Self::Reflow { .. } | Self::Section { .. } | Self::End => RowDelta::Forward(0), }; (target, delta) } @@ -718,7 +768,7 @@ impl ViewportRequest { FollowEndPolicy::AtEnd => at_end, FollowEndPolicy::Always => true, }, - Self::Reflow { .. } | Self::Search { .. } => false, + Self::Reflow { .. } | Self::Search { .. } | Self::Section { .. } => false, } } @@ -734,6 +784,7 @@ pub(super) struct App { layout: Option, anchor: SourceOffset, anchor_is_row_start: bool, + section_focus: Option, follow_end: bool, geometry: Geometry, mode: Mode, @@ -966,14 +1017,16 @@ pub(super) fn app_from_text(path: &std::path::Path, text: String) -> App { impl App { pub(super) fn new(document: Document) -> Self { - let anchor = document.source_start(); + let anchor = document.preferred_start(); + let anchor_is_row_start = anchor == document.source_start(); Self { document, document_cache: DocumentCache::default(), search_cache: DocumentCache::default(), layout: None, anchor, - anchor_is_row_start: true, + anchor_is_row_start, + section_focus: None, follow_end: false, geometry: Geometry::new(0, 0), mode: Mode::Content(ContentMode::Reading), @@ -1086,6 +1139,36 @@ impl App { !self.geometry.is_usable() } + fn content_width_for(&self, geometry: Geometry) -> Option { + if self.document.book().is_some() { + geometry.book_content_width() + } else { + geometry.content_width() + } + } + + fn body_width(&self) -> u16 { + self.content_width_for(self.geometry) + .map_or(0, |width| width.get()) + } + + fn book_state(&self, at: SourceOffset) -> Option> { + let book = self.document.book()?; + let at = self.section_focus.unwrap_or(at); + let sections = book.sections(); + let section_count = sections.partition_point(|section| section.target() <= at); + let section_ordinal = (section_count != 0).then_some(section_count); + let section_title = book.section_at_or_before(at).map(BookSection::title); + Some(BookState { + title: book.title(), + creator: book.creator(), + section_title, + section_ordinal, + section_total: sections.len(), + heading_ranges: book.headings(), + }) + } + pub(super) fn mode(&self) -> &Mode { &self.mode } @@ -1187,11 +1270,15 @@ impl App { self.document.validate()?; } self.prepare_search_highlights(viewport)?; - let line = if viewport.is_some() { + let book_at = viewport.map_or(self.anchor, |viewport| viewport.first_visible_start); + let line = if self.document.book().is_some() { + None + } else if viewport.is_some() { self.validated_line_position_for(viewport)? } else { self.line_position_for(viewport)? }; + let book = self.book_state(book_at); let progress = self.progress_for(viewport); let (ranges, current) = if viewport.is_some() { let key = self @@ -1209,6 +1296,8 @@ impl App { Ok(RenderState { filename: self.document.display_name(), path: self.document.display_path(), + book, + body_width: self.body_width(), rows: RenderRowsView::new(rows, ranges, current), progress, current_line: line.map(LinePosition::current), @@ -1235,12 +1324,18 @@ impl App { if let Mode::Help { return_to } = &self.mode { return Ok(ViewState::Help { q_closes: matches!(return_to, ContentMode::Reading), + book: self + .document + .book() + .is_some_and(|book| !book.sections().is_empty()), }); } if !self.reader_frame_ready() { return Ok(ViewState::Pending(PendingState { filename: self.document.display_name(), path: self.document.display_path(), + book: self.book_state(self.anchor), + body_width: self.body_width(), status: self.search_status(), repeat: self.repeat, })); @@ -1251,9 +1346,11 @@ impl App { const fn view_activity(&self) -> Option { match self.viewport_request { Some(ViewportRequest::End) => Some(ViewActivity::GoingToEnd), - Some(ViewportRequest::Move { .. } | ViewportRequest::Reflow { .. }) => { - Some(ViewActivity::PreparingView) - } + Some( + ViewportRequest::Move { .. } + | ViewportRequest::Reflow { .. } + | ViewportRequest::Section { .. }, + ) => Some(ViewActivity::PreparingView), Some(ViewportRequest::Search { .. }) | None => None, } } @@ -1405,7 +1502,7 @@ impl App { } fn cached_viewport_location(&self, request: ViewportRequest) -> Option { - let height = self.geometry.body_height()?; + let height = request.locator_height(self.geometry.body_height()?); let layout = self.layout.as_ref()?; let (target, delta) = request.locator_parameters(self.document.source_end(), height); self.row_neighborhood.locate_target( @@ -1533,7 +1630,8 @@ impl App { return Ok(self.finish_viewport_request(request, located)); } if self.locator.is_none() { - let height = self.geometry.body_height().expect("usable geometry"); + let height = + request.locator_height(self.geometry.body_height().expect("usable geometry")); let (target, delta) = request.locator_parameters(self.document.source_end(), height); let row_bound = source_row_bound(self.document.source_start(), self.document.source_end()); @@ -1567,11 +1665,20 @@ impl App { request: ViewportRequest, located: LocatedViewport, ) -> bool { + let previous_anchor = self.anchor; self.cancel_render_scan(); self.viewport_request = None; self.locator = None; self.anchor = located.anchor; self.anchor_is_row_start = true; + match request { + ViewportRequest::Section { target } => self.section_focus = Some(target), + ViewportRequest::Search { .. } | ViewportRequest::End => self.section_focus = None, + ViewportRequest::Move { .. } if located.anchor != previous_anchor => { + self.section_focus = None; + } + ViewportRequest::Move { .. } | ViewportRequest::Reflow { .. } => {} + } self.follow_end = request.follows_end(located.at_end); if request.is_move() { self.start_queued_move(); @@ -1670,6 +1777,8 @@ impl App { } Action::DocumentStart if reading => self.document_start(), Action::DocumentEnd if reading => self.document_end(), + Action::NextSection if reading => self.move_section_repeated(true), + Action::PreviousSection if reading => self.move_section_repeated(false), Action::BeginSearch if reading => self.begin_search(), Action::SearchInsert(character) if editing => self.insert_search(character)?, Action::SearchBackspace if editing => self.backspace_search(), @@ -1774,16 +1883,47 @@ impl App { self.move_rows(downward, amount) || consumed } + fn move_section_repeated(&mut self, forward: bool) -> bool { + let (count, consumed) = self.take_repeat_count(); + let Some(book) = self.document.book() else { + return consumed; + }; + if book.sections().is_empty() { + return consumed; + } + let origin = match self.viewport_request { + Some(ViewportRequest::Section { target }) => target, + _ => self.section_focus.unwrap_or(self.anchor), + }; + let mut target = origin; + for _ in 0..count { + let section = if forward { + book.next_section(target) + } else { + book.previous_section(target) + }; + let Some(section) = section else { + break; + }; + target = section.target(); + } + if target == origin { + return consumed; + } + self.schedule_section_jump(target) || consumed + } + fn resize(&mut self, geometry: Geometry) -> bool { let geometry_changed = self.geometry != geometry; if !geometry_changed { return !geometry.is_usable() && self.cancel_repeat(); } + let first_layout = self.layout.is_none(); self.render_body = RenderBody::Empty; if let Some(search) = &mut self.search { search.invalidate_highlights(); } - if self.geometry.content_width() != geometry.content_width() { + if self.content_width_for(self.geometry) != self.content_width_for(geometry) { self.row_neighborhood.clear(); self.anchor_is_row_start = self.anchor == self.document.source_start(); } @@ -1799,11 +1939,12 @@ impl App { return true; } + let content_width = self.content_width_for(geometry); let reader = self.document.reader(&mut self.document_cache); let rebuilt = ensure_viewport_layout( &mut self.layout, &reader, - geometry.content_width(), + content_width, geometry.body_height(), ); if self.viewport_request.is_some() { @@ -1812,10 +1953,20 @@ impl App { if self.follow_end { self.follow_end = false; self.viewport_request = Some(ViewportRequest::End); - } else if rebuilt && self.anchor != self.document.source_start() { - self.viewport_request = Some(ViewportRequest::Reflow { - target: self.anchor, - }); + } else if rebuilt + && (self.section_focus.is_some() || self.anchor != self.document.source_start()) + { + self.viewport_request = Some( + if self.section_focus.is_some() || first_layout && self.document.book().is_some() { + ViewportRequest::Section { + target: self.section_focus.unwrap_or(self.anchor), + } + } else { + ViewportRequest::Reflow { + target: self.anchor, + } + }, + ); } true } @@ -1844,6 +1995,7 @@ impl App { let canceled_repeat = self.cancel_repeat(); let source_start = self.document.source_start(); let anchor_changed = self.anchor != source_start; + let focus_changed = self.section_focus.take().is_some(); let canceled_render = anchor_changed && self.cancel_render_scan(); let canceled_viewport = self.cancel_viewport_request(); let canceled_search = self.cancel_search_motion(); @@ -1851,6 +2003,7 @@ impl App { || canceled_viewport || canceled_search || anchor_changed + || focus_changed || self.follow_end || canceled_repeat; self.anchor = source_start; @@ -2099,6 +2252,22 @@ impl App { changed } + fn schedule_section_jump(&mut self, target: SourceOffset) -> bool { + let request = ViewportRequest::Section { target }; + let canceled_search = self.cancel_search_motion(); + let canceled_render = self.cancel_render_scan(); + let changed = canceled_search + || canceled_render + || self.viewport_request != Some(request) + || self.queued_rows != 0 + || self.follow_end; + self.viewport_request = Some(request); + self.locator = None; + self.queued_rows = 0; + self.follow_end = false; + changed + } + fn advance_render(&mut self) -> Result { let key = self.render_key(); if self.current_render_cache().is_some() { @@ -2699,7 +2868,10 @@ mod tests { use tempfile::tempdir; use super::*; - use crate::{document::overwrite_with_distinct_fingerprint, layout::DisplayAtoms}; + use crate::{ + document::{BookStructure, overwrite_with_distinct_fingerprint}, + layout::DisplayAtoms, + }; const BACKGROUND_STEP_LIMIT: usize = 100_000; @@ -2708,11 +2880,18 @@ mod tests { Move, End, Search, + Section, Reflow, } impl ViewportRequestCase { - const ALL: [Self; 4] = [Self::Move, Self::End, Self::Search, Self::Reflow]; + const ALL: [Self; 5] = [ + Self::Move, + Self::End, + Self::Search, + Self::Section, + Self::Reflow, + ]; fn request(self, app: &mut App) -> ViewportRequest { let target = SourceOffset::new(35); @@ -2728,6 +2907,7 @@ mod tests { } Self::End => ViewportRequest::End, Self::Search => ViewportRequest::Search { target }, + Self::Section => ViewportRequest::Section { target }, Self::Reflow => ViewportRequest::Reflow { target }, } } @@ -2741,6 +2921,482 @@ mod tests { app } + #[derive(Clone, Copy)] + struct BookOffsets { + section_targets: [SourceOffset; 3], + heading_starts: [SourceOffset; 3], + } + + fn book_document() -> (Document, BookOffsets) { + let text = concat!( + "front matter\n\n", + "one starts\nONE\nfirst target\n\n", + "two starts\nTWO\nsecond target\n\n", + "three starts\nTHREE\nthird target\n", + ); + let entries = [ + ("One", "one starts", "ONE"), + ("Two", "two starts", "TWO"), + ("Three", "three starts", "THREE"), + ]; + let section_targets = entries.map(|(_, marker, _)| { + SourceOffset::from_usize(text.find(marker).expect("book fixture section exists")) + }); + let heading_starts = entries.map(|(_, _, heading)| { + SourceOffset::from_usize(text.find(heading).expect("book fixture heading exists")) + }); + let sections = entries + .iter() + .zip(section_targets) + .map(|((title, _, _), target)| { + BookSection::new((*title).to_owned(), 1, target) + .expect("book fixture section is valid") + }) + .collect(); + let headings = entries + .iter() + .zip(heading_starts) + .map(|((_, _, heading), start)| { + BookHeading::new( + 1, + start, + start + .checked_add(heading.len()) + .expect("book fixture offsets fit"), + ) + .expect("book fixture heading is valid") + }) + .collect(); + let book = BookStructure::new( + Some("A Test Book".to_owned()), + Some("Ada Reader".to_owned()), + sections, + headings, + ); + ( + Document::from_book_text(Path::new("/tmp/structured.epub"), text.to_owned(), book), + BookOffsets { + section_targets, + heading_starts, + }, + ) + } + + fn book_reader(columns: u16, rows: u16) -> (App, BookOffsets) { + let (document, offsets) = book_document(); + let mut app = App::new(document); + app.update(Action::Resize(Geometry::new(columns, rows))) + .unwrap(); + settle_frame(&mut app); + (app, offsets) + } + + fn inline_section_book() -> (Document, [SourceOffset; 3]) { + let text = concat!( + "front matter padding padding padding padding FIRST chapter text\n", + "second line padding padding padding padding SECOND chapter text\n", + "third line padding padding padding padding THIRD chapter text\n", + ); + let markers = ["FIRST", "SECOND", "THIRD"]; + let targets = markers.map(|marker| { + SourceOffset::from_usize(text.find(marker).expect("inline section marker exists")) + }); + let sections = markers + .iter() + .zip(targets) + .map(|(title, target)| { + BookSection::new((*title).to_owned(), 1, target) + .expect("inline section target is valid") + }) + .collect(); + let book = BookStructure::new(None, None, sections, Vec::new()); + ( + Document::from_book_text( + Path::new("/tmp/inline-sections.epub"), + text.to_owned(), + book, + ), + targets, + ) + } + + #[test] + fn book_body_width_is_full_on_narrow_terminals_and_bounded_on_wide_ones() { + for (columns, expected) in [(16, 16), (39, 39), (40, 36), (83, 79), (84, 80), (200, 80)] { + assert_eq!( + Geometry::new(columns, 4) + .book_content_width() + .map(ContentWidth::get), + Some(expected), + ); + } + assert_eq!(Geometry::new(15, 4).book_content_width(), None); + assert_eq!(Geometry::new(80, 3).book_content_width(), None); + } + + #[test] + fn section_actions_leave_plain_text_navigation_and_chrome_unchanged() { + let mut app = reader("one\ntwo\nthree\nfour\nfive\n", 80, 5); + let anchor = app.anchor; + assert_eq!(app.update(Action::NextSection).unwrap(), Outcome::Unchanged); + assert_eq!( + app.update(Action::PreviousSection).unwrap(), + Outcome::Unchanged + ); + assert_eq!(app.anchor, anchor); + assert_eq!(app.body_width(), 80); + + let state = app.render_state().unwrap(); + assert!(state.book.is_none()); + assert_eq!(state.body_width, 80); + assert_eq!(state.current_line, Some(1)); + + enter_repeat(&mut app, "3"); + assert_eq!(app.update(Action::NextSection).unwrap(), Outcome::Changed); + assert_eq!(app.repeat_status(), None); + assert_eq!(app.anchor, anchor); + + app.update(Action::LineDown).unwrap(); + settle(&mut app); + assert_eq!(app.render_state().unwrap().current_line, Some(2)); + + let document = Document::from_book_text( + Path::new("/tmp/sectionless.epub"), + "book body\n".to_owned(), + BookStructure::new(Some("Sectionless".to_owned()), None, Vec::new(), Vec::new()), + ); + let mut app = App::new(document); + app.update(Action::Resize(Geometry::new(80, 5))).unwrap(); + settle_frame(&mut app); + let anchor = app.anchor; + assert_eq!( + app.update(Action::PreviousSection).unwrap(), + Outcome::Unchanged + ); + enter_repeat(&mut app, "3"); + assert_eq!( + app.update(Action::PreviousSection).unwrap(), + Outcome::Changed + ); + assert_eq!(app.repeat_status(), None); + assert_eq!(app.anchor, anchor); + assert_eq!(app.update(Action::ShowHelp).unwrap(), Outcome::Changed); + assert!(matches!( + app.view_state().unwrap(), + ViewState::Help { + q_closes: true, + book: false + } + )); + } + + #[test] + fn books_open_at_the_first_heading_but_document_start_reaches_front_matter() { + let (document, offsets) = book_document(); + let mut app = App::new(document); + assert_eq!(app.anchor, offsets.heading_starts[0]); + assert!(!app.anchor_is_row_start); + + app.update(Action::Resize(Geometry::new(120, 4))).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { + target: offsets.heading_starts[0] + }) + ); + let ViewState::Pending(pending) = app.view_state().unwrap() else { + panic!("initial book location publishes pending book state"); + }; + assert_eq!(pending.body_width, 80); + assert_eq!(pending.book.unwrap().section_ordinal, Some(1)); + settle_frame(&mut app); + assert_eq!(app.anchor, offsets.heading_starts[0]); + + assert_eq!( + app.document.book().unwrap().sections()[0].target(), + offsets.section_targets[0] + ); + let state = app.render_state().unwrap(); + let book = state.book.expect("book metadata is published"); + assert_eq!(book.title, Some("A Test Book")); + assert_eq!(book.creator, Some("Ada Reader")); + assert_eq!(book.section_title, Some("One")); + assert_eq!(book.section_ordinal, Some(1)); + assert_eq!(book.section_total, 3); + assert_eq!(book.heading_ranges.len(), 3); + assert_eq!(book.heading_ranges[0].start(), offsets.heading_starts[0]); + assert_eq!( + book.heading_ranges[0].end().get(), + offsets.heading_starts[0].get() + 3 + ); + assert_eq!(book.heading_ranges[0].level(), 1); + assert_ne!(book.heading_ranges[0].start(), offsets.section_targets[0]); + assert_eq!(state.body_width, 80); + assert_eq!(state.current_line, None); + assert_eq!(state.total_lines, None); + + assert_eq!(app.update(Action::DocumentStart).unwrap(), Outcome::Changed); + settle_frame(&mut app); + assert_eq!(app.anchor, app.document.source_start()); + let state = app.render_state().unwrap(); + let book = state.book.expect("book metadata remains available"); + assert_eq!(book.section_title, None); + assert_eq!(book.section_ordinal, None); + assert_eq!(state.current_line, None); + + app.update(Action::ShowHelp).unwrap(); + assert!(matches!( + app.view_state().unwrap(), + ViewState::Help { + q_closes: true, + book: true + } + )); + } + + #[test] + fn short_books_keep_the_preferred_heading_at_the_top() { + let (document, offsets) = book_document(); + let mut app = App::new(document); + + app.update(Action::Resize(Geometry::new(120, 20))).unwrap(); + settle_frame(&mut app); + + assert_eq!(app.anchor, offsets.heading_starts[0]); + let state = app.render_state().unwrap(); + assert_eq!(state.rows.get(0).unwrap().text, "ONE"); + assert!( + state + .rows + .iter() + .all(|row| !row.text.contains("front matter")) + ); + } + + #[test] + fn section_navigation_is_located_counted_and_clamped() { + let (mut app, offsets) = book_reader(120, 4); + let targets = offsets.section_targets; + + assert_eq!(app.update(Action::NextSection).unwrap(), Outcome::Changed); + assert_eq!(app.anchor, offsets.heading_starts[0]); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[1] }) + ); + assert_eq!(app.update(Action::NextSection).unwrap(), Outcome::Changed); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[2] }) + ); + assert_eq!( + app.update(Action::PreviousSection).unwrap(), + Outcome::Changed + ); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[1] }) + ); + settle(&mut app); + assert_eq!(app.anchor, targets[1]); + assert_eq!( + app.render_state().unwrap().book.unwrap().section_ordinal, + Some(2) + ); + + app.update(Action::PreviousSection).unwrap(); + settle(&mut app); + assert_eq!(app.anchor, targets[0]); + + app.update(Action::LineDown).unwrap(); + settle(&mut app); + assert!(app.anchor > targets[0] && app.anchor < targets[1]); + app.update(Action::PreviousSection).unwrap(); + settle(&mut app); + assert_eq!(app.anchor, targets[0]); + + enter_repeat(&mut app, "9"); + app.update(Action::NextSection).unwrap(); + assert!(!app.repeat_active()); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[2] }) + ); + settle(&mut app); + assert_eq!(app.anchor, targets[2]); + assert_eq!(app.update(Action::NextSection).unwrap(), Outcome::Unchanged); + + enter_repeat(&mut app, "9"); + assert_eq!( + app.update(Action::PreviousSection).unwrap(), + Outcome::Changed + ); + settle(&mut app); + assert_eq!(app.anchor, targets[0]); + assert_eq!( + app.update(Action::PreviousSection).unwrap(), + Outcome::Unchanged + ); + } + + #[test] + fn inline_section_targets_keep_precise_navigation_focus() { + let (document, targets) = inline_section_book(); + let mut app = App::new(document); + + app.update(Action::Resize(Geometry::new(120, 4))).unwrap(); + settle_frame(&mut app); + assert!(app.anchor < targets[0]); + assert_eq!(app.section_focus, Some(targets[0])); + assert_eq!( + app.render_state().unwrap().book.unwrap().section_ordinal, + Some(1) + ); + + app.update(Action::LineUp).unwrap(); + settle(&mut app); + assert_eq!(app.section_focus, Some(targets[0])); + + app.update(Action::NextSection).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[1] }) + ); + app.update(Action::NextSection).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[2] }) + ); + app.update(Action::PreviousSection).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[1] }) + ); + settle(&mut app); + assert!(app.anchor < targets[1]); + assert_eq!(app.section_focus, Some(targets[1])); + assert_eq!( + app.render_state().unwrap().book.unwrap().section_ordinal, + Some(2) + ); + + app.update(Action::NextSection).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[2] }) + ); + app.update(Action::PreviousSection).unwrap(); + settle(&mut app); + + app.update(Action::Resize(Geometry::new(40, 4))).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[1] }) + ); + settle_frame(&mut app); + assert!(app.anchor < targets[1]); + assert_eq!(app.section_focus, Some(targets[1])); + assert_eq!( + app.render_state().unwrap().book.unwrap().section_ordinal, + Some(2) + ); + app.update(Action::NextSection).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { target: targets[2] }) + ); + + app.update(Action::LineDown).unwrap(); + settle(&mut app); + assert_eq!(app.section_focus, None); + } + + #[test] + fn final_section_stays_at_the_top_instead_of_backfilling_the_previous_section() { + let (mut app, offsets) = book_reader(120, 20); + + enter_repeat(&mut app, "9"); + app.update(Action::NextSection).unwrap(); + settle(&mut app); + + assert_eq!(app.anchor, offsets.section_targets[2]); + let body_height = app.geometry.body_height().unwrap(); + let state = app.render_state().unwrap(); + assert_eq!(state.rows.get(0).unwrap().text, "three starts"); + assert!(state.rows.len() < usize::from(body_height.get())); + assert!( + state + .rows + .iter() + .all(|row| !row.text.contains("second target")) + ); + } + + #[test] + fn book_width_reflows_and_section_motion_preserves_committed_search() { + let (mut app, offsets) = book_reader(120, 4); + let targets = offsets.section_targets; + assert_eq!(app.body_width(), 80); + + app.update(Action::Resize(Geometry::new(100, 4))).unwrap(); + settle_frame(&mut app); + assert_eq!(app.anchor, offsets.heading_starts[0]); + assert_eq!(app.body_width(), 80); + + app.update(Action::Resize(Geometry::new(60, 4))).unwrap(); + assert_eq!( + app.viewport_request, + Some(ViewportRequest::Section { + target: offsets.heading_starts[0] + }) + ); + settle_frame(&mut app); + assert_eq!(app.anchor, offsets.heading_starts[0]); + assert_eq!(app.body_width(), 56); + + app.update(Action::NextSection).unwrap(); + assert!(matches!( + app.viewport_request, + Some(ViewportRequest::Section { target }) if target == targets[1] + )); + app.update(Action::BeginSearch).unwrap(); + assert!(app.viewport_request.is_none()); + assert_eq!(app.anchor, offsets.heading_starts[0]); + app.update(Action::SearchCancel).unwrap(); + + commit(&mut app, "target"); + let first_match = app.current_match().expect("the fixture has search matches"); + assert!(matches!( + app.search_status(), + SearchStatus::Committed { + query: "target", + no_matches: false, + searching: false, + } + )); + + app.update(Action::NextSection).unwrap(); + settle(&mut app); + assert_eq!(app.anchor, targets[1]); + assert_eq!(app.current_match(), Some(first_match)); + assert!(matches!( + app.search_status(), + SearchStatus::Committed { + query: "target", + .. + } + )); + + app.update(Action::NextMatch).unwrap(); + settle(&mut app); + assert_eq!(app.section_focus, None); + assert_ne!(app.current_match(), Some(first_match)); + assert_eq!( + app.render_state().unwrap().book.unwrap().section_ordinal, + Some(2) + ); + } + fn projected_atom(text: &str) -> ProjectedAtom<'_> { DisplayAtoms::new(text) .next() @@ -3126,8 +3782,12 @@ mod tests { panic!("viewport locator exceeded the test step limit"); } - fn cache_location(app: &mut App, target: SourceOffset, delta: RowDelta) -> LocatedViewport { - let height = app.geometry.body_height().unwrap(); + fn cache_location_at_height( + app: &mut App, + target: SourceOffset, + delta: RowDelta, + height: BodyHeight, + ) -> LocatedViewport { let row_bound = source_row_bound(app.document.source_start(), app.document.source_end()); let mut locator = ViewportLocator::new(target, delta, height, row_bound).unwrap(); for _ in 0..BACKGROUND_STEP_LIMIT { @@ -3143,6 +3803,11 @@ mod tests { panic!("viewport cache priming exceeded the test step limit"); } + fn cache_location(app: &mut App, target: SourceOffset, delta: RowDelta) -> LocatedViewport { + let height = app.geometry.body_height().unwrap(); + cache_location_at_height(app, target, delta, height) + } + #[test] fn navigation_clamps_and_preserves_end_following_across_reflow() { let mut app = reader("0123456789abcdef", 16, 4); @@ -3376,9 +4041,9 @@ mod tests { for case in ViewportRequestCase::ALL { let mut app = reader(&text, 16, 7); let request = case.request(&mut app); - let height = app.geometry.body_height().unwrap(); + let height = request.locator_height(app.geometry.body_height().unwrap()); let (target, delta) = request.locator_parameters(app.document.source_end(), height); - let expected = cache_location(&mut app, target, delta); + let expected = cache_location_at_height(&mut app, target, delta, height); app.viewport_request = Some(request); app.locator = None; app.document_cache.reset_metrics(); @@ -3971,7 +4636,10 @@ mod tests { assert!(app.frame_ready()); assert!(matches!( app.view_state().unwrap(), - ViewState::Help { q_closes: true } + ViewState::Help { + q_closes: true, + book: false + } )); assert_eq!(app.background_work(), Some(BackgroundWork::Render)); assert!(!app.advance_background().unwrap()); @@ -4111,7 +4779,10 @@ mod tests { )); assert!(matches!( app.view_state().unwrap(), - ViewState::Help { q_closes: false } + ViewState::Help { + q_closes: false, + book: false + } )); assert_eq!(app.update(Action::LineDown).unwrap(), Outcome::Unchanged); @@ -4135,7 +4806,10 @@ mod tests { app.update(Action::ShowHelp).unwrap(); assert!(matches!( app.view_state().unwrap(), - ViewState::Help { q_closes: true } + ViewState::Help { + q_closes: true, + book: false + } )); assert_eq!(app.pending_highlight_cursors(), pending); app.update(Action::DismissHelp).unwrap(); @@ -4147,7 +4821,10 @@ mod tests { app.update(Action::ShowHelp).unwrap(); assert!(matches!( app.view_state().unwrap(), - ViewState::Help { q_closes: true } + ViewState::Help { + q_closes: true, + book: false + } )); assert_eq!(app.published_highlight_count(), published); } @@ -5835,9 +6512,9 @@ mod tests { let mut app = App::new(crate::document::load(path.clone()).unwrap()); app.update(Action::Resize(Geometry::new(16, 7))).unwrap(); let request = case.request(&mut app); - let height = app.geometry.body_height().unwrap(); + let height = request.locator_height(app.geometry.body_height().unwrap()); let (target, delta) = request.locator_parameters(app.document.source_end(), height); - let expected = cache_location(&mut app, target, delta); + let expected = cache_location_at_height(&mut app, target, delta, height); let key = app.layout.as_ref().unwrap().row_cache_key(); assert_eq!( app.row_neighborhood.locate_target( diff --git a/src/cli.rs b/src/cli.rs index 975878d..23e0e33 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,14 +9,14 @@ use crate::error::InvocationError; pub const USAGE: &str = "Usage: tut [OPTION]... FILE"; pub const HELP: &str = "\ Usage: tut [OPTION]... FILE -Read UTF-8 text from FILE in the terminal. +Read UTF-8 text or EPUB books in the terminal. -h, --help display this help and exit -V, --version output version information and exit --log-file=FILE append typed session events to FILE -With FILE -, read standard input. +With FILE -, read UTF-8 text from standard input. Use -- before a FILE whose name begins with '-'. For a file named '-', use ./-. TUT_LOG_FILE is used when --log-file is not specified. @@ -260,7 +260,7 @@ mod tests { fn help_and_version_follow_gnu_output_conventions() { assert!(HELP.starts_with("Usage: tut ")); assert!(HELP.ends_with('\n')); - assert!(HELP.contains("With FILE -, read standard input.")); + assert!(HELP.contains("With FILE -, read UTF-8 text from standard input.")); assert!(HELP.contains("--log-file=FILE")); assert!(HELP.contains("TUT_LOG_FILE")); assert!(HELP.contains("For a file named '-', use ./-.")); diff --git a/src/document.rs b/src/document.rs index 7c4aeee..712345f 100644 --- a/src/document.rs +++ b/src/document.rs @@ -25,12 +25,188 @@ use crate::{ pub const MAX_FILE_BYTES: u64 = 33_554_432; pub(super) const SOURCE_WINDOW_BYTES: usize = 64 * 1024; +pub(super) const MAX_BOOK_SECTIONS: usize = 4_096; +pub(super) const MAX_BOOK_HEADINGS: usize = 4_096; +pub(super) const MAX_BOOK_TITLE_BYTES: usize = 1_024; +pub(super) const MAX_BOOK_TITLE_TOTAL_BYTES: usize = 1024 * 1024; const STANDARD_INPUT_NAME: &str = "standard input"; const UTF8_BOM_BYTES: usize = 3; const UTF8_BOUNDARY_SLOP_BYTES: usize = 3; const MAX_GRAPHEME_BYTES: usize = 1024 * 1024; static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct BookSection { + title: String, + level: u8, + target: SourceOffset, +} + +impl BookSection { + pub(super) fn new(title: String, level: u8, target: SourceOffset) -> Option { + (!title.is_empty() && title.len() <= MAX_BOOK_TITLE_BYTES && level != 0).then_some(Self { + title, + level, + target, + }) + } + + pub(super) fn title(&self) -> &str { + &self.title + } + + pub(super) const fn level(&self) -> u8 { + self.level + } + + pub(super) const fn target(&self) -> SourceOffset { + self.target + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BookHeading { + level: u8, + start: SourceOffset, + end: SourceOffset, +} + +impl BookHeading { + pub(super) fn new(level: u8, start: SourceOffset, end: SourceOffset) -> Option { + ((1..=6).contains(&level) && start < end).then_some(Self { level, start, end }) + } + + pub(super) const fn level(&self) -> u8 { + self.level + } + + pub(super) const fn start(&self) -> SourceOffset { + self.start + } + + pub(super) const fn end(&self) -> SourceOffset { + self.end + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct BookStructure { + title: Option, + creator: Option, + sections: Vec, + headings: Vec, +} + +impl BookStructure { + pub(super) fn new( + title: Option, + creator: Option, + mut sections: Vec, + mut headings: Vec, + ) -> Self { + let mut title_bytes = 0_usize; + let title = bounded_book_field(title, &mut title_bytes); + let creator = bounded_book_field(creator, &mut title_bytes); + + sections.sort_by_key(BookSection::target); + sections.truncate(MAX_BOOK_SECTIONS); + sections.retain(|section| { + if section.level() == 0 { + return false; + } + title_bytes + .checked_add(section.title.len()) + .filter(|total| *total <= MAX_BOOK_TITLE_TOTAL_BYTES) + .is_some_and(|total| { + title_bytes = total; + true + }) + }); + headings.sort_by_key(BookHeading::start); + headings.truncate(MAX_BOOK_HEADINGS); + + Self { + title, + creator, + sections, + headings, + } + } + + pub(super) fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + pub(super) fn creator(&self) -> Option<&str> { + self.creator.as_deref() + } + + pub(super) fn sections(&self) -> &[BookSection] { + &self.sections + } + + pub(super) fn headings(&self) -> &[BookHeading] { + &self.headings + } + + pub(super) fn preferred_start(&self) -> Option { + self.headings + .first() + .map(BookHeading::start) + .or_else(|| self.sections.first().map(BookSection::target)) + } + + pub(super) fn section_at_or_before(&self, at: SourceOffset) -> Option<&BookSection> { + let index = self + .sections + .partition_point(|section| section.target <= at); + index + .checked_sub(1) + .and_then(|index| self.sections.get(index)) + } + + pub(super) fn previous_section(&self, at: SourceOffset) -> Option<&BookSection> { + let index = self.sections.partition_point(|section| section.target < at); + index + .checked_sub(1) + .and_then(|index| self.sections.get(index)) + } + + pub(super) fn next_section(&self, at: SourceOffset) -> Option<&BookSection> { + let index = self + .sections + .partition_point(|section| section.target <= at); + self.sections.get(index) + } + + fn retain_source_range(&mut self, source_start: SourceOffset, source_end: SourceOffset) { + self.sections + .retain(|section| source_start <= section.target && section.target < source_end); + self.headings.retain(|heading| { + source_start <= heading.start + && heading.start < heading.end + && heading.end <= source_end + }); + } + + fn is_empty(&self) -> bool { + self.title.is_none() + && self.creator.is_none() + && self.sections.is_empty() + && self.headings.is_empty() + } +} + +fn bounded_book_field(value: Option, total: &mut usize) -> Option { + let value = value.filter(|value| !value.is_empty() && value.len() <= MAX_BOOK_TITLE_BYTES)?; + let next = total.checked_add(value.len())?; + if next > MAX_BOOK_TITLE_TOTAL_BYTES { + return None; + } + *total = next; + Some(value) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(transparent)] pub(super) struct DocumentId(NonZeroU64); @@ -65,6 +241,7 @@ pub(super) struct Document { path: PathBuf, display_path: String, display_name: String, + book: Option, } impl Document { @@ -101,6 +278,17 @@ impl Document { &self.display_name } + pub(super) fn book(&self) -> Option<&BookStructure> { + self.book.as_ref() + } + + pub(super) fn preferred_start(&self) -> SourceOffset { + self.book + .as_ref() + .and_then(BookStructure::preferred_start) + .unwrap_or(self.source_start) + } + pub(super) fn validate(&self) -> Result<(), LoadError> { self.store.validate() } @@ -176,6 +364,13 @@ impl Document { Self::from_parts(path, store, line_index) } + #[cfg(test)] + pub(super) fn from_book_text(path: &Path, text: String, book: BookStructure) -> Self { + let mut document = Self::from_text(path, text); + document.book = Some(book); + document + } + #[cfg(test)] fn new(path: &Path, source: String) -> Result { let store = DocumentStore::in_memory(source); @@ -221,6 +416,7 @@ impl Document { path, display_path, display_name, + book: None, } } } @@ -1172,6 +1368,28 @@ pub(super) fn load(path: PathBuf) -> Result { )) } +pub(super) fn open_source(path: PathBuf) -> Result { + SourceFile::open(path, MAX_FILE_BYTES) +} + +pub(super) fn load_snapshot( + source: SourceFile, + file: File, + source_len: u64, + book: Option, +) -> Result { + let store = source.into_snapshot(file, source_len)?; + let path = store.path.clone(); + let line_index = LineIndex::new(store.source_start, store.source_end) + .map_err(|error| map_line_index_error(&path, error))?; + let mut document = Document::from_parts(&path, DocumentStore::File(store), line_index); + document.book = book.and_then(|mut book| { + book.retain_source_range(document.source_start, document.source_end); + (!book.is_empty()).then_some(book) + }); + Ok(document) +} + #[cfg(test)] fn build_line_index(store: &DocumentStore, path: &Path) -> Result { let source_start = store.source_start(); @@ -1210,6 +1428,7 @@ struct FileStore { file: File, path: PathBuf, path_binding: Option, + origin: Option>, source_start: SourceOffset, source_end: SourceOffset, stability: FileStability, @@ -1235,6 +1454,20 @@ struct FileFingerprint { changed_nanoseconds: i64, } +#[derive(Debug)] +pub(super) struct SourceFile { + file: File, + path: PathBuf, + path_binding: InputPathBinding, + fingerprint: FileFingerprint, +} + +#[derive(Debug)] +struct OriginFile { + file: File, + fingerprint: FileFingerprint, +} + #[derive(Debug, Clone, Copy)] pub(super) struct InputIdentity<'a> { path: &'a InputPathBinding, @@ -1357,7 +1590,7 @@ pub(super) fn overwrite_with_distinct_fingerprint(path: &Path, contents: impl As ); } -impl FileStore { +impl SourceFile { fn open(path: PathBuf, limit: u64) -> Result { let bound = BoundPath::capture(&path).map_err(|source| LoadError::Open { path: path.clone(), @@ -1383,16 +1616,7 @@ impl FileStore { resolved: resolved.into_anchor(), lexical_location, }; - - Self::from_tracked_file_with_identity(File::from(descriptor), path, path_binding, limit) - } - - fn from_tracked_file_with_identity( - file: File, - path: PathBuf, - path_binding: InputPathBinding, - limit: u64, - ) -> Result { + let file = File::from(descriptor); let metadata = file.metadata().map_err(|source| LoadError::Read { path: path.clone(), source, @@ -1404,27 +1628,114 @@ impl FileStore { return Err(LoadError::TooLarge { path, limit }); } - Self::new( + Ok(Self { + file, + path, + path_binding, + fingerprint: FileFingerprint::from_metadata(&metadata), + }) + } + + pub(super) fn try_clone(&self) -> Result { + self.file.try_clone().map_err(|source| LoadError::Read { + path: self.path.clone(), + source, + }) + } + + pub(super) const fn len(&self) -> u64 { + self.fingerprint.length + } + + pub(super) fn validate(&self) -> Result<(), LoadError> { + let metadata = self.file.metadata().map_err(|source| LoadError::Read { + path: self.path.clone(), + source, + })?; + if FileFingerprint::from_metadata(&metadata) != self.fingerprint { + return Err(LoadError::Read { + path: self.path.clone(), + source: io::Error::new(io::ErrorKind::InvalidData, "file changed while reading"), + }); + } + Ok(()) + } + + fn into_store(self) -> Result { + let source_len = self.fingerprint.length; + FileStore::new( + self.file, + self.path, + source_len, + FileStability::Tracked(self.fingerprint), + Some(self.path_binding), + None, + ) + } + + fn into_snapshot(self, file: File, source_len: u64) -> Result { + let Self { + file: origin, + path, + path_binding, + fingerprint, + } = self; + FileStore::new( file, path, - metadata.len(), - FileStability::Tracked(FileFingerprint::from_metadata(&metadata)), + source_len, + FileStability::PrivateSnapshot, Some(path_binding), + Some(Box::new(OriginFile { + file: origin, + fingerprint, + })), ) } +} + +impl OriginFile { + fn validate(&self, path: &Path) -> Result<(), LoadError> { + let metadata = self.file.metadata().map_err(|source| LoadError::Read { + path: path.to_path_buf(), + source, + })?; + if FileFingerprint::from_metadata(&metadata) != self.fingerprint { + return Err(LoadError::Read { + path: path.to_path_buf(), + source: io::Error::new(io::ErrorKind::InvalidData, "file changed while reading"), + }); + } + Ok(()) + } +} + +impl FileStore { + fn open(path: PathBuf, limit: u64) -> Result { + SourceFile::open(path, limit)?.into_store() + } fn from_private_snapshot( file: File, path: PathBuf, source_len: u64, ) -> Result { - Self::new(file, path, source_len, FileStability::PrivateSnapshot, None) + Self::new( + file, + path, + source_len, + FileStability::PrivateSnapshot, + None, + None, + ) } fn input_identity(&self) -> Option> { let path = self.path_binding.as_ref()?; - let FileStability::Tracked(fingerprint) = self.stability else { - return None; + let fingerprint = match (&self.origin, self.stability) { + (Some(origin), _) => origin.fingerprint, + (None, FileStability::Tracked(fingerprint)) => fingerprint, + (None, FileStability::PrivateSnapshot) => return None, }; Some(InputIdentity { path, @@ -1438,15 +1749,20 @@ impl FileStore { source_len: u64, stability: FileStability, path_binding: Option, + origin: Option>, ) -> Result { debug_assert_eq!( - path_binding.is_some(), - matches!(stability, FileStability::Tracked(_)) + (path_binding.is_some(), origin.is_some()), + match stability { + FileStability::Tracked(_) => (true, false), + FileStability::PrivateSnapshot => (origin.is_some(), origin.is_some()), + } ); let mut store = Self { file, path, path_binding, + origin, source_start: SourceOffset::ZERO, source_end: SourceOffset::new(source_len), stability, @@ -1740,6 +2056,9 @@ impl FileStore { } fn require_unchanged(&self) -> Result<(), LoadError> { + if let Some(origin) = &self.origin { + return origin.validate(&self.path); + } let FileStability::Tracked(fingerprint) = self.stability else { return Ok(()); }; @@ -2276,6 +2595,144 @@ mod tests { let document = Document::from_text(&path, "text".to_owned()); assert_eq!(document.display_path(), "/tmp/book.txt"); assert_eq!(document.source().as_str(), "text"); + assert!(document.book().is_none()); + assert_eq!(document.preferred_start(), document.source_start()); + } + + #[test] + fn book_structure_orders_sections_and_locates_navigation_targets() { + let first = BookSection::new("First".to_owned(), 1, SourceOffset::new(4)).unwrap(); + let second = BookSection::new("Second".to_owned(), 2, SourceOffset::new(20)).unwrap(); + let first_heading = + BookHeading::new(1, SourceOffset::new(4), SourceOffset::new(9)).unwrap(); + let second_heading = + BookHeading::new(2, SourceOffset::new(20), SourceOffset::new(26)).unwrap(); + let book = BookStructure::new( + Some("Book".to_owned()), + Some("Writer".to_owned()), + vec![second, first], + vec![second_heading, first_heading], + ); + + assert_eq!(book.title(), Some("Book")); + assert_eq!(book.creator(), Some("Writer")); + assert_eq!(book.preferred_start(), Some(SourceOffset::new(4))); + assert_eq!(book.sections()[0].title(), "First"); + assert_eq!(book.sections()[1].title(), "Second"); + assert_eq!(book.sections()[0].target(), SourceOffset::new(4)); + assert_eq!(book.headings()[0].level(), 1); + assert_eq!(book.headings()[0].start(), SourceOffset::new(4)); + assert_eq!(book.headings()[0].end(), SourceOffset::new(9)); + assert!(book.section_at_or_before(SourceOffset::new(3)).is_none()); + assert_eq!( + book.section_at_or_before(SourceOffset::new(20)) + .map(BookSection::title), + Some("Second") + ); + assert_eq!( + book.previous_section(SourceOffset::new(19)) + .map(BookSection::title), + Some("First") + ); + assert_eq!( + book.previous_section(SourceOffset::new(20)) + .map(BookSection::title), + Some("First") + ); + assert_eq!( + book.previous_section(SourceOffset::new(21)) + .map(BookSection::title), + Some("Second") + ); + assert_eq!( + book.next_section(SourceOffset::new(4)) + .map(BookSection::title), + Some("Second") + ); + assert_eq!( + book.next_section(SourceOffset::new(3)) + .map(BookSection::title), + Some("First") + ); + + let document = Document::from_book_text( + Path::new("book.txt"), + "xxxxFirst text.......Second text".to_owned(), + book, + ); + assert_eq!(document.preferred_start(), SourceOffset::new(4)); + } + + #[test] + fn book_structure_limits_degrade_metadata_and_section_indexes() { + let mut sections = Vec::new(); + for index in (0..=MAX_BOOK_SECTIONS).rev() { + let target = SourceOffset::from_usize(index * 2); + sections.push(BookSection::new("x".to_owned(), 1, target).unwrap()); + } + let headings = (0..=MAX_BOOK_HEADINGS) + .rev() + .map(|index| { + let start = SourceOffset::from_usize(index * 2); + BookHeading::new(1, start, start.checked_add(1).unwrap()).unwrap() + }) + .collect(); + let book = BookStructure::new( + Some("x".repeat(MAX_BOOK_TITLE_BYTES + 1)), + Some("Writer".to_owned()), + sections, + headings, + ); + assert_eq!(book.title(), None); + assert_eq!(book.creator(), Some("Writer")); + assert_eq!(book.sections().len(), MAX_BOOK_SECTIONS); + assert!( + book.sections() + .windows(2) + .all(|pair| pair[0].target() < pair[1].target()) + ); + assert_eq!(book.headings().len(), MAX_BOOK_HEADINGS); + assert!( + book.headings() + .windows(2) + .all(|pair| pair[0].start() < pair[1].start()) + ); + + let title = "x".repeat(MAX_BOOK_TITLE_BYTES); + let mut sections = Vec::new(); + for index in 0..=MAX_BOOK_TITLE_TOTAL_BYTES / MAX_BOOK_TITLE_BYTES { + let target = SourceOffset::from_usize(index * 2); + sections.push(BookSection::new(title.clone(), 1, target).unwrap()); + } + let book = BookStructure::new(None, None, sections, Vec::new()); + assert_eq!( + book.sections() + .iter() + .map(|section| section.title().len()) + .sum::(), + MAX_BOOK_TITLE_TOTAL_BYTES + ); + } + + #[test] + fn book_structure_drops_ranges_outside_the_attached_source() { + let valid = BookSection::new("Valid".to_owned(), 1, SourceOffset::new(2)).unwrap(); + let invalid = BookSection::new("Invalid".to_owned(), 1, SourceOffset::new(12)).unwrap(); + let valid_heading = + BookHeading::new(1, SourceOffset::new(2), SourceOffset::new(4)).unwrap(); + let invalid_heading = + BookHeading::new(1, SourceOffset::new(8), SourceOffset::new(12)).unwrap(); + let mut book = BookStructure::new( + None, + None, + vec![invalid, valid], + vec![invalid_heading, valid_heading], + ); + book.retain_source_range(SourceOffset::ZERO, SourceOffset::new(10)); + assert_eq!(book.sections().len(), 1); + assert_eq!(book.sections()[0].title(), "Valid"); + assert_eq!(book.headings().len(), 1); + assert_eq!(book.headings()[0].start(), SourceOffset::new(2)); } #[test] diff --git a/src/epub.rs b/src/epub.rs new file mode 100644 index 0000000..c4711e5 --- /dev/null +++ b/src/epub.rs @@ -0,0 +1,2528 @@ +use std::{ + collections::HashSet, + fs::File, + io::{self, BufWriter, Read, Seek, SeekFrom, Write}, + os::unix::fs::FileExt, + path::{Path, PathBuf}, +}; + +use percent_encoding::percent_decode_str; +use quick_xml::{ + Reader as XmlReader, XmlVersion, + encoding::Decoder, + escape::resolve_predefined_entity, + events::{BytesStart, Event}, +}; +use rbook::{ + Epub, + epub::{reader::LinearBehavior, toc::EpubTocEntry}, +}; +use zip::{CompressionMethod, ZipArchive}; + +use crate::{ + document::{ + self, BookHeading, BookSection, BookStructure, Document, MAX_BOOK_HEADINGS, + MAX_BOOK_SECTIONS, MAX_BOOK_TITLE_BYTES, MAX_BOOK_TITLE_TOTAL_BYTES, MAX_FILE_BYTES, + SOURCE_WINDOW_BYTES, SourceFile, + }, + error::LoadError, + source::SourceOffset, +}; + +const MAX_ENTRIES: usize = 4_096; +const MAX_ENTRY_NAME_BYTES: usize = 4_096; +const MAX_ENTRY_NAME_TOTAL_BYTES: usize = 4 * 1024 * 1024; +const MAX_CENTRAL_DIRECTORY_BYTES: u64 = 8 * 1024 * 1024; +const MAX_ENTRY_BYTES: u64 = 8 * 1024 * 1024; +const MAX_EXPANDED_BYTES: u64 = 256 * 1024 * 1024; +const MAX_SPINE_BYTES: u64 = 128 * 1024 * 1024; +const MAX_XML_DEPTH: usize = 128; +const MAX_TOC_DEPTH: usize = 64; +const MAX_TOC_FRAGMENT_BYTES: usize = 4_096; +const MAX_TOC_FRAGMENT_TOTAL_BYTES: usize = 1024 * 1024; +const EPUB_MIMETYPE: &[u8] = b"application/epub+zip"; +const EOCD_SIGNATURE: &[u8; 4] = b"PK\x05\x06"; +const ZIP64_EOCD_SIGNATURE: &[u8; 4] = b"PK\x06\x06"; +const ZIP64_LOCATOR_SIGNATURE: &[u8; 4] = b"PK\x06\x07"; +const EOCD_LEN: usize = 22; +const MAX_ZIP_COMMENT_BYTES: usize = u16::MAX as usize; + +#[derive(Debug, Clone, Copy)] +struct ArchiveLimits { + entries: usize, + entry_name_bytes: usize, + entry_name_total_bytes: usize, + central_directory_bytes: u64, + entry_bytes: u64, + expanded_bytes: u64, +} + +const ARCHIVE_LIMITS: ArchiveLimits = ArchiveLimits { + entries: MAX_ENTRIES, + entry_name_bytes: MAX_ENTRY_NAME_BYTES, + entry_name_total_bytes: MAX_ENTRY_NAME_TOTAL_BYTES, + central_directory_bytes: MAX_CENTRAL_DIRECTORY_BYTES, + entry_bytes: MAX_ENTRY_BYTES, + expanded_bytes: MAX_EXPANDED_BYTES, +}; + +pub(super) fn is_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("epub")) +} + +pub(super) fn load(path: PathBuf) -> Result { + let source = document::open_source(path.clone())?; + let archive = snapshot_archive(&source, &path)?; + inspect_eocd(&archive, &path, ARCHIVE_LIMITS)?; + preflight( + archive + .try_clone() + .map_err(|source| LoadError::BufferEpub { + path: path.clone(), + source, + })?, + &path, + ARCHIVE_LIMITS, + )?; + + let (epub, has_toc) = read_epub(archive, &path)?; + let metadata = epub.metadata(); + let title = metadata + .title() + .and_then(|title| collapsed_text(title.value())); + let creator = metadata + .creators() + .next() + .and_then(|creator| collapsed_text(creator.value())); + let mut toc = if has_toc { + TocTargets::collect(&epub) + } else { + TocTargets::default() + }; + let mut reader = epub + .reader_builder() + .linear_behavior(LinearBehavior::LinearOnly) + .create(); + if reader.is_empty() { + return Err(LoadError::NoLinearEpubContent(path)); + } + + let file = tempfile::tempfile().map_err(|source| LoadError::BufferEpub { + path: path.clone(), + source, + })?; + let mut output = TextWriter::new(file, path.clone(), MAX_FILE_BYTES); + let mut headings = HeadingCollector::default(); + let mut spine_bytes = 0_u64; + + for chapter in &mut reader { + let chapter = chapter.map_err(|source| invalid_epub(&path, source))?; + let length = + u64::try_from(chapter.content().len()).expect("strings fit EPUB source coordinates"); + if length > MAX_ENTRY_BYTES { + return Err(LoadError::EpubEntryTooLarge { + path, + limit: MAX_ENTRY_BYTES, + }); + } + add_spine_bytes(&mut spine_bytes, length, MAX_SPINE_BYTES, &path)?; + + output.chapter_break(); + let manifest_index = chapter.manifest_entry().index(); + let chapter_targets = toc.for_manifest(manifest_index); + match chapter.manifest_entry().media_type() { + "application/xhtml+xml" => { + project_xhtml( + chapter.content(), + &mut output, + &mut headings, + chapter_targets, + &path, + )?; + } + "image/svg+xml" => { + let mut resolver = ChapterTargetResolver::new(chapter_targets); + if resolver.has_pending() { + output.capture_next_content(); + } + output.image(None)?; + resolver.resolve_capture(&mut output); + resolver.finish(&mut output); + } + _ => { + return Err(invalid_epub_message( + &path, + "spine contains unsupported content", + )); + } + } + } + + let (file, length) = output.finish()?; + let navigation = toc.finish(); + let (sections, headings) = headings.finish(navigation); + let book = + (title.is_some() || creator.is_some() || !sections.is_empty() || !headings.is_empty()) + .then(|| BookStructure::new(title, creator, sections, headings)); + source.validate()?; + document::load_snapshot(source, file, length, book) +} + +fn add_spine_bytes(total: &mut u64, length: u64, limit: u64, path: &Path) -> Result<(), LoadError> { + let next = total + .checked_add(length) + .filter(|length| *length <= limit) + .ok_or_else(|| LoadError::EpubExpandedTooLarge { + path: path.to_path_buf(), + limit, + })?; + *total = next; + Ok(()) +} + +fn read_epub(archive: File, path: &Path) -> Result<(Epub, bool), LoadError> { + let with_toc = archive + .try_clone() + .map_err(|source| LoadError::BufferEpub { + path: path.to_path_buf(), + source, + })?; + match Epub::options().strict(false).read(with_toc) { + Ok(epub) => Ok((epub, true)), + Err(_) => Epub::options() + .strict(false) + .skip_toc(true) + .read(archive) + .map(|epub| (epub, false)) + .map_err(|source| invalid_epub(path, source)), + } +} + +fn snapshot_archive(source: &SourceFile, path: &Path) -> Result { + source.validate()?; + let mut input = source.try_clone()?; + input + .seek(SeekFrom::Start(0)) + .map_err(|source| LoadError::Read { + path: path.to_path_buf(), + source, + })?; + let mut output = tempfile::tempfile().map_err(|source| LoadError::BufferEpub { + path: path.to_path_buf(), + source, + })?; + let mut buffer = [0_u8; SOURCE_WINDOW_BYTES]; + let mut remaining = source.len(); + + while remaining != 0 { + let request = usize::try_from(remaining.min(SOURCE_WINDOW_BYTES as u64)) + .expect("bounded EPUB reads fit usize"); + let count = loop { + match input.read(&mut buffer[..request]) { + Ok(count) => break count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(source) => { + return Err(LoadError::Read { + path: path.to_path_buf(), + source, + }); + } + } + }; + if count == 0 { + return Err(changed(path)); + } + output + .write_all(&buffer[..count]) + .map_err(|source| LoadError::BufferEpub { + path: path.to_path_buf(), + source, + })?; + remaining -= u64::try_from(count).expect("source windows fit u64"); + } + source.validate()?; + Ok(output) +} + +fn changed(path: &Path) -> LoadError { + LoadError::Read { + path: path.to_path_buf(), + source: io::Error::new(io::ErrorKind::InvalidData, "file changed while reading"), + } +} + +fn inspect_eocd(file: &File, path: &Path, limits: ArchiveLimits) -> Result<(), LoadError> { + let length = file + .metadata() + .map_err(|source| LoadError::BufferEpub { + path: path.to_path_buf(), + source, + })? + .len(); + if length < EOCD_LEN as u64 { + return Err(invalid_epub_message(path, "ZIP end record is missing")); + } + + let tail_len = usize::try_from( + length.min( + u64::try_from(EOCD_LEN + MAX_ZIP_COMMENT_BYTES) + .expect("ZIP end-record search length fits u64"), + ), + ) + .expect("bounded ZIP end-record searches fit usize"); + let mut tail = Vec::new(); + tail.try_reserve_exact(tail_len) + .map_err(|_| LoadError::Allocation("EPUB ZIP end-record buffer"))?; + tail.resize(tail_len, 0); + read_exact_at(file, &mut tail, length - tail_len as u64) + .map_err(|source| invalid_epub(path, source))?; + + let Some(position) = (0..=tail_len - EOCD_LEN).rev().find(|position| { + tail[*position..].starts_with(EOCD_SIGNATURE) + && *position + EOCD_LEN + usize::from(le_u16(&tail[*position + 20..])) == tail_len + }) else { + return Err(invalid_epub_message(path, "ZIP end record is invalid")); + }; + let record = &tail[position..position + EOCD_LEN]; + let eocd_offset = length - tail_len as u64 + position as u64; + let disk = le_u16(&record[4..]); + let central_disk = le_u16(&record[6..]); + let disk_entries = le_u16(&record[8..]); + let entries = le_u16(&record[10..]); + let central_size = le_u32(&record[12..]); + let central_offset = le_u32(&record[16..]); + + let uses_zip64 = disk_entries == u16::MAX + || entries == u16::MAX + || central_size == u32::MAX + || central_offset == u32::MAX; + let (entries, central_size, central_offset, central_end) = if uses_zip64 { + inspect_zip64(file, path, eocd_offset)? + } else { + if disk != 0 || central_disk != 0 || disk_entries != entries { + return Err(invalid_epub_message(path, "multi-disk ZIP is unsupported")); + } + ( + u64::from(entries), + u64::from(central_size), + u64::from(central_offset), + eocd_offset, + ) + }; + + if entries > limits.entries as u64 { + return Err(LoadError::EpubTooManyEntries { + path: path.to_path_buf(), + limit: limits.entries, + }); + } + if central_size > limits.central_directory_bytes { + return Err(invalid_epub_message( + path, + "ZIP central directory exceeds its limit", + )); + } + if central_offset + .checked_add(central_size) + .is_none_or(|end| end > central_end) + { + return Err(invalid_epub_message( + path, + "ZIP central directory is out of bounds", + )); + } + Ok(()) +} + +fn inspect_zip64( + file: &File, + path: &Path, + eocd_offset: u64, +) -> Result<(u64, u64, u64, u64), LoadError> { + let locator_offset = eocd_offset + .checked_sub(20) + .ok_or_else(|| invalid_epub_message(path, "ZIP64 locator is missing"))?; + let mut locator = [0_u8; 20]; + read_exact_at(file, &mut locator, locator_offset) + .map_err(|source| invalid_epub(path, source))?; + if !locator.starts_with(ZIP64_LOCATOR_SIGNATURE) + || le_u32(&locator[4..]) != 0 + || le_u32(&locator[16..]) != 1 + { + return Err(invalid_epub_message(path, "ZIP64 locator is invalid")); + } + + let record_offset = le_u64(&locator[8..]); + if record_offset + .checked_add(56) + .is_none_or(|end| end > locator_offset) + { + return Err(invalid_epub_message( + path, + "ZIP64 end record is out of bounds", + )); + } + let mut record = [0_u8; 56]; + read_exact_at(file, &mut record, record_offset).map_err(|source| invalid_epub(path, source))?; + if !record.starts_with(ZIP64_EOCD_SIGNATURE) + || le_u64(&record[4..]) < 44 + || le_u32(&record[16..]) != 0 + || le_u32(&record[20..]) != 0 + || le_u64(&record[24..]) != le_u64(&record[32..]) + { + return Err(invalid_epub_message(path, "ZIP64 end record is invalid")); + } + Ok(( + le_u64(&record[32..]), + le_u64(&record[40..]), + le_u64(&record[48..]), + record_offset, + )) +} + +fn preflight(file: File, path: &Path, limits: ArchiveLimits) -> Result<(), LoadError> { + let mut archive = ZipArchive::new(file).map_err(|source| invalid_epub(path, source))?; + if archive.len() > limits.entries { + return Err(LoadError::EpubTooManyEntries { + path: path.to_path_buf(), + limit: limits.entries, + }); + } + let mut names = HashSet::new(); + names + .try_reserve(archive.len()) + .map_err(|_| LoadError::Allocation("EPUB entry index"))?; + let mut name_bytes = 0_usize; + let mut advertised_total = 0_u64; + let mut actual_total = 0_u64; + + for index in 0..archive.len() { + let metadata = archive + .by_index_raw(index) + .map_err(|source| invalid_epub(path, source))?; + if metadata.encrypted() { + return Err(invalid_epub_message( + path, + "encrypted EPUB entries are unsupported", + )); + } + if !matches!( + metadata.compression(), + CompressionMethod::Stored | CompressionMethod::Deflated + ) { + return Err(invalid_epub_message( + path, + "EPUB compression method is unsupported", + )); + } + let raw_name = metadata.name_raw(); + if raw_name.len() > limits.entry_name_bytes { + return Err(invalid_epub_message( + path, + "EPUB entry name exceeds its limit", + )); + } + name_bytes = name_bytes + .checked_add(raw_name.len()) + .filter(|length| *length <= limits.entry_name_total_bytes) + .ok_or_else(|| invalid_epub_message(path, "EPUB entry names exceed their limit"))?; + let name = std::str::from_utf8(raw_name).map_err(|source| invalid_epub(path, source))?; + if !safe_entry_name(name) { + return Err(invalid_epub_message( + path, + "EPUB contains an unsafe entry name", + )); + } + let mut owned_name = String::new(); + owned_name + .try_reserve_exact(name.len()) + .map_err(|_| LoadError::Allocation("EPUB entry name"))?; + owned_name.push_str(name); + if !names.insert(owned_name) { + return Err(invalid_epub_message( + path, + "EPUB contains duplicate entry names", + )); + } + let advertised = metadata.size(); + if advertised > limits.entry_bytes { + return Err(LoadError::EpubEntryTooLarge { + path: path.to_path_buf(), + limit: limits.entry_bytes, + }); + } + advertised_total = advertised_total + .checked_add(advertised) + .filter(|length| *length <= limits.expanded_bytes) + .ok_or_else(|| LoadError::EpubExpandedTooLarge { + path: path.to_path_buf(), + limit: limits.expanded_bytes, + })?; + let is_mimetype = + index == 0 && name == "mimetype" && metadata.compression() == CompressionMethod::Stored; + drop(metadata); + + let mut entry = archive + .by_index(index) + .map_err(|source| invalid_epub(path, source))?; + let actual = if index == 0 { + let mut value = Vec::new(); + value + .try_reserve_exact(EPUB_MIMETYPE.len() + 1) + .map_err(|_| LoadError::Allocation("EPUB mimetype"))?; + (&mut entry) + .take((EPUB_MIMETYPE.len() + 1) as u64) + .read_to_end(&mut value) + .map_err(|source| invalid_epub(path, source))?; + if !is_mimetype || value != EPUB_MIMETYPE { + return Err(invalid_epub_message(path, "EPUB mimetype entry is invalid")); + } + value.len() as u64 + } else { + io::copy( + &mut (&mut entry).take(limits.entry_bytes.saturating_add(1)), + &mut io::sink(), + ) + .map_err(|source| invalid_epub(path, source))? + }; + if actual > limits.entry_bytes { + return Err(LoadError::EpubEntryTooLarge { + path: path.to_path_buf(), + limit: limits.entry_bytes, + }); + } + if actual != advertised { + return Err(invalid_epub_message( + path, + "EPUB entry length does not match its ZIP record", + )); + } + actual_total = actual_total + .checked_add(actual) + .filter(|length| *length <= limits.expanded_bytes) + .ok_or_else(|| LoadError::EpubExpandedTooLarge { + path: path.to_path_buf(), + limit: limits.expanded_bytes, + })?; + } + Ok(()) +} + +fn safe_entry_name(name: &str) -> bool { + if name.is_empty() || name.starts_with('/') || name.contains(['\\', '\0']) { + return false; + } + let name = name.strip_suffix('/').unwrap_or(name); + !name.is_empty() + && name + .split('/') + .all(|component| !component.is_empty() && component != "." && component != "..") +} + +#[derive(Debug)] +struct TocTarget { + manifest_index: usize, + fragment: Option, + title: String, + level: u8, + ordinal: usize, + resolved: Option, + attempted: bool, + active: bool, + awaiting: bool, +} + +#[derive(Debug, Default)] +struct TocTargets { + targets: Vec, + visited: usize, + title_bytes: usize, + fragment_bytes: usize, + saturated: bool, +} + +impl TocTargets { + fn collect(epub: &Epub) -> Self { + let mut targets = Self::default(); + if let Some(root) = epub.toc().contents() { + targets.visit(root); + } + targets.targets.sort_unstable_by(|left, right| { + left.manifest_index + .cmp(&right.manifest_index) + .then_with(|| left.fragment.cmp(&right.fragment)) + .then_with(|| left.ordinal.cmp(&right.ordinal)) + }); + targets + } + + fn visit(&mut self, parent: EpubTocEntry<'_>) { + if self.saturated || parent.depth() >= MAX_TOC_DEPTH { + return; + } + for entry in parent.iter() { + if self.visited == MAX_BOOK_SECTIONS { + self.saturated = true; + return; + } + self.visited += 1; + self.push(entry); + self.visit(entry); + if self.saturated { + return; + } + } + } + + fn push(&mut self, entry: EpubTocEntry<'_>) { + let Some(href) = entry.href() else { + return; + }; + let Some(manifest) = entry.manifest_entry() else { + return; + }; + let Some(title) = collapsed_text(entry.label()) else { + return; + }; + let Ok(level) = u8::try_from(entry.depth()) else { + return; + }; + let Some(next_title_bytes) = self + .title_bytes + .checked_add(title.len()) + .filter(|total| *total <= MAX_BOOK_TITLE_TOTAL_BYTES) + else { + return; + }; + let fragment = match href.fragment().filter(|fragment| !fragment.is_empty()) { + Some(fragment) => { + let Some(fragment) = decoded_fragment(fragment) else { + return; + }; + let Some(next_fragment_bytes) = self + .fragment_bytes + .checked_add(fragment.len()) + .filter(|total| *total <= MAX_TOC_FRAGMENT_TOTAL_BYTES) + else { + return; + }; + self.fragment_bytes = next_fragment_bytes; + Some(fragment) + } + None => None, + }; + if self.targets.try_reserve(1).is_err() { + self.saturated = true; + return; + } + self.targets.push(TocTarget { + manifest_index: manifest.index(), + fragment, + title, + level, + ordinal: self.visited, + resolved: None, + attempted: false, + active: false, + awaiting: false, + }); + self.title_bytes = next_title_bytes; + } + + fn for_manifest(&mut self, manifest_index: usize) -> &mut [TocTarget] { + let start = self + .targets + .partition_point(|target| target.manifest_index < manifest_index); + let end = self + .targets + .partition_point(|target| target.manifest_index <= manifest_index); + let targets = &mut self.targets[start..end]; + for target in &mut *targets { + if !target.attempted { + target.attempted = true; + target.active = true; + target.awaiting = target.fragment.is_none(); + } + } + targets + } + + fn finish(mut self) -> Vec { + self.targets.sort_unstable_by_key(|target| { + ( + target.resolved.map(SourceOffset::get).unwrap_or(u64::MAX), + target.ordinal, + ) + }); + let mut sections = Vec::new(); + if sections.try_reserve(self.targets.len()).is_err() { + return sections; + } + let mut previous = None; + for target in self.targets { + let Some(offset) = target.resolved else { + continue; + }; + if previous == Some(offset) { + continue; + } + if let Some(section) = BookSection::new(target.title, target.level, offset) { + sections.push(section); + previous = Some(offset); + } + } + sections + } +} + +struct ChapterTargetResolver<'a> { + targets: &'a mut [TocTarget], + fragment_start: usize, + unresolved_fragments: usize, + pending: Vec, + active: bool, +} + +impl<'a> ChapterTargetResolver<'a> { + fn new(targets: &'a mut [TocTarget]) -> Self { + let fragment_start = targets.partition_point(|target| target.fragment.is_none()); + let mut pending = Vec::new(); + let mut active = targets.first().is_some_and(|target| target.active); + if active && pending.try_reserve_exact(targets.len()).is_err() { + active = false; + } + if active { + pending.extend(0..fragment_start); + } else { + for target in &mut *targets { + target.active = false; + target.awaiting = false; + } + } + let unresolved_fragments = if active { + targets.len() - fragment_start + } else { + 0 + }; + Self { + targets, + fragment_start, + unresolved_fragments, + pending, + active, + } + } + + fn has_pending(&self) -> bool { + !self.pending.is_empty() + } + + fn has_unresolved_fragments(&self) -> bool { + self.unresolved_fragments != 0 + } + + fn match_fragment(&mut self, value: &str) -> bool { + if !self.active || self.unresolved_fragments == 0 { + return false; + } + let fragments = &self.targets[self.fragment_start..]; + let start = fragments.partition_point(|target| { + target + .fragment + .as_deref() + .expect("fragment targets are partitioned") + < value + }); + let end = fragments.partition_point(|target| { + target + .fragment + .as_deref() + .expect("fragment targets are partitioned") + <= value + }); + if start == end { + return false; + } + for relative in start..end { + let index = self.fragment_start + relative; + let target = &mut self.targets[index]; + if !target.awaiting && target.resolved.is_none() { + target.awaiting = true; + self.pending.push(index); + self.unresolved_fragments -= 1; + } + } + true + } + + fn resolve_capture(&mut self, output: &mut TextWriter) { + let Some(offset) = output.take_captured_content_start() else { + return; + }; + for index in self.pending.drain(..) { + let target = &mut self.targets[index]; + target.resolved = Some(offset); + target.awaiting = false; + } + } + + fn finish(mut self, output: &mut TextWriter) { + output.cancel_content_capture(); + self.pending.clear(); + for target in self.targets { + target.active = false; + target.awaiting = false; + } + } +} + +fn decoded_fragment(fragment: &str) -> Option { + if fragment.len() > MAX_TOC_FRAGMENT_BYTES.saturating_mul(3) { + return None; + } + let decoded = percent_decode_str(fragment).decode_utf8().ok()?; + if decoded.is_empty() || decoded.len() > MAX_TOC_FRAGMENT_BYTES { + return None; + } + let mut owned = String::new(); + owned.try_reserve_exact(decoded.len()).ok()?; + owned.push_str(&decoded); + Some(owned) +} + +#[derive(Debug)] +struct CapturedHeading { + title: Option, + heading: BookHeading, +} + +#[derive(Debug, Default)] +struct HeadingCollector { + captured: Vec, + title_bytes: usize, + saturated: bool, +} + +impl HeadingCollector { + fn push(&mut self, title: String, level: u8, start: SourceOffset, end: SourceOffset) { + if self.saturated || self.captured.len() == MAX_BOOK_HEADINGS { + self.saturated = true; + return; + } + let heading = BookHeading::new(level, start, end); + let retained_title = self + .title_bytes + .checked_add(title.len()) + .filter(|total| *total <= MAX_BOOK_TITLE_TOTAL_BYTES); + let Some(heading) = heading else { + return; + }; + if self.captured.try_reserve(1).is_err() { + self.saturated = true; + return; + } + self.captured.push(CapturedHeading { + title: retained_title.map(|_| title), + heading, + }); + if let Some(total) = retained_title { + self.title_bytes = total; + } + } + + fn finish(self, mut sections: Vec) -> (Vec, Vec) { + let fallback = sections.is_empty(); + let mut headings = Vec::new(); + let retain_headings = headings.try_reserve(self.captured.len()).is_ok(); + let retain_fallback = !fallback || sections.try_reserve(self.captured.len()).is_ok(); + + for captured in self.captured { + if retain_headings { + headings.push(captured.heading); + } + if fallback + && retain_fallback + && let Some(title) = captured.title + && let Some(section) = + BookSection::new(title, captured.heading.level(), captured.heading.start()) + { + sections.push(section); + } + } + (sections, headings) + } +} + +#[derive(Debug)] +struct ActiveHeading { + depth: usize, + level: u8, + start: SourceOffset, + title: CollapsedText, +} + +#[derive(Debug, Default)] +struct CollapsedText { + text: String, + pending_space: bool, + truncated: bool, + discarded: bool, +} + +impl CollapsedText { + fn push(&mut self, value: &str) { + if self.truncated || self.discarded { + return; + } + let reserve = value + .len() + .saturating_add(1) + .min(MAX_BOOK_TITLE_BYTES.saturating_sub(self.text.len())); + if self.text.try_reserve(reserve).is_err() { + self.text.clear(); + self.discarded = true; + return; + } + + for character in value.chars() { + if character.is_whitespace() { + self.space(); + continue; + } + let separator = usize::from(self.pending_space && !self.text.is_empty()); + let required = separator.saturating_add(character.len_utf8()); + if self.text.len().saturating_add(required) > MAX_BOOK_TITLE_BYTES { + self.truncate(); + return; + } + if separator != 0 { + self.text.push(' '); + } + self.text.push(character); + self.pending_space = false; + } + } + + fn space(&mut self) { + if !self.text.is_empty() { + self.pending_space = true; + } + } + + fn truncate(&mut self) { + const ELLIPSIS: char = '\u{2026}'; + while self.text.len().saturating_add(ELLIPSIS.len_utf8()) > MAX_BOOK_TITLE_BYTES { + self.text.pop(); + } + self.text.push(ELLIPSIS); + self.pending_space = false; + self.truncated = true; + } + + fn finish(self) -> Option { + (!self.discarded && !self.text.is_empty()).then_some(self.text) + } +} + +fn collapsed_text(value: &str) -> Option { + let mut text = CollapsedText::default(); + text.push(value); + text.finish() +} + +fn project_xhtml( + source: &str, + output: &mut TextWriter, + headings: &mut HeadingCollector, + toc_targets: &mut [TocTarget], + path: &Path, +) -> Result<(), LoadError> { + let mut resolver = ChapterTargetResolver::new(toc_targets); + let mut reader = XmlReader::from_str(source); + reader.config_mut().check_end_names = true; + let decoder = reader.decoder(); + let mut depth = 0_usize; + let mut suppressed = 0_usize; + let mut preformatted = 0_usize; + let mut in_body = false; + let mut saw_body = false; + let mut heading: Option = None; + if resolver.has_pending() { + output.capture_next_content(); + } + + loop { + match reader + .read_event() + .map_err(|source| invalid_epub(path, source))? + { + Event::Start(element) => { + depth = depth + .checked_add(1) + .filter(|depth| *depth <= MAX_XML_DEPTH) + .ok_or_else(|| invalid_epub_message(path, "XHTML nesting exceeds its limit"))?; + let name = element.local_name(); + let name = name.as_ref(); + if name.eq_ignore_ascii_case(b"body") { + if saw_body { + return Err(invalid_epub_message(path, "XHTML contains multiple bodies")); + } + saw_body = true; + in_body = true; + if match_toc_targets(&element, name, decoder, &mut resolver, path)? { + output.capture_next_content(); + } + if element_hidden(&element, decoder, path)? { + suppressed = 1; + } + continue; + } + if !in_body { + continue; + } + if match_toc_targets(&element, name, decoder, &mut resolver, path)? { + output.capture_next_content(); + } + if suppressed != 0 { + suppressed += 1; + continue; + } + if element_hidden(&element, decoder, path)? { + suppressed = 1; + continue; + } + if ignored_element(name) { + if image_element(name) { + output.image(image_alt(&element, decoder, path)?.as_deref())?; + resolver.resolve_capture(output); + } + suppressed = 1; + continue; + } + element_start(name, &element, decoder, output, path)?; + if let Some(active) = heading.as_mut() + && name.eq_ignore_ascii_case(b"br") + { + active.title.space(); + } + if heading.is_none() + && let Some(level) = heading_level(name) + { + output.flush_separator()?; + heading = Some(ActiveHeading { + depth, + level, + start: output.position(), + title: CollapsedText::default(), + }); + } + if name.eq_ignore_ascii_case(b"pre") { + preformatted += 1; + } + } + Event::Empty(element) => { + let name = element.local_name(); + let name = name.as_ref(); + if name.eq_ignore_ascii_case(b"body") { + saw_body = true; + } else if in_body { + if match_toc_targets(&element, name, decoder, &mut resolver, path)? { + output.capture_next_content(); + } + if suppressed != 0 || element_hidden(&element, decoder, path)? { + continue; + } + element_start(name, &element, decoder, output, path)?; + if let Some(active) = heading.as_mut() + && name.eq_ignore_ascii_case(b"br") + { + active.title.space(); + } + element_end(name, output)?; + } + } + Event::End(element) => { + let name = element.local_name(); + let name = name.as_ref(); + if in_body { + if suppressed != 0 { + suppressed -= 1; + } else { + if heading + .as_ref() + .is_some_and(|heading| heading.depth == depth) + { + let heading = heading.take().expect("matching heading is active"); + let heading_end = output.position(); + if let Some(title) = heading.title.finish() { + headings.push(title, heading.level, heading.start, heading_end); + } + } + if name.eq_ignore_ascii_case(b"pre") { + preformatted = preformatted.saturating_sub(1); + } + element_end(name, output)?; + } + if name.eq_ignore_ascii_case(b"body") { + in_body = false; + } + } + depth = depth + .checked_sub(1) + .ok_or_else(|| invalid_epub_message(path, "XHTML nesting is invalid"))?; + } + Event::Text(text) if in_body && suppressed == 0 => { + let text = text + .html_content() + .map_err(|source| invalid_epub(path, source))?; + if let Some(heading) = heading.as_mut() { + heading.title.push(&text); + } + if preformatted == 0 { + output.text(&text)?; + } else { + output.preformatted(&text)?; + } + } + Event::CData(text) if in_body && suppressed == 0 => { + let text = text.decode().map_err(|source| invalid_epub(path, source))?; + if let Some(heading) = heading.as_mut() { + heading.title.push(&text); + } + if preformatted == 0 { + output.text(&text)?; + } else { + output.preformatted(&text)?; + } + } + Event::GeneralRef(reference) if in_body && suppressed == 0 => { + if let Some(character) = reference + .resolve_char_ref() + .map_err(|source| invalid_epub(path, source))? + { + let mut encoded = [0_u8; 4]; + let value = character.encode_utf8(&mut encoded); + if let Some(heading) = heading.as_mut() { + heading.title.push(value); + } + if preformatted == 0 { + output.text(value)?; + } else { + output.preformatted(value)?; + } + } else { + let name = reference + .decode() + .map_err(|source| invalid_epub(path, source))?; + let value = resolve_predefined_entity(&name) + .ok_or_else(|| invalid_epub_message(path, "XHTML entity is undefined"))?; + if let Some(heading) = heading.as_mut() { + heading.title.push(value); + } + if preformatted == 0 { + output.text(value)?; + } else { + output.preformatted(value)?; + } + } + } + Event::Eof => break, + _ => {} + } + resolver.resolve_capture(output); + } + resolver.finish(output); + if !saw_body || depth != 0 { + return Err(invalid_epub_message( + path, + "XHTML body is missing or incomplete", + )); + } + Ok(()) +} + +fn match_toc_targets( + element: &BytesStart<'_>, + element_name: &[u8], + decoder: Decoder, + resolver: &mut ChapterTargetResolver<'_>, + path: &Path, +) -> Result { + if !resolver.has_unresolved_fragments() { + return Ok(false); + } + let mut matched = false; + for attribute in element.attributes() { + let attribute = attribute.map_err(|source| invalid_epub(path, source))?; + let name = attribute.key.as_ref(); + let is_id = name.eq_ignore_ascii_case(b"id") || name.eq_ignore_ascii_case(b"xml:id"); + let is_legacy_name = element_name.eq_ignore_ascii_case(b"a") + && attribute + .key + .local_name() + .as_ref() + .eq_ignore_ascii_case(b"name"); + if !is_id && !is_legacy_name { + continue; + } + let value = attribute + .decoded_and_normalized_value(XmlVersion::Implicit1_0, decoder) + .map_err(|source| invalid_epub(path, source))?; + matched |= resolver.match_fragment(&value); + } + Ok(matched) +} + +fn heading_level(name: &[u8]) -> Option { + match name { + name if name.eq_ignore_ascii_case(b"h1") => Some(1), + name if name.eq_ignore_ascii_case(b"h2") => Some(2), + name if name.eq_ignore_ascii_case(b"h3") => Some(3), + name if name.eq_ignore_ascii_case(b"h4") => Some(4), + name if name.eq_ignore_ascii_case(b"h5") => Some(5), + name if name.eq_ignore_ascii_case(b"h6") => Some(6), + _ => None, + } +} + +fn element_start( + name: &[u8], + element: &BytesStart<'_>, + decoder: Decoder, + output: &mut TextWriter, + path: &Path, +) -> Result<(), LoadError> { + if block_element(name) { + output.paragraph(); + } + if name.eq_ignore_ascii_case(b"br") { + output.break_line(); + } else if name.eq_ignore_ascii_case(b"li") { + output.literal("•")?; + output.space(); + } else if name.eq_ignore_ascii_case(b"hr") { + output.paragraph(); + output.literal("* * *")?; + output.paragraph(); + } else if image_element(name) { + output.image(image_alt(element, decoder, path)?.as_deref())?; + } else if name.eq_ignore_ascii_case(b"rt") { + output.literal("(")?; + } + Ok(()) +} + +fn element_end(name: &[u8], output: &mut TextWriter) -> Result<(), LoadError> { + if name.eq_ignore_ascii_case(b"td") || name.eq_ignore_ascii_case(b"th") { + output.tab(); + } else if name.eq_ignore_ascii_case(b"tr") || name.eq_ignore_ascii_case(b"li") { + output.line(); + } else if name.eq_ignore_ascii_case(b"rt") { + output.literal(")")?; + } + if block_element(name) { + output.paragraph(); + } + Ok(()) +} + +fn element_hidden( + element: &BytesStart<'_>, + decoder: Decoder, + path: &Path, +) -> Result { + for attribute in element.attributes() { + let attribute = attribute.map_err(|source| invalid_epub(path, source))?; + let name = attribute.key.local_name(); + let name = name.as_ref(); + if name.eq_ignore_ascii_case(b"hidden") { + return Ok(true); + } + if name.eq_ignore_ascii_case(b"aria-hidden") || name.eq_ignore_ascii_case(b"style") { + let value = attribute + .decoded_and_normalized_value(XmlVersion::Implicit1_0, decoder) + .map_err(|source| invalid_epub(path, source))?; + if (name.eq_ignore_ascii_case(b"aria-hidden") + && value.trim().eq_ignore_ascii_case("true")) + || (name.eq_ignore_ascii_case(b"style") && style_hides(&value)) + { + return Ok(true); + } + } + } + Ok(false) +} + +fn image_alt( + element: &BytesStart<'_>, + decoder: Decoder, + path: &Path, +) -> Result, LoadError> { + for attribute in element.attributes() { + let attribute = attribute.map_err(|source| invalid_epub(path, source))?; + if attribute + .key + .local_name() + .as_ref() + .eq_ignore_ascii_case(b"alt") + { + let value = attribute + .decoded_and_normalized_value(XmlVersion::Implicit1_0, decoder) + .map_err(|source| invalid_epub(path, source))?; + return Ok(collapsed_text(&value).filter(|alt| useful_image_alt(alt))); + } + } + Ok(None) +} + +fn useful_image_alt(alt: &str) -> bool { + let mut value = alt.trim(); + while let Some(inner) = value + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + value = inner.trim(); + } + if let Some((kind, inner)) = value.split_once(':') + && (kind.trim().eq_ignore_ascii_case("image") + || kind.trim().eq_ignore_ascii_case("illustration")) + { + value = inner.trim(); + while let Some(inner) = value + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + value = inner.trim(); + } + } + let value = value.trim_end_matches(['.', '!', ':']).trim(); + !value.is_empty() + && ![ + "cover", + "image", + "illustration", + "image unavailable", + "image not available", + "illustration unavailable", + "illustration not available", + ] + .iter() + .any(|generic| value.eq_ignore_ascii_case(generic)) +} + +fn style_hides(style: &str) -> bool { + style.split(';').any(|declaration| { + let Some((property, value)) = declaration.split_once(':') else { + return false; + }; + let property = property.trim(); + let value = value + .trim() + .split_ascii_whitespace() + .next() + .unwrap_or_default(); + (property.eq_ignore_ascii_case("display") && value.eq_ignore_ascii_case("none")) + || (property.eq_ignore_ascii_case("visibility") && value.eq_ignore_ascii_case("hidden")) + }) +} + +fn image_element(name: &[u8]) -> bool { + name.eq_ignore_ascii_case(b"img") + || name.eq_ignore_ascii_case(b"svg") + || name.eq_ignore_ascii_case(b"canvas") +} + +fn ignored_element(name: &[u8]) -> bool { + [ + b"head".as_slice(), + b"script", + b"style", + b"template", + b"svg", + b"canvas", + b"rp", + ] + .iter() + .any(|ignored| name.eq_ignore_ascii_case(ignored)) +} + +fn block_element(name: &[u8]) -> bool { + [ + b"address".as_slice(), + b"article", + b"aside", + b"blockquote", + b"caption", + b"dd", + b"div", + b"dl", + b"dt", + b"figcaption", + b"figure", + b"footer", + b"form", + b"h1", + b"h2", + b"h3", + b"h4", + b"h5", + b"h6", + b"header", + b"main", + b"nav", + b"ol", + b"p", + b"pre", + b"section", + b"table", + b"ul", + ] + .iter() + .any(|block| name.eq_ignore_ascii_case(block)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Separator { + None, + Space, + Tab, + Line, + Paragraph, +} + +struct TextWriter { + output: BufWriter, + path: PathBuf, + limit: u64, + length: u64, + pending: Separator, + ending_newlines: u8, + horizontal_space: bool, + has_content: bool, + capture_next_content: bool, + captured_content_start: Option, +} + +impl TextWriter { + fn new(file: File, path: PathBuf, limit: u64) -> Self { + Self { + output: BufWriter::new(file), + path, + limit, + length: 0, + pending: Separator::None, + ending_newlines: 0, + horizontal_space: false, + has_content: false, + capture_next_content: false, + captured_content_start: None, + } + } + + fn chapter_break(&mut self) { + self.request(Separator::Paragraph); + } + + fn paragraph(&mut self) { + self.request(Separator::Paragraph); + } + + fn line(&mut self) { + self.request(Separator::Line); + } + + fn break_line(&mut self) { + if self.pending == Separator::Line { + self.pending = Separator::Paragraph; + } else { + self.request(Separator::Line); + } + } + + fn tab(&mut self) { + self.request(Separator::Tab); + } + + fn space(&mut self) { + self.request(Separator::Space); + } + + fn request(&mut self, separator: Separator) { + self.pending = self.pending.max(separator); + } + + const fn position(&self) -> SourceOffset { + SourceOffset::new(self.length) + } + + fn capture_next_content(&mut self) { + if self.captured_content_start.is_none() { + self.capture_next_content = true; + } + } + + fn take_captured_content_start(&mut self) -> Option { + self.captured_content_start.take() + } + + fn cancel_content_capture(&mut self) { + self.capture_next_content = false; + self.captured_content_start = None; + } + + fn text(&mut self, text: &str) -> Result<(), LoadError> { + for character in text.chars() { + if character.is_whitespace() { + self.space(); + } else { + self.flush_separator()?; + let mut encoded = [0_u8; 4]; + self.write_raw(character.encode_utf8(&mut encoded), true)?; + } + } + Ok(()) + } + + fn preformatted(&mut self, text: &str) -> Result<(), LoadError> { + self.flush_separator()?; + let Some((content_start, _)) = text + .char_indices() + .find(|(_, character)| !character.is_whitespace()) + else { + return self.write_raw(text, false); + }; + if content_start != 0 { + self.write_raw(&text[..content_start], false)?; + } + self.write_raw(&text[content_start..], true) + } + + fn literal(&mut self, text: &str) -> Result<(), LoadError> { + self.flush_separator()?; + self.write_raw(text, true) + } + + fn image(&mut self, alt: Option<&str>) -> Result<(), LoadError> { + let Some(alt) = alt.filter(|alt| !alt.trim().is_empty()) else { + return Ok(()); + }; + self.space(); + self.literal("[Illustration: ")?; + self.text(alt)?; + self.literal("]")?; + self.space(); + Ok(()) + } + + fn flush_separator(&mut self) -> Result<(), LoadError> { + let pending = std::mem::replace(&mut self.pending, Separator::None); + if self.length == 0 { + return Ok(()); + } + match pending { + Separator::None => {} + Separator::Space if self.ending_newlines == 0 && !self.horizontal_space => { + self.write_raw(" ", false)?; + } + Separator::Tab if self.ending_newlines == 0 => { + if self.horizontal_space { + return Ok(()); + } + self.write_raw("\t", false)?; + } + Separator::Line if self.ending_newlines == 0 => self.write_raw("\n", false)?, + Separator::Paragraph => match self.ending_newlines { + 0 => self.write_raw("\n\n", false)?, + 1 => self.write_raw("\n", false)?, + _ => {} + }, + Separator::Space | Separator::Tab | Separator::Line => {} + } + Ok(()) + } + + fn write_raw(&mut self, value: &str, content: bool) -> Result<(), LoadError> { + let value_len = u64::try_from(value.len()).expect("strings fit EPUB source coordinates"); + let next = self + .length + .checked_add(value_len) + .filter(|next| *next <= self.limit) + .ok_or_else(|| LoadError::EpubTextTooLarge { + path: self.path.clone(), + limit: self.limit, + })?; + if content && self.capture_next_content { + self.captured_content_start = Some(self.position()); + self.capture_next_content = false; + } + self.output + .write_all(value.as_bytes()) + .map_err(|source| LoadError::BufferEpub { + path: self.path.clone(), + source, + })?; + self.length = next; + self.has_content |= content; + if value.ends_with('\n') { + let previous = self.ending_newlines; + let trailing = value + .as_bytes() + .iter() + .rev() + .take_while(|byte| **byte == b'\n') + .take(2) + .count() as u8; + self.ending_newlines = if usize::from(trailing) == value.len() { + previous.saturating_add(trailing).min(2) + } else { + trailing + }; + self.horizontal_space = false; + } else if let Some(last) = value.as_bytes().last() { + self.ending_newlines = 0; + self.horizontal_space = matches!(last, b' ' | b'\t'); + } + Ok(()) + } + + fn finish(mut self) -> Result<(File, u64), LoadError> { + if !self.has_content { + return Err(LoadError::NoLinearEpubContent(self.path)); + } + self.pending = Separator::None; + if self.ending_newlines == 0 { + self.write_raw("\n", false)?; + } + self.output + .flush() + .map_err(|source| LoadError::BufferEpub { + path: self.path.clone(), + source, + })?; + let file = self + .output + .into_inner() + .map_err(|error| LoadError::BufferEpub { + path: self.path.clone(), + source: error.into_error(), + })?; + Ok((file, self.length)) + } +} + +fn read_exact_at(file: &File, mut output: &mut [u8], mut offset: u64) -> io::Result<()> { + while !output.is_empty() { + match file.read_at(output, offset) { + Ok(0) => return Err(io::ErrorKind::UnexpectedEof.into()), + Ok(count) => { + offset = offset + .checked_add(count as u64) + .ok_or(io::ErrorKind::InvalidData)?; + output = &mut output[count..]; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn le_u16(bytes: &[u8]) -> u16 { + u16::from_le_bytes(bytes[..2].try_into().expect("ZIP fields are bounded")) +} + +fn le_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes[..4].try_into().expect("ZIP fields are bounded")) +} + +fn le_u64(bytes: &[u8]) -> u64 { + u64::from_le_bytes(bytes[..8].try_into().expect("ZIP fields are bounded")) +} + +fn invalid_epub( + path: &Path, + source: impl Into>, +) -> LoadError { + LoadError::InvalidEpub { + path: path.to_path_buf(), + source: io::Error::new(io::ErrorKind::InvalidData, source), + } +} + +fn invalid_epub_message(path: &Path, message: &'static str) -> LoadError { + LoadError::InvalidEpub { + path: path.to_path_buf(), + source: io::Error::new(io::ErrorKind::InvalidData, message), + } +} + +#[cfg(test)] +mod tests { + use std::{io::Cursor, num::NonZeroUsize}; + + use tempfile::{Builder, NamedTempFile, tempdir}; + use zip::{ZipWriter, write::SimpleFileOptions}; + + use crate::{ + document::{DocumentCache, overwrite_with_distinct_fingerprint}, + source::SourceOffset, + }; + + use super::*; + + const CONTAINER: &str = r#" + + + + +"#; + + const CHAPTER_ONE: &str = r#" + + + Hidden title + +

Chapter One

+

Hello wide world & æ.

+

Plate I

+ + +"#; + + const SUPPLEMENT: &str = r#" +

NONLINEAR IMAGE WRAPPER

"#; + + const CHAPTER_TWO: &str = r#" +

Chapter Two

+
  • One
  • Two
    continued
+
AB
+ + +

hidden style

+

Visible 字zi.

+"#; + + fn fixture(mimetype: &[u8]) -> NamedTempFile { + fixture_version(mimetype, "2.0") + } + + fn fixture_version(mimetype: &[u8], version: &str) -> NamedTempFile { + let file = Builder::new().suffix(".epub").tempfile().unwrap(); + let mut writer = ZipWriter::new(file.reopen().unwrap()); + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + writer.start_file("mimetype", stored).unwrap(); + writer.write_all(mimetype).unwrap(); + writer + .start_file("META-INF/container.xml", deflated) + .unwrap(); + writer.write_all(CONTAINER.as_bytes()).unwrap(); + + let modified = if version == "3.0" { + "2026-08-28T00:00:00Z" + } else { + "" + }; + let package = format!( + r#" + + + urn:uuid:test + Test Book + Test Author + en + {modified} + + + + + + + + + + + +"# + ); + for (name, contents) in [ + ("OEBPS/content.opf", package.as_str()), + ("OEBPS/one.xhtml", CHAPTER_ONE), + ("OEBPS/supplement.xhtml", SUPPLEMENT), + ("OEBPS/two.xhtml", CHAPTER_TWO), + ] { + writer.start_file(name, deflated).unwrap(); + writer.write_all(contents.as_bytes()).unwrap(); + } + writer.finish().unwrap(); + file + } + + fn fixture_with_package(package: &str, resources: &[(&str, &str)]) -> NamedTempFile { + let file = Builder::new().suffix(".epub").tempfile().unwrap(); + let mut writer = ZipWriter::new(file.reopen().unwrap()); + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + writer.start_file("mimetype", stored).unwrap(); + writer.write_all(EPUB_MIMETYPE).unwrap(); + writer + .start_file("META-INF/container.xml", deflated) + .unwrap(); + writer.write_all(CONTAINER.as_bytes()).unwrap(); + writer.start_file("OEBPS/content.opf", deflated).unwrap(); + writer.write_all(package.as_bytes()).unwrap(); + for (name, contents) in resources { + writer.start_file(*name, deflated).unwrap(); + writer.write_all(contents.as_bytes()).unwrap(); + } + writer.finish().unwrap(); + file + } + + fn epub_two_navigation_fixture(ncx: &str) -> NamedTempFile { + let package = r#" + + + urn:uuid:ncx-test + NCX Booken + + + + + + + + + + +"#; + fixture_with_package( + package, + &[ + ("OEBPS/toc.ncx", ncx), + ( + "OEBPS/one.xhtml", + r#" +

Front matter.

Opening paragraph.

+

Unicode destination.

+

Styled Heading

Body.

"#, + ), + ( + "OEBPS/supplement.xhtml", + r#"

Supplement

"#, + ), + ( + "OEBPS/two.xhtml", + r#"

Second chapter.

Second Heading

"#, + ), + ], + ) + } + + fn read_all(document: &Document) -> String { + let mut cache = DocumentCache::default(); + let mut reader = document.reader(&mut cache); + let mut cursor = reader.source_start(); + let mut output = String::new(); + let target = NonZeroUsize::new(4_096).unwrap(); + while cursor < reader.source_end() { + let window = reader.window(cursor, target).unwrap(); + output.push_str(window.as_str()); + cursor = window.end(); + } + output + } + + fn project(source: &str) -> (String, Vec) { + let file = tempfile::tempfile().unwrap(); + let mut output = TextWriter::new(file, PathBuf::from("project.epub"), MAX_FILE_BYTES); + let mut headings = HeadingCollector::default(); + let mut targets = Vec::new(); + project_xhtml( + source, + &mut output, + &mut headings, + &mut targets, + Path::new("project.epub"), + ) + .unwrap(); + let (mut file, _) = output.finish().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut text = String::new(); + file.read_to_string(&mut text).unwrap(); + (text, headings.captured) + } + + #[test] + fn extension_detection_is_case_insensitive() { + assert!(is_path(Path::new("book.EPUB"))); + assert!(!is_path(Path::new("book.epub.zip"))); + assert!(!is_path(Path::new("epub"))); + } + + #[test] + fn projects_only_linear_spine_content() { + let fixture = fixture(EPUB_MIMETYPE); + let document = load(fixture.path().to_path_buf()).unwrap(); + let text = read_all(&document); + + assert!(text.starts_with("Chapter One\n\nHello wide world & æ.")); + assert!(text.contains("[Illustration: Plate I]")); + assert!(text.contains("Chapter Two")); + assert!(text.contains("• One\n• Two\ncontinued")); + assert!(text.contains("A\tB")); + assert!(text.contains("Visible 字(zi).")); + assert!(!text.contains("NONLINEAR")); + assert!(!text.contains("Hidden title")); + assert!(!text.contains("hidden script")); + assert!(!text.contains("hidden attribute")); + assert!(!text.contains("hidden aria")); + assert!(!text.contains("hidden style")); + let book = document.book().unwrap(); + assert_eq!(book.title(), Some("Test Book")); + assert_eq!(book.creator(), Some("Test Author")); + assert_eq!(book.sections().len(), 2); + assert_eq!(book.sections()[0].title(), "Chapter One"); + assert_eq!(book.sections()[0].level(), 1); + assert_eq!(book.sections()[1].title(), "Chapter Two"); + assert_eq!(book.sections()[1].level(), 2); + assert_eq!(book.headings().len(), 2); + assert_eq!(document.preferred_start(), book.headings()[0].start()); + for (section, heading) in book.sections().iter().zip(book.headings()) { + assert_eq!(section.target(), heading.start()); + let start = usize::try_from(heading.start().get()).unwrap(); + let end = usize::try_from(heading.end().get()).unwrap(); + assert_eq!( + text[start..end].split_whitespace().collect::>(), + section.title().split_whitespace().collect::>() + ); + } + assert!(document.input_identity().is_some()); + document.validate().unwrap(); + } + + #[test] + fn epub_two_ncx_drives_navigation_without_replacing_heading_ranges() { + let ncx = r#" + + NCX Book + Opening + + Unicode Label + + Styled from NCX + + Missing + + + Supplement + + Second from NCX + + +"#; + let fixture = epub_two_navigation_fixture(ncx); + let document = load(fixture.path().to_path_buf()).unwrap(); + let text = read_all(&document); + let book = document.book().unwrap(); + + assert_eq!( + book.sections() + .iter() + .map(BookSection::title) + .collect::>(), + [ + "Opening", + "Unicode Label", + "Styled from NCX", + "Second from NCX" + ] + ); + assert_eq!( + book.sections() + .iter() + .map(BookSection::level) + .collect::>(), + [1, 2, 2, 1] + ); + for (section, visible) in book.sections().iter().zip([ + "Opening paragraph.", + "Unicode destination.", + "Styled Heading", + "Second chapter.", + ]) { + let target = usize::try_from(section.target().get()).unwrap(); + assert!(text[target..].starts_with(visible)); + } + assert_eq!(book.headings().len(), 2); + assert_eq!(book.headings()[0].level(), 2); + assert_eq!(book.headings()[0].start(), book.sections()[2].target()); + assert_eq!(book.headings()[1].level(), 3); + assert_eq!(document.preferred_start(), book.headings()[0].start()); + assert!(!text.contains("Supplement")); + } + + #[test] + fn epub_three_nav_is_preferred_over_legacy_ncx() { + let package = r#" + + + urn:uuid:nav-testNav Book + en2026-08-28T00:00:00Z + + + + + + + + +"#; + let nav = r#" +"#; + let ncx = r#" +Legacy +NCX Label +"#; + let fixture = fixture_with_package( + package, + &[ + ( + "OEBPS/one.xhtml", + r#"

Actual Heading

"#, + ), + ( + "OEBPS/two.xhtml", + r#"

Last body.

"#, + ), + ("OEBPS/nav.xhtml", nav), + ("OEBPS/toc.ncx", ncx), + ], + ); + let document = load(fixture.path().to_path_buf()).unwrap(); + let text = read_all(&document); + let book = document.book().unwrap(); + + assert_eq!( + book.sections() + .iter() + .map(BookSection::title) + .collect::>(), + ["EPUB 3 Label", "Nested Label", "Last Label"] + ); + assert_eq!( + book.sections() + .iter() + .map(BookSection::level) + .collect::>(), + [1, 2, 1] + ); + assert!( + !book + .sections() + .iter() + .any(|section| section.title() == "NCX Label") + ); + for (section, visible) in + book.sections() + .iter() + .zip(["Nav body.", "Actual Heading", "Last body."]) + { + let target = usize::try_from(section.target().get()).unwrap(); + assert!(text[target..].starts_with(visible)); + } + assert_eq!(book.headings().len(), 1); + assert_eq!(book.headings()[0].start(), book.sections()[1].target()); + } + + #[test] + fn malformed_toc_retries_without_it_and_uses_heading_navigation() { + let fixture = epub_two_navigation_fixture(""); + let archive = File::open(fixture.path()).unwrap(); + let (_, has_toc) = read_epub(archive, fixture.path()).unwrap(); + assert!(!has_toc); + + let document = load(fixture.path().to_path_buf()).unwrap(); + let book = document.book().unwrap(); + assert_eq!( + book.sections() + .iter() + .map(BookSection::title) + .collect::>(), + ["Styled Heading", "Second Heading"] + ); + assert_eq!(book.sections().len(), book.headings().len()); + } + + #[test] + fn chapter_targets_begin_at_preformatted_content_not_its_leading_space() { + let mut toc = TocTargets { + targets: vec![TocTarget { + manifest_index: 0, + fragment: None, + title: "Code".to_owned(), + level: 1, + ordinal: 1, + resolved: None, + attempted: false, + active: false, + awaiting: false, + }], + ..TocTargets::default() + }; + let file = tempfile::tempfile().unwrap(); + let mut output = TextWriter::new(file, PathBuf::from("pre.epub"), MAX_FILE_BYTES); + let mut headings = HeadingCollector::default(); + project_xhtml( + "
\n  code
", + &mut output, + &mut headings, + toc.for_manifest(0), + Path::new("pre.epub"), + ) + .unwrap(); + let (mut file, _) = output.finish().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut text = String::new(); + file.read_to_string(&mut text).unwrap(); + let sections = toc.finish(); + + assert_eq!(sections.len(), 1); + let target = usize::try_from(sections[0].target().get()).unwrap(); + assert!(text[target..].starts_with("code")); + assert!(text[..target].chars().all(char::is_whitespace)); + } + + #[test] + fn unresolved_fragment_targets_do_not_cross_repeated_spine_chapters() { + let mut toc = TocTargets { + targets: vec![TocTarget { + manifest_index: 0, + fragment: Some("late".to_owned()), + title: "Late".to_owned(), + level: 1, + ordinal: 1, + resolved: None, + attempted: false, + active: false, + awaiting: false, + }], + ..TocTargets::default() + }; + let file = tempfile::tempfile().unwrap(); + let mut output = TextWriter::new(file, PathBuf::from("repeat.epub"), MAX_FILE_BYTES); + let mut headings = HeadingCollector::default(); + project_xhtml( + "

First occurrence.

", + &mut output, + &mut headings, + toc.for_manifest(0), + Path::new("repeat.epub"), + ) + .unwrap(); + output.chapter_break(); + project_xhtml( + "

Second occurrence.

", + &mut output, + &mut headings, + toc.for_manifest(0), + Path::new("repeat.epub"), + ) + .unwrap(); + output.finish().unwrap(); + + assert!(toc.finish().is_empty()); + } + + #[test] + fn toc_traversal_budget_counts_unusable_nodes() { + let package = r#" +urn:many +Manyen + + +"#; + let mut nav = String::from( + r#" +"); + let fixture = fixture_with_package( + package, + &[ + ( + "OEBPS/one.xhtml", + "

Fallback

", + ), + ("OEBPS/nav.xhtml", &nav), + ], + ); + let archive = File::open(fixture.path()).unwrap(); + let (epub, has_toc) = read_epub(archive, fixture.path()).unwrap(); + assert!(has_toc); + let toc = TocTargets::collect(&epub); + + assert_eq!(toc.visited, MAX_BOOK_SECTIONS); + assert!(toc.saturated); + assert!(toc.targets.is_empty()); + } + + #[test] + fn headings_capture_collapsed_titles_and_projected_ranges() { + let (text, sections) = project( + r#"

Front matter.

THE
Principles of + Ornament

Part & Form

"#, + ); + + assert_eq!(sections.len(), 2); + assert_eq!( + sections[0].title.as_deref(), + Some("THE Principles of Ornament") + ); + assert_eq!(sections[0].heading.level(), 1); + assert_eq!(sections[1].title.as_deref(), Some("Part & Form")); + assert_eq!(sections[1].heading.level(), 3); + for section in §ions { + let start = usize::try_from(section.heading.start().get()).unwrap(); + let end = usize::try_from(section.heading.end().get()).unwrap(); + let projected = text[start..end] + .split_whitespace() + .collect::>() + .join(" "); + assert_eq!(projected, section.title.as_deref().unwrap()); + } + } + + #[test] + fn consecutive_breaks_preserve_a_blank_line() { + let (text, _) = project("

one

two

"); + assert_eq!(text, "one\n\ntwo\n"); + } + + #[test] + fn table_rows_and_list_items_use_single_line_breaks() { + let (text, _) = project( + "\ + \ +
AB
12
34
  • One
  • Two
", + ); + assert_eq!(text, "A\tB\n1\t2\n3\t4\n\n• One\n• Two\n"); + } + + #[test] + fn generic_image_alternatives_are_silent() { + let (text, _) = project( + r#"

+ [Image unavailable.] + [Image: [Image unavailable.]] + Cover + + + Plate I +

"#, + ); + assert_eq!(text, "[Illustration: Plate I]\n"); + assert!(!text.contains("unavailable")); + } + + #[test] + fn horizontal_rules_remain_visible() { + let (text, _) = project("

Before


After

"); + assert_eq!(text, "Before\n\n* * *\n\nAfter\n"); + } + + #[test] + fn section_limits_degrade_without_rejecting_projection() { + let mut collector = HeadingCollector::default(); + for index in 0..=MAX_BOOK_HEADINGS { + let start = SourceOffset::from_usize(index * 2); + collector.push("x".to_owned(), 1, start, start.checked_add(1).unwrap()); + } + assert_eq!(collector.captured.len(), MAX_BOOK_HEADINGS); + + let oversized = "x".repeat(MAX_BOOK_TITLE_BYTES + 1); + let (text, sections) = project(&format!( + "

{oversized}

body

" + )); + assert!(text.contains("body")); + assert_eq!(sections.len(), 1); + let title = sections[0].title.as_deref().unwrap(); + assert!(title.len() <= MAX_BOOK_TITLE_BYTES); + assert!(title.ends_with('…')); + + let mut collector = HeadingCollector::default(); + let title = "x".repeat(MAX_BOOK_TITLE_BYTES); + let retained = MAX_BOOK_TITLE_TOTAL_BYTES / MAX_BOOK_TITLE_BYTES; + for index in 0..=retained { + let start = SourceOffset::from_usize(index * 2); + collector.push(title.clone(), 1, start, start.checked_add(1).unwrap()); + } + let (sections, headings) = collector.finish(Vec::new()); + assert_eq!(sections.len(), retained); + assert_eq!(headings.len(), retained + 1); + } + + #[test] + fn fragment_decoding_is_bounded_before_allocation() { + let limit = "%61".repeat(MAX_TOC_FRAGMENT_BYTES); + assert_eq!( + decoded_fragment(&limit).unwrap().len(), + MAX_TOC_FRAGMENT_BYTES + ); + + let oversized = format!("{limit}%61"); + assert!(decoded_fragment(&oversized).is_none()); + } + + #[test] + fn projects_epub_three_spine_content() { + let fixture = fixture_version(EPUB_MIMETYPE, "3.0"); + let document = load(fixture.path().to_path_buf()).unwrap(); + let text = read_all(&document); + assert!(text.contains("Chapter One")); + assert!(text.contains("Chapter Two")); + assert!(!text.contains("NONLINEAR")); + } + + #[test] + fn projected_documents_continue_tracking_the_origin() { + let fixture = fixture(EPUB_MIMETYPE); + let document = load(fixture.path().to_path_buf()).unwrap(); + overwrite_with_distinct_fingerprint(fixture.path(), b"changed"); + assert!(matches!(document.validate(), Err(LoadError::Read { .. }))); + } + + #[test] + fn rejects_an_invalid_mimetype_entry() { + let fixture = fixture(b"application/zip"); + assert!(matches!( + load(fixture.path().to_path_buf()), + Err(LoadError::InvalidEpub { .. }) + )); + } + + #[test] + fn rejects_html_spine_content_instead_of_parsing_it_as_xhtml() { + let package = r#" + + + urn:uuid:html-test + HTML Booken + + + +"#; + let fixture = fixture_with_package( + package, + &[( + "OEBPS/one.html", + "

Well-formed but not XHTML.

", + )], + ); + + assert!(matches!( + load(fixture.path().to_path_buf()), + Err(LoadError::InvalidEpub { .. }) + )); + } + + #[test] + fn spine_limit_accepts_the_boundary_and_rejects_the_next_byte_atomically() { + let path = Path::new("bounded.epub"); + let mut total = 6; + add_spine_bytes(&mut total, 4, 10, path).unwrap(); + assert_eq!(total, 10); + + assert!(matches!( + add_spine_bytes(&mut total, 1, 10, path), + Err(LoadError::EpubExpandedTooLarge { limit: 10, .. }) + )); + assert_eq!(total, 10); + } + + #[test] + fn eocd_entry_limit_is_checked_before_zip_construction() { + let directory = tempdir().unwrap(); + let path = directory.path().join("many.epub"); + let mut bytes = [0_u8; EOCD_LEN]; + bytes[..4].copy_from_slice(EOCD_SIGNATURE); + bytes[8..10].copy_from_slice(&2_u16.to_le_bytes()); + bytes[10..12].copy_from_slice(&2_u16.to_le_bytes()); + std::fs::write(&path, bytes).unwrap(); + let file = File::open(&path).unwrap(); + let limits = ArchiveLimits { + entries: 1, + ..ARCHIVE_LIMITS + }; + assert!(matches!( + inspect_eocd(&file, &path, limits), + Err(LoadError::EpubTooManyEntries { limit: 1, .. }) + )); + } + + #[test] + fn eocd_central_directory_limit_is_exact() { + let directory = tempdir().unwrap(); + let path = directory.path().join("directory.epub"); + let mut bytes = vec![0_u8; EOCD_LEN + 1]; + bytes[1..5].copy_from_slice(EOCD_SIGNATURE); + bytes[13..17].copy_from_slice(&1_u32.to_le_bytes()); + std::fs::write(&path, bytes).unwrap(); + let file = File::open(&path).unwrap(); + let exact = ArchiveLimits { + central_directory_bytes: 1, + ..ARCHIVE_LIMITS + }; + inspect_eocd(&file, &path, exact).unwrap(); + + let exceeded = ArchiveLimits { + central_directory_bytes: 0, + ..ARCHIVE_LIMITS + }; + assert!(matches!( + inspect_eocd(&file, &path, exceeded), + Err(LoadError::InvalidEpub { .. }) + )); + } + + #[test] + fn preflight_entry_name_limits_are_exact() { + let file = Builder::new().suffix(".epub").tempfile().unwrap(); + let mut writer = ZipWriter::new(file.reopen().unwrap()); + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + writer.start_file("mimetype", stored).unwrap(); + writer.write_all(EPUB_MIMETYPE).unwrap(); + writer.start_file("a", stored).unwrap(); + writer.finish().unwrap(); + + let exact = ArchiveLimits { + entry_name_bytes: 8, + entry_name_total_bytes: 9, + ..ARCHIVE_LIMITS + }; + preflight(File::open(file.path()).unwrap(), file.path(), exact).unwrap(); + + for limits in [ + ArchiveLimits { + entry_name_bytes: 7, + entry_name_total_bytes: 9, + ..ARCHIVE_LIMITS + }, + ArchiveLimits { + entry_name_bytes: 8, + entry_name_total_bytes: 8, + ..ARCHIVE_LIMITS + }, + ] { + assert!(matches!( + preflight(File::open(file.path()).unwrap(), file.path(), limits), + Err(LoadError::InvalidEpub { .. }) + )); + } + } + + #[test] + fn preflight_counts_actual_expansion() { + let fixture = fixture(EPUB_MIMETYPE); + let file = File::open(fixture.path()).unwrap(); + let limits = ArchiveLimits { + expanded_bytes: 64, + ..ARCHIVE_LIMITS + }; + assert!(matches!( + preflight(file, fixture.path(), limits), + Err(LoadError::EpubExpandedTooLarge { limit: 64, .. }) + )); + } + + #[test] + fn text_writer_enforces_projection_limit_without_partial_overflow() { + let path = PathBuf::from("small.epub"); + let file = tempfile::tempfile().unwrap(); + let mut writer = TextWriter::new(file, path, 4); + writer.text("abcd").unwrap(); + assert!(matches!( + writer.text("e"), + Err(LoadError::EpubTextTooLarge { limit: 4, .. }) + )); + } + + #[test] + fn xhtml_depth_is_bounded() { + let mut source = String::from(""); + source.extend(std::iter::repeat_n("
", MAX_XML_DEPTH)); + source.extend(std::iter::repeat_n("
", MAX_XML_DEPTH)); + source.push_str(""); + let file = tempfile::tempfile().unwrap(); + let mut output = TextWriter::new(file, PathBuf::from("deep.epub"), MAX_FILE_BYTES); + let mut headings = HeadingCollector::default(); + let mut targets = Vec::new(); + assert!(matches!( + project_xhtml( + &source, + &mut output, + &mut headings, + &mut targets, + Path::new("deep.epub") + ), + Err(LoadError::InvalidEpub { .. }) + )); + } + + #[test] + fn entry_names_reject_archive_paths() { + for name in ["", "/root", "../book", "a/./b", "a//b", "a\\b", "a\0b"] { + assert!(!safe_entry_name(name), "accepted {name:?}"); + } + for name in ["mimetype", "META-INF/", "OEBPS/text/chapter.xhtml"] { + assert!(safe_entry_name(name), "rejected {name:?}"); + } + } + + #[test] + fn paragraph_separator_accumulates_across_buffered_writes() { + let file = tempfile::tempfile().unwrap(); + let mut writer = TextWriter::new(file, PathBuf::from("spacing.epub"), 64); + writer.literal("one").unwrap(); + writer.line(); + writer.literal("two").unwrap(); + writer.paragraph(); + writer.literal("three").unwrap(); + let (mut file, length) = writer.finish().unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + let mut text = String::new(); + file.read_to_string(&mut text).unwrap(); + assert_eq!(text, "one\ntwo\n\nthree\n"); + assert_eq!(length, text.len() as u64); + assert_eq!(SourceOffset::new(length).get(), text.len() as u64); + } + + #[test] + fn zip_probe_rejects_truncated_records() { + let path = Path::new("truncated.epub"); + let file = tempfile::tempfile().unwrap(); + file.set_len(EOCD_LEN as u64).unwrap(); + assert!(matches!( + inspect_eocd(&file, path, ARCHIVE_LIMITS), + Err(LoadError::InvalidEpub { .. }) + )); + + let mut cursor = Cursor::new(Vec::::new()); + cursor.write_all(EOCD_SIGNATURE).unwrap(); + assert_eq!(cursor.into_inner(), EOCD_SIGNATURE); + } +} diff --git a/src/error.rs b/src/error.rs index d24f241..1a7be2e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,13 @@ pub enum LoadError { NotRegular(PathBuf), TooLarge { path: PathBuf, limit: u64 }, InvalidUtf8 { path: PathBuf, offset: u64 }, + InvalidEpub { path: PathBuf, source: io::Error }, + EpubTooManyEntries { path: PathBuf, limit: usize }, + EpubEntryTooLarge { path: PathBuf, limit: u64 }, + EpubExpandedTooLarge { path: PathBuf, limit: u64 }, + EpubTextTooLarge { path: PathBuf, limit: u64 }, + NoLinearEpubContent(PathBuf), + BufferEpub { path: PathBuf, source: io::Error }, StandardInputTooLarge { limit: u64 }, InvalidStandardInputUtf8 { offset: u64 }, ReadStandardInput { source: io::Error }, @@ -338,6 +345,34 @@ fn load_message(error: &LoadError) -> String { "invalid UTF-8 at byte {offset}: '{}'", sanitize_os(path.as_os_str()) ), + LoadError::InvalidEpub { path, source } => format!( + "invalid or unsupported EPUB '{}': {source}", + sanitize_os(path.as_os_str()) + ), + LoadError::EpubTooManyEntries { path, limit } => format!( + "EPUB contains more than {limit} entries: '{}'", + sanitize_os(path.as_os_str()) + ), + LoadError::EpubEntryTooLarge { path, limit } => format!( + "an EPUB entry exceeds the {limit}-byte expansion limit: '{}'", + sanitize_os(path.as_os_str()) + ), + LoadError::EpubExpandedTooLarge { path, limit } => format!( + "EPUB expands beyond the {limit}-byte limit: '{}'", + sanitize_os(path.as_os_str()) + ), + LoadError::EpubTextTooLarge { path, limit } => format!( + "EPUB text exceeds the {limit}-byte limit: '{}'", + sanitize_os(path.as_os_str()) + ), + LoadError::NoLinearEpubContent(path) => format!( + "EPUB has no linear reading content: '{}'", + sanitize_os(path.as_os_str()) + ), + LoadError::BufferEpub { path, source } => format!( + "cannot buffer EPUB text '{}': {source}", + sanitize_os(path.as_os_str()) + ), LoadError::StandardInputTooLarge { limit } => { format!("standard input exceeds the {limit}-byte limit") } @@ -403,6 +438,8 @@ impl Error for TutError { Self::Load( LoadError::Open { source, .. } | LoadError::Read { source, .. } + | LoadError::InvalidEpub { source, .. } + | LoadError::BufferEpub { source, .. } | LoadError::ReadStandardInput { source } | LoadError::BufferStandardInput { source }, ) @@ -498,6 +535,15 @@ mod tests { "invalid UTF-8 in standard input at byte 16" ); + let epub = TutError::Load(LoadError::InvalidEpub { + path: PathBuf::from("bad\n.epub"), + source: io::Error::new(io::ErrorKind::InvalidData, "broken\npackage"), + }); + assert_eq!( + epub.message(), + "invalid or unsupported EPUB 'bad\\x0a.epub': broken\\x0apackage" + ); + let terminal = TutError::TerminalTooLarge { columns: u16::MAX, rows: u16::MAX, diff --git a/src/lib.rs b/src/lib.rs index 45c2d93..1f0f614 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ use std::{ mod app; mod cli; mod document; +mod epub; mod error; mod layout; mod line_index; @@ -65,7 +66,12 @@ fn run_open(command: OpenCommand) -> Result { } let mut handlers = tui::install_signal_handlers(lease)?; - let result = match document::load(path) { + let document = if epub::is_path(&path) { + epub::load(path) + } else { + document::load(path) + }; + let result = match document { Ok(document) => { run_document(document, &mut handlers, &mut observer, InputKind::Path) } diff --git a/src/tui/ansi.rs b/src/tui/ansi.rs new file mode 100644 index 0000000..0f0a53f --- /dev/null +++ b/src/tui/ansi.rs @@ -0,0 +1,328 @@ +use std::{ + fs::File, + io::{self, BufWriter, Stdout, Write}, + mem, + os::fd::AsRawFd, +}; + +use ratatui::{ + backend::{Backend, ClearType, WindowSize}, + buffer::{Cell, CellWidth}, + layout::{Position, Size}, + style::{Color, Modifier}, +}; + +const CURSOR_HIDE: &[u8] = b"\x1b[?25l"; +const CURSOR_SHOW: &[u8] = b"\x1b[?25h"; +const STYLE_RESET: &[u8] = b"\x1b[0m"; + +/// A small ANSI backend for the fixed, Unix-only terminal surface used by TUT. +#[derive(Debug)] +pub(super) struct AnsiBackend> { + writer: W, + tty: File, + cursor: Position, +} + +impl AnsiBackend> { + /// Creates a buffered stdout backend whose dimensions come from `tty`. + pub(super) fn new(tty: &File) -> io::Result { + Ok(Self { + writer: BufWriter::new(io::stdout()), + tty: tty.try_clone()?, + cursor: Position::ORIGIN, + }) + } +} + +impl AnsiBackend { + /// Writes a session-level escape sequence immediately. + pub(super) fn write_session(&mut self, sequence: &[u8]) -> io::Result<()> { + self.writer.write_all(sequence)?; + self.writer.flush() + } + + fn move_cursor(&mut self, position: Position) -> io::Result<()> { + write!( + self.writer, + "\x1b[{};{}H", + position.y.saturating_add(1), + position.x.saturating_add(1) + )?; + self.cursor = position; + Ok(()) + } +} + +impl Backend for AnsiBackend { + type Error = io::Error; + + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + let mut current_style = None; + let mut next_position = None; + + for (x, y, cell) in content { + let position = Position::new(x, y); + if next_position != Some(position) { + self.move_cursor(position)?; + } + + let style = CellStyle::from(cell); + if current_style != Some(style) { + write_style(&mut self.writer, style)?; + current_style = Some(style); + } + + self.writer.write_all(cell.symbol().as_bytes())?; + let position_after_cell = Position::new(x.saturating_add(cell.cell_width()), y); + self.cursor = position_after_cell; + next_position = Some(position_after_cell); + } + + if current_style.is_some() { + self.writer.write_all(STYLE_RESET)?; + } + Ok(()) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.write_session(CURSOR_HIDE) + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.write_session(CURSOR_SHOW) + } + + fn get_cursor_position(&mut self) -> io::Result { + Ok(self.cursor) + } + + fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + self.move_cursor(position.into())?; + self.writer.flush() + } + + fn clear(&mut self) -> io::Result<()> { + self.clear_region(ClearType::All) + } + + fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> { + let sequence = match clear_type { + ClearType::All => b"\x1b[2J".as_slice(), + ClearType::AfterCursor => b"\x1b[0J".as_slice(), + ClearType::BeforeCursor => b"\x1b[1J".as_slice(), + ClearType::CurrentLine => b"\x1b[2K".as_slice(), + ClearType::UntilNewLine => b"\x1b[0K".as_slice(), + }; + self.write_session(sequence) + } + + fn size(&self) -> io::Result { + Ok(query_window_size(&self.tty)?.columns_rows) + } + + fn window_size(&mut self) -> io::Result { + query_window_size(&self.tty) + } + + fn flush(&mut self) -> io::Result<()> { + self.writer.flush() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CellStyle { + foreground: Color, + background: Color, + modifiers: Modifier, +} + +impl From<&Cell> for CellStyle { + fn from(cell: &Cell) -> Self { + Self { + foreground: cell.fg, + background: cell.bg, + modifiers: cell.modifier, + } + } +} + +fn write_style(writer: &mut W, style: CellStyle) -> io::Result<()> { + writer.write_all(b"\x1b[0")?; + + for (modifier, code) in [ + (Modifier::BOLD, 1), + (Modifier::DIM, 2), + (Modifier::ITALIC, 3), + (Modifier::UNDERLINED, 4), + (Modifier::SLOW_BLINK, 5), + (Modifier::RAPID_BLINK, 6), + (Modifier::REVERSED, 7), + (Modifier::HIDDEN, 8), + (Modifier::CROSSED_OUT, 9), + ] { + if style.modifiers.contains(modifier) { + write!(writer, ";{code}")?; + } + } + + write_color(writer, style.foreground, ColorLayer::Foreground)?; + write_color(writer, style.background, ColorLayer::Background)?; + writer.write_all(b"m") +} + +#[derive(Debug, Clone, Copy)] +enum ColorLayer { + Foreground, + Background, +} + +fn write_color(writer: &mut W, color: Color, layer: ColorLayer) -> io::Result<()> { + let (normal_offset, bright_offset, reset, indexed, rgb) = match layer { + ColorLayer::Foreground => (30, 90, 39, 38, 38), + ColorLayer::Background => (40, 100, 49, 48, 48), + }; + + match color { + Color::Reset => write!(writer, ";{reset}"), + Color::Black => write!(writer, ";{normal_offset}"), + Color::Red => write!(writer, ";{}", normal_offset + 1), + Color::Green => write!(writer, ";{}", normal_offset + 2), + Color::Yellow => write!(writer, ";{}", normal_offset + 3), + Color::Blue => write!(writer, ";{}", normal_offset + 4), + Color::Magenta => write!(writer, ";{}", normal_offset + 5), + Color::Cyan => write!(writer, ";{}", normal_offset + 6), + Color::Gray => write!(writer, ";{}", normal_offset + 7), + Color::DarkGray => write!(writer, ";{bright_offset}"), + Color::LightRed => write!(writer, ";{}", bright_offset + 1), + Color::LightGreen => write!(writer, ";{}", bright_offset + 2), + Color::LightYellow => write!(writer, ";{}", bright_offset + 3), + Color::LightBlue => write!(writer, ";{}", bright_offset + 4), + Color::LightMagenta => write!(writer, ";{}", bright_offset + 5), + Color::LightCyan => write!(writer, ";{}", bright_offset + 6), + Color::White => write!(writer, ";{}", bright_offset + 7), + Color::Indexed(value) => write!(writer, ";{indexed};5;{value}"), + Color::Rgb(red, green, blue) => write!(writer, ";{rgb};2;{red};{green};{blue}"), + } +} + +fn query_window_size(tty: &File) -> io::Result { + // SAFETY: `libc::winsize` contains only integer fields, so all-zero is valid. + let mut size = unsafe { mem::zeroed::() }; + // SAFETY: `tty` owns a valid descriptor and `size` is writable for the duration of the call. + if unsafe { libc::ioctl(tty.as_raw_fd(), libc::TIOCGWINSZ, &mut size) } == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(WindowSize { + columns_rows: Size::new(size.ws_col, size.ws_row), + pixels: Size::new(size.ws_xpixel, size.ws_ypixel), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn backend() -> AnsiBackend> { + AnsiBackend { + writer: Vec::new(), + tty: File::open("/dev/null").expect("open /dev/null"), + cursor: Position::ORIGIN, + } + } + + #[test] + fn style_sequence_covers_all_modifiers_and_named_colors() { + let mut output = Vec::new(); + let style = CellStyle { + foreground: Color::LightCyan, + background: Color::Blue, + modifiers: Modifier::all(), + }; + + write_style(&mut output, style).expect("encode style"); + + assert_eq!(output, b"\x1b[0;1;2;3;4;5;6;7;8;9;96;44m"); + } + + #[test] + fn style_sequence_supports_indexed_and_true_colors() { + let mut indexed = Vec::new(); + write_style( + &mut indexed, + CellStyle { + foreground: Color::Indexed(123), + background: Color::Reset, + modifiers: Modifier::empty(), + }, + ) + .expect("encode indexed style"); + assert_eq!(indexed, b"\x1b[0;38;5;123;49m"); + + let mut rgb = Vec::new(); + write_style( + &mut rgb, + CellStyle { + foreground: Color::Reset, + background: Color::Rgb(1, 2, 3), + modifiers: Modifier::empty(), + }, + ) + .expect("encode RGB style"); + assert_eq!(rgb, b"\x1b[0;39;48;2;1;2;3m"); + } + + #[test] + fn draw_repositions_disjoint_cells_and_tracks_the_cursor() { + let mut backend = backend(); + let mut first = Cell::new("A"); + first.set_fg(Color::Red); + let second = Cell::new("界"); + let third = Cell::new("Z"); + + backend + .draw([(2, 3, &first), (3, 3, &second), (8, 4, &third)].into_iter()) + .expect("draw cells"); + + assert_eq!(backend.get_cursor_position().unwrap(), Position::new(9, 4)); + assert_eq!( + backend.writer, + b"\x1b[4;3H\x1b[0;31;49mA\x1b[0;39;49m\ + \xE7\x95\x8C\x1b[5;9HZ\x1b[0m" + ); + } + + #[test] + fn cursor_clear_and_session_sequences_flush_to_the_writer() { + let mut backend = backend(); + + backend.hide_cursor().unwrap(); + backend.show_cursor().unwrap(); + backend.set_cursor_position(Position::new(7, 11)).unwrap(); + backend.clear_region(ClearType::BeforeCursor).unwrap(); + backend.clear_region(ClearType::CurrentLine).unwrap(); + backend.clear_region(ClearType::UntilNewLine).unwrap(); + backend.clear_region(ClearType::AfterCursor).unwrap(); + backend.clear().unwrap(); + backend.write_session(b"session").unwrap(); + + assert_eq!(backend.get_cursor_position().unwrap(), Position::new(7, 11)); + assert_eq!( + backend.writer, + b"\x1b[?25l\x1b[?25h\x1b[12;8H\x1b[1J\x1b[2K\x1b[0K\x1b[0J\ + \x1b[2Jsession" + ); + } + + #[test] + fn failed_terminal_size_query_preserves_the_os_error() { + let mut backend = backend(); + + assert!(backend.size().is_err()); + assert!(backend.window_size().is_err()); + } +} diff --git a/src/tui/input.rs b/src/tui/input.rs index b84ff44..88b88fa 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -1,7 +1,125 @@ -use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use std::ops::{BitOr, BitOrAssign}; use crate::app::{Action, ContentMode, Geometry, Mode}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Event { + Key(KeyEvent), + Resize(u16, u16), + Ignored, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum KeyCode { + Char(char), + Up, + Down, + PageUp, + PageDown, + Home, + End, + Backspace, + Enter, + Esc, + F(u8), + Modifier, + CapsLock, + ScrollLock, + NumLock, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct KeyEvent { + pub(super) code: KeyCode, + pub(super) modifiers: KeyModifiers, + pub(super) kind: KeyEventKind, + pub(super) state: KeyEventState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum KeyEventKind { + Press, + Repeat, + Release, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[repr(transparent)] +pub(super) struct KeyModifiers(u8); + +impl KeyModifiers { + pub(super) const NONE: Self = Self(0); + pub(super) const SHIFT: Self = Self(1 << 0); + pub(super) const CONTROL: Self = Self(1 << 1); + pub(super) const ALT: Self = Self(1 << 2); + pub(super) const SUPER: Self = Self(1 << 3); + pub(super) const HYPER: Self = Self(1 << 4); + pub(super) const META: Self = Self(1 << 5); + const ALL: Self = Self( + Self::SHIFT.0 + | Self::CONTROL.0 + | Self::ALT.0 + | Self::SUPER.0 + | Self::HYPER.0 + | Self::META.0, + ); + + pub(super) const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub(super) const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } + + pub(super) const fn intersects(self, other: Self) -> bool { + self.0 & other.0 != 0 + } + + pub(super) fn set(&mut self, other: Self, value: bool) { + if value { + self.0 |= other.0; + } else { + self.0 &= !other.0; + } + } + + pub(super) const fn bits(self) -> u8 { + self.0 + } + + pub(super) const fn from_bits_truncate(bits: u8) -> Self { + Self(bits & Self::ALL.0) + } +} + +impl BitOr for KeyModifiers { + type Output = Self; + + fn bitor(self, rhs: Self) -> Self::Output { + self.union(rhs) + } +} + +impl BitOrAssign for KeyModifiers { + fn bitor_assign(&mut self, rhs: Self) { + *self = self.union(rhs); + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[repr(transparent)] +pub(super) struct KeyEventState(u8); + +impl KeyEventState { + pub(super) const NONE: Self = Self(0); + pub(super) const CAPS_LOCK: Self = Self(1 << 0); + + pub(super) const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + const NON_TEXT_MODIFIERS: KeyModifiers = KeyModifiers::CONTROL .union(KeyModifiers::ALT) .union(KeyModifiers::SUPER) @@ -17,7 +135,7 @@ fn normalize_key_case(mut key: KeyEvent) -> KeyEvent { } // Legacy and original CSI-u input contain the produced character, while Kitty-style CSI-u's - // primary codepoint is the unshifted key. Crossterm can also consume SHIFT when a Kitty + // primary codepoint is the unshifted key. A protocol parser can also consume SHIFT when a Kitty // alternate keycode supplies the produced character. Treat an uppercase codepoint as the // consumed SHIFT representation, then apply Caps Lock only to text-like keys. Lock state must // not change physical Ctrl/Alt shortcut matching (for example, Ctrl-C remains Ctrl-C while @@ -51,9 +169,9 @@ pub(super) fn map_event( if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return None; } - // Crossterm treats `Char('g') + SHIFT` and `Char('G') + SHIFT` as the same key, - // but different terminal protocols can emit either representation. Canonicalize before - // matching commands or inserting search text so their behavior agrees as well. + // `Char('g') + SHIFT` and `Char('G') + SHIFT` describe the same key, but terminal protocols can + // emit either representation. Canonicalize before matching commands or inserting search text + // so their behavior agrees as well. let key = normalize_key_case(key); if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::CONTROL { return Some(Action::Interrupt); @@ -86,6 +204,8 @@ fn map_reading_key(key: KeyEvent, repeat_active: bool) -> Option { (KeyCode::Char('G'), KeyModifiers::SHIFT) | (KeyCode::End, KeyModifiers::NONE) => { Some(Action::DocumentEnd) } + (KeyCode::Char('['), KeyModifiers::NONE) => Some(Action::PreviousSection), + (KeyCode::Char(']'), KeyModifiers::NONE) => Some(Action::NextSection), (KeyCode::Char('/'), KeyModifiers::NONE) => Some(Action::BeginSearch), (KeyCode::Char('n'), KeyModifiers::NONE) => Some(Action::NextMatch), (KeyCode::Char('N'), KeyModifiers::SHIFT) => Some(Action::PreviousMatch), @@ -115,9 +235,7 @@ fn map_reading_key(key: KeyEvent, repeat_active: bool) -> Option { // Kitty can report modifier and lock keys separately. They change the following chord // rather than acting as unknown commands, so they must not discard an active repeat // prefix. - (KeyCode::Modifier(_) | KeyCode::CapsLock | KeyCode::ScrollLock | KeyCode::NumLock, _) => { - None - } + (KeyCode::Modifier | KeyCode::CapsLock | KeyCode::ScrollLock | KeyCode::NumLock, _) => None, _ if key.kind == KeyEventKind::Press && repeat_active => Some(Action::RepeatCancel), _ => None, } @@ -162,8 +280,6 @@ fn map_search_key(key: KeyEvent) -> Option { #[cfg(test)] mod tests { - use crossterm::event::{ModifierKeyCode, MouseEvent, MouseEventKind}; - use super::*; fn key(code: KeyCode, modifiers: KeyModifiers) -> Event { @@ -205,6 +321,24 @@ mod tests { Mode::Content(ContentMode::Reading) } + #[test] + fn modifier_flags_truncate_unknown_bits_and_support_updates() { + let all_modifiers = KeyModifiers::SHIFT + | KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META; + let mut modifiers = KeyModifiers::from_bits_truncate(u8::MAX); + assert_eq!(modifiers.bits(), all_modifiers.bits()); + assert!(modifiers.contains(KeyModifiers::SHIFT | KeyModifiers::CONTROL)); + assert!(modifiers.intersects(KeyModifiers::META)); + modifiers.set(KeyModifiers::ALT, false); + assert!(!modifiers.intersects(KeyModifiers::ALT)); + modifiers |= KeyModifiers::ALT; + assert!(modifiers.contains(KeyModifiers::ALT)); + } + #[test] fn reading_mode_maps_navigation_search_and_quit() { for (event, action) in [ @@ -234,6 +368,14 @@ mod tests { key(KeyCode::Char('G'), KeyModifiers::SHIFT), Action::DocumentEnd, ), + ( + key(KeyCode::Char('['), KeyModifiers::NONE), + Action::PreviousSection, + ), + ( + key(KeyCode::Char(']'), KeyModifiers::NONE), + Action::NextSection, + ), ( key(KeyCode::Char('/'), KeyModifiers::NONE), Action::BeginSearch, @@ -303,17 +445,16 @@ mod tests { map(true, key(KeyCode::Char('?'), KeyModifiers::SHIFT)), Some(Action::RepeatCancel) ); - for (code, modifiers) in [ - (ModifierKeyCode::LeftShift, KeyModifiers::SHIFT), - (ModifierKeyCode::RightControl, KeyModifiers::CONTROL), - (ModifierKeyCode::LeftAlt, KeyModifiers::ALT), - (ModifierKeyCode::RightSuper, KeyModifiers::SUPER), - (ModifierKeyCode::LeftHyper, KeyModifiers::HYPER), - (ModifierKeyCode::RightMeta, KeyModifiers::META), - (ModifierKeyCode::IsoLevel3Shift, KeyModifiers::NONE), - (ModifierKeyCode::IsoLevel5Shift, KeyModifiers::NONE), + for modifiers in [ + KeyModifiers::SHIFT, + KeyModifiers::CONTROL, + KeyModifiers::ALT, + KeyModifiers::SUPER, + KeyModifiers::HYPER, + KeyModifiers::META, + KeyModifiers::NONE, ] { - assert_eq!(map(true, key(KeyCode::Modifier(code), modifiers)), None); + assert_eq!(map(true, key(KeyCode::Modifier, modifiers)), None); } for code in [KeyCode::CapsLock, KeyCode::ScrollLock, KeyCode::NumLock] { assert_eq!(map(true, key(code, KeyModifiers::NONE)), None); @@ -326,6 +467,10 @@ mod tests { map(true, key(KeyCode::Char('/'), KeyModifiers::NONE)), Some(Action::BeginSearch) ); + assert_eq!( + map(true, key(KeyCode::Char(']'), KeyModifiers::NONE)), + Some(Action::NextSection) + ); assert_eq!( map(true, key(KeyCode::F(1), KeyModifiers::NONE)), Some(Action::ShowHelp) @@ -358,6 +503,14 @@ mod tests { map_event(&mode, false, key(KeyCode::Char('7'), KeyModifiers::NONE)), Some(Action::SearchInsert('7')) ); + assert_eq!( + map_event(&mode, false, key(KeyCode::Char('['), KeyModifiers::NONE)), + Some(Action::SearchInsert('[')) + ); + assert_eq!( + map_event(&mode, false, key(KeyCode::Char(']'), KeyModifiers::NONE)), + Some(Action::SearchInsert(']')) + ); assert_eq!( map_event(&mode, false, key(KeyCode::Backspace, KeyModifiers::NONE)), Some(Action::SearchBackspace) @@ -457,7 +610,7 @@ mod tests { KeyModifiers::SHIFT | KeyModifiers::ALT, KeyModifiers::SHIFT | KeyModifiers::CONTROL, ] { - for character in ['g', 'n', 'q'] { + for character in ['g', 'n', 'q', '[', ']'] { assert_eq!( map_event( &reading_mode(), @@ -759,18 +912,6 @@ mod tests { None ); } - assert_eq!( - map_event( - &reading_mode(), - false, - Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }) - ), - None - ); + assert_eq!(map_event(&reading_mode(), false, Event::Ignored), None); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f00f6bd..eb9f727 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,30 +1,17 @@ +mod ansi; mod input; mod signals; +mod terminal_io; mod view; -use std::{ - io::{self, Stdout}, - mem::size_of, - time::Duration, -}; +use std::{io, mem::size_of, time::Duration}; -use crossterm::{ - cursor::{Hide, Show}, - event::{self, Event}, - execute, - terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, - is_raw_mode_enabled, - }, -}; -use ratatui::{ - Terminal, TerminalOptions, Viewport, - backend::{Backend, CrosstermBackend}, - buffer::Cell, - layout::Rect, -}; +use ansi::AnsiBackend; +use input::Event; +use ratatui::{Terminal, TerminalOptions, Viewport, backend::Backend, buffer::Cell, layout::Rect}; use signals::SuspendOutcome; pub(super) use signals::{ProcessSessionLease, SignalHandlers, SignalState}; +use terminal_io::TerminalInput; use crate::{ app::{Action, App, BackgroundWork, Geometry, Outcome, ViewState}, @@ -237,24 +224,19 @@ impl<'a, T: TerminalDriver> TerminalSession<'a, T> { fn restore(&mut self) -> Option { let mut first = None; if self.cursor_cleanup { - self.cursor_cleanup = false; - retain_first(&mut first, "show cursor", self.driver.show_cursor()); + let result = self.driver.show_cursor(); + self.cursor_cleanup = result.is_err(); + retain_first(&mut first, "show cursor", result); } if self.alternate_cleanup { - self.alternate_cleanup = false; - retain_first( - &mut first, - "leave alternate screen", - self.driver.leave_alternate_screen(), - ); + let result = self.driver.leave_alternate_screen(); + self.alternate_cleanup = result.is_err(); + retain_first(&mut first, "leave alternate screen", result); } if self.raw_cleanup { - self.raw_cleanup = false; - retain_first( - &mut first, - "disable raw mode", - self.driver.disable_raw_mode(), - ); + let result = self.driver.disable_raw_mode(); + self.raw_cleanup = result.is_err(); + retain_first(&mut first, "disable raw mode", result); } first } @@ -648,8 +630,12 @@ fn advance_background( result.map_err(Primary::Error) } -struct CrosstermDriver { - terminal: Terminal>, +const ENTER_ALTERNATE_SCREEN: &[u8] = b"\x1b[?1049h"; +const LEAVE_ALTERNATE_SCREEN: &[u8] = b"\x1b[?1049l"; + +struct UnixDriver { + terminal: Terminal, + input: TerminalInput, } fn try_draw_fallible(terminal: &mut Terminal, render: F) -> Result<(), TutError> @@ -687,41 +673,47 @@ fn render_budgeted_frame( view::render(frame, state, &mut symbols) } -impl CrosstermDriver { +impl UnixDriver { fn new() -> io::Result { + let input = TerminalInput::open()?; + let screen = input.screen_clone()?; let options = TerminalOptions { viewport: Viewport::Fixed(Rect::default()), }; Ok(Self { - terminal: Terminal::with_options(CrosstermBackend::new(io::stdout()), options)?, + terminal: Terminal::with_options(AnsiBackend::new(&screen)?, options)?, + input, }) } } -impl TerminalDriver for CrosstermDriver { +impl TerminalDriver for UnixDriver { fn raw_mode_enabled(&mut self) -> io::Result { - is_raw_mode_enabled() + self.input.raw_mode_enabled() } fn size(&mut self) -> io::Result<(u16, u16)> { - let size = self.terminal.size()?; - Ok((size.width, size.height)) + self.input.size() } fn resize(&mut self, size: TerminalSize) -> io::Result<()> { - self.terminal.resize(size.area()) + self.terminal.resize(size.area())?; + self.input.record_size((size.width, size.height)); + Ok(()) } fn enable_raw_mode(&mut self) -> io::Result<()> { - enable_raw_mode() + self.input.enable_raw_mode() } fn enter_alternate_screen(&mut self) -> io::Result<()> { - execute!(self.terminal.backend_mut(), EnterAlternateScreen) + self.terminal + .backend_mut() + .write_session(ENTER_ALTERNATE_SCREEN) } fn hide_cursor(&mut self) -> io::Result<()> { - execute!(self.terminal.backend_mut(), Hide) + self.terminal.hide_cursor() } fn draw(&mut self, app: &mut App) -> Result<(), TutError> { @@ -732,14 +724,17 @@ impl TerminalDriver for CrosstermDriver { } fn poll(&mut self, timeout: Duration) -> io::Result { - event::poll(timeout) + self.input.poll(timeout) } fn read(&mut self) -> io::Result { - event::read() + self.input.read() } fn suspend(&mut self) -> io::Result<()> { + // A signal can arrive after poll has cached an event but before read consumes it. Do not + // replay that pre-suspension event after the terminal has been rebuilt with fresh geometry. + self.input.discard_buffered_events(); // SAFETY: SIGSTOP is valid and raise has no Rust memory-safety requirements. let result = unsafe { libc::raise(libc::SIGSTOP) }; if result == 0 { @@ -752,15 +747,17 @@ impl TerminalDriver for CrosstermDriver { } fn show_cursor(&mut self) -> io::Result<()> { - execute!(self.terminal.backend_mut(), Show) + self.terminal.show_cursor() } fn leave_alternate_screen(&mut self) -> io::Result<()> { - execute!(self.terminal.backend_mut(), LeaveAlternateScreen) + self.terminal + .backend_mut() + .write_session(LEAVE_ALTERNATE_SCREEN) } fn disable_raw_mode(&mut self) -> io::Result<()> { - disable_raw_mode() + self.input.disable_raw_mode() } } @@ -770,7 +767,7 @@ pub(super) fn run( observer: &mut Observer, ) -> Result { let signals = handlers.state().clone(); - let mut driver = match CrosstermDriver::new() { + let mut driver = match UnixDriver::new() { Ok(driver) => driver, Err(source) => { if let Some(signal) = signals.received() { @@ -796,7 +793,6 @@ mod tests { cell::Cell as StateCell, collections::VecDeque, convert::Infallible, fs, path::Path, rc::Rc, }; - use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use libc::{SIGHUP, SIGTERM}; use ratatui::{ backend::{ClearType, TestBackend, WindowSize}, @@ -804,6 +800,7 @@ mod tests { }; use tempfile::tempdir; + use super::input::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use super::*; use crate::app::{ BoundedCapacityFootprint, BoundedCapacityProbe, RenderRows, RenderRowsView, RenderState, @@ -1363,7 +1360,7 @@ mod tests { MAX_TERMINAL_CELLS * 2 * u64::try_from(size_of::()).unwrap() ); assert!(boundary.buffer_bytes() <= MAX_TERMINAL_BUFFER_BYTES); - assert_eq!(boundary.symbol_budget(), 8 * 1024 * 1024); + assert_eq!(boundary.symbol_budget(), 12 * 1024 * 1024); assert!(TerminalSize::new(1000, 300).is_ok()); assert!(TerminalSize::new(0, u16::MAX).is_ok()); assert!(matches!( @@ -1433,6 +1430,8 @@ mod tests { let state = ViewState::Reader(RenderState { filename: "fixture", path: "fixture", + book: None, + body_width: COLUMNS, rows: RenderRowsView::from_rows(&rows), progress: 100, current_line: Some(1), @@ -1455,11 +1454,11 @@ mod tests { assert!(matches!( error, - TutError::TerminalFrameSymbolBudgetExceeded { limit: 8_388_608 } + TutError::TerminalFrameSymbolBudgetExceeded { limit: 12_582_912 } )); assert_eq!( error.message(), - "terminal frame symbols exceed the 8388608-byte limit" + "terminal frame symbols exceed the 12582912-byte limit" ); assert_eq!(terminal.backend().draw_calls, 0); assert_eq!(terminal.backend().flush_calls, 0); @@ -1485,7 +1484,10 @@ mod tests { .all(|cell| cell == &Cell::default()) ); - let retry = ViewState::Help { q_closes: true }; + let retry = ViewState::Help { + q_closes: true, + book: false, + }; try_draw_fallible(&mut terminal, |frame| render_budgeted_frame(frame, &retry)).unwrap(); assert_eq!(terminal.backend().draw_calls, 1); assert_eq!(terminal.backend().flush_calls, 1); @@ -1500,7 +1502,10 @@ mod tests { let mut backend = IoTestBackend::new(16, 4); backend.fail_draw = true; let mut terminal = Terminal::with_options(backend, options).unwrap(); - let state = ViewState::Help { q_closes: true }; + let state = ViewState::Help { + q_closes: true, + book: false, + }; let error = try_draw_fallible(&mut terminal, |frame| render_budgeted_frame(frame, &state)) .unwrap_err(); @@ -2103,7 +2108,7 @@ mod tests { let signals = SignalState::empty(); let mut driver = FakeDriver::new(&signals); driver.sizes.push_back((4096, 4)); - driver.events.extend([Event::FocusGained, quit_event()]); + driver.events.extend([Event::Ignored, quit_event()]); let mut recorder = TraceRecorder::default(); let mut app = app_from_text(Path::new("/tmp/queued-render.txt"), "x".repeat(4096)); @@ -2183,7 +2188,7 @@ mod tests { let mut app = App::new(crate::document::load(path).unwrap()); let signals = SignalState::empty(); let mut driver = FakeDriver::new(&signals); - driver.events.extend([Event::FocusGained, quit_event()]); + driver.events.extend([Event::Ignored, quit_event()]); let mut recorder = TraceRecorder::default(); assert_eq!( @@ -2389,7 +2394,7 @@ mod tests { let signals = SignalState::empty(); let mut driver = FakeDriver::new(&signals); driver.sizes.extend([(4096, 4), (resume_width, 4)]); - driver.events.extend([Event::FocusGained, quit_event()]); + driver.events.extend([Event::Ignored, quit_event()]); let mut recorder = TraceRecorder { suspend_after_render: Some(signals.clone()), ..TraceRecorder::default() @@ -2490,7 +2495,16 @@ mod tests { assert!( driver .calls - .ends_with(&["show_cursor", "leave_alt", "disable_raw"]) + .windows(3) + .any(|calls| calls == ["show_cursor", "leave_alt", "disable_raw"]) + ); + assert_eq!( + driver + .calls + .iter() + .filter(|call| **call == "show_cursor") + .count(), + 2 ); } @@ -2709,12 +2723,9 @@ mod tests { let mut app = App::new(crate::document::load(path).unwrap()); let signals = SignalState::empty(); let mut driver = FakeDriver::new(&signals); - driver.events.extend([ - Event::FocusGained, - Event::FocusGained, - Event::FocusGained, - quit_event(), - ]); + driver + .events + .extend([Event::Ignored, Event::Ignored, Event::Ignored, quit_event()]); assert_eq!( run_with_driver(&mut app, &mut driver, &signals).unwrap(), @@ -2753,11 +2764,11 @@ mod tests { let signals = SignalState::empty(); let mut driver = FakeDriver::new(&signals); driver.events.extend([ - Event::FocusGained, - Event::FocusGained, - Event::FocusGained, - Event::FocusGained, - Event::FocusGained, + Event::Ignored, + Event::Ignored, + Event::Ignored, + Event::Ignored, + Event::Ignored, quit_event(), ]); let mut recorder = TraceRecorder::default(); diff --git a/src/tui/terminal_io.rs b/src/tui/terminal_io.rs new file mode 100644 index 0000000..39d62e9 --- /dev/null +++ b/src/tui/terminal_io.rs @@ -0,0 +1,1071 @@ +use std::{ + fs::{File, OpenOptions}, + io::{self, Read}, + os::fd::{AsRawFd, FromRawFd}, + time::{Duration, Instant}, +}; + +use super::input::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + +const MAX_SEQUENCE_BYTES: usize = 64 * 1024; +const SEQUENCE_TIMEOUT: Duration = Duration::from_secs(2); +const ESCAPE_TIMEOUT: Duration = Duration::from_millis(25); +const READ_CHUNK_BYTES: usize = 8 * 1024; + +pub(super) struct TerminalInput { + input: File, + screen: File, + original_mode: Option, + input_disconnected: bool, + buffer: Vec, + start: usize, + ready: Option, + incomplete_since: Option, + last_size: (u16, u16), +} + +impl TerminalInput { + pub(super) fn open() -> io::Result { + let input = terminal_input()?; + let screen = screen_terminal()?; + let last_size = terminal_size(&screen)?; + Ok(Self { + input, + screen, + original_mode: None, + input_disconnected: false, + buffer: Vec::with_capacity(READ_CHUNK_BYTES), + start: 0, + ready: None, + incomplete_since: None, + last_size, + }) + } + + pub(super) fn screen_clone(&self) -> io::Result { + self.screen.try_clone() + } + + pub(super) fn size(&self) -> io::Result<(u16, u16)> { + terminal_size(&self.screen) + } + + pub(super) fn record_size(&mut self, size: (u16, u16)) { + self.last_size = size; + } + + pub(super) fn discard_buffered_events(&mut self) { + self.ready = None; + self.buffer.clear(); + self.start = 0; + self.incomplete_since = None; + } + + pub(super) fn raw_mode_enabled(&self) -> io::Result { + terminal_mode(&self.input).map(|mode| mode_is_raw(&mode)) + } + + pub(super) fn enable_raw_mode(&mut self) -> io::Result<()> { + if self.original_mode.is_some() { + return Ok(()); + } + + let original = terminal_mode(&self.input)?; + let mut raw = original; + // SAFETY: raw is initialized termios storage owned by this function. + unsafe { libc::cfmakeraw(&mut raw) }; + // Keep Unix-generated Ctrl-C, Ctrl-Z, and related job-control signals active. + raw.c_lflag |= libc::ISIG; + set_terminal_mode(&self.input, &raw)?; + self.original_mode = Some(original); + self.input_disconnected = false; + Ok(()) + } + + pub(super) fn disable_raw_mode(&mut self) -> io::Result<()> { + let Some(original) = self.original_mode.as_ref() else { + return Ok(()); + }; + if let Err(error) = set_terminal_mode(&self.input, original) { + // Linux returns EIO once a PTY's last master has closed. At that point the + // disconnected terminal can no longer observe raw mode, so there is no live state + // left to restore. Confirm the hangup with tcgetattr so a live background terminal's + // EIO remains a real cleanup failure. + let confirmed_disconnect = self.input_disconnected + && error.raw_os_error() == Some(libc::EIO) + && matches!( + terminal_mode(&self.input), + Err(probe) if probe.raw_os_error() == Some(libc::EIO) + ); + if !confirmed_disconnect { + return Err(error); + } + } + self.original_mode = None; + Ok(()) + } + + pub(super) fn poll(&mut self, timeout: Duration) -> io::Result { + if self.ready.is_some() { + return Ok(true); + } + + let call_started = Instant::now(); + let call_deadline = call_started.checked_add(timeout); + let mut checked_after_deadline = false; + loop { + if self.prepare_event()? { + return Ok(true); + } + + let size = self.size()?; + if size != self.last_size { + self.last_size = size; + self.ready = Some(Event::Resize(size.0, size.1)); + return Ok(true); + } + + let now = Instant::now(); + // A zero timeout still means one nonblocking descriptor check. The event loop uses + // that path while background work is pending so keyboard input can preempt it. + let call_remaining = remaining_until(call_deadline, now); + let call_expired = call_remaining.is_none(); + if call_expired && checked_after_deadline { + return Ok(false); + } + checked_after_deadline |= call_expired; + let call_remaining = call_remaining.unwrap_or(Duration::ZERO); + let wait = call_remaining.min(self.sequence_wait(now)?); + if !poll_readable(&self.input, wait)? { + if self.prepare_event()? { + return Ok(true); + } + if call_expired || remaining_until(call_deadline, Instant::now()).is_none() { + return Ok(false); + } + continue; + } + + self.read_available()?; + } + } + + pub(super) fn read(&mut self) -> io::Result { + self.ready + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, "no terminal event is ready")) + } + + fn active(&self) -> &[u8] { + &self.buffer[self.start..] + } + + fn prepare_event(&mut self) -> io::Result { + loop { + if self.active().is_empty() { + self.compact(); + self.incomplete_since = None; + return Ok(false); + } + + let escape_expired = self + .incomplete_since + .is_some_and(|started| started.elapsed() >= ESCAPE_TIMEOUT); + match parse_one(self.active(), escape_expired)? { + Parsed::Complete(event, used) => { + self.consume(used); + self.incomplete_since = None; + if event == Event::Ignored { + continue; + } + self.ready = Some(event); + return Ok(true); + } + Parsed::Incomplete => { + let started = *self.incomplete_since.get_or_insert_with(Instant::now); + if started.elapsed() >= SEQUENCE_TIMEOUT { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal input sequence did not finish within 2 seconds", + )); + } + return Ok(false); + } + } + } + } + + fn sequence_wait(&self, now: Instant) -> io::Result { + let Some(started) = self.incomplete_since else { + return Ok(Duration::MAX); + }; + let active = self.active(); + let limit = if active == [0x1b] { + ESCAPE_TIMEOUT + } else { + SEQUENCE_TIMEOUT + }; + let elapsed = now.saturating_duration_since(started); + if elapsed >= SEQUENCE_TIMEOUT { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "terminal input sequence did not finish within 2 seconds", + )); + } + Ok(limit.saturating_sub(elapsed)) + } + + fn read_available(&mut self) -> io::Result<()> { + let active_len = self.active().len(); + if active_len >= MAX_SEQUENCE_BYTES { + let mut extra = [0_u8; 1]; + return match self.input.read(&mut extra) { + Ok(0) => Err(self.mark_input_disconnected()), + Ok(_) => Err(sequence_too_large()), + Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(()), + Err(error) if error.raw_os_error() == Some(libc::EIO) => { + Err(self.mark_input_disconnected()) + } + Err(error) => Err(error), + }; + } + + self.compact(); + let available = MAX_SEQUENCE_BYTES - self.active().len(); + let mut bytes = [0_u8; READ_CHUNK_BYTES]; + let requested = available.min(bytes.len()); + match self.input.read(&mut bytes[..requested]) { + Ok(0) => Err(self.mark_input_disconnected()), + Ok(count) => { + self.buffer + .try_reserve(count) + .map_err(|_| io::Error::other("terminal input buffer allocation failed"))?; + self.buffer.extend_from_slice(&bytes[..count]); + Ok(()) + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(()), + Err(error) if error.raw_os_error() == Some(libc::EIO) => { + Err(self.mark_input_disconnected()) + } + Err(error) => Err(error), + } + } + + fn mark_input_disconnected(&mut self) -> io::Error { + self.input_disconnected = true; + input_closed() + } + + fn consume(&mut self, count: usize) { + self.start += count; + self.compact(); + } + + fn compact(&mut self) { + if self.start == self.buffer.len() { + self.buffer.clear(); + self.start = 0; + } else if self.start >= READ_CHUNK_BYTES && self.start >= self.buffer.len() / 2 { + self.buffer.drain(..self.start); + self.start = 0; + } + } +} + +impl Drop for TerminalInput { + fn drop(&mut self) { + if let Some(original) = self.original_mode.take() { + let _ = set_terminal_mode(&self.input, &original); + } + } +} + +fn terminal_input() -> io::Result { + if let Some(stdin) = duplicate_terminal(libc::STDIN_FILENO) { + return Ok(stdin); + } + OpenOptions::new().read(true).write(true).open("/dev/tty") +} + +fn screen_terminal() -> io::Result { + match OpenOptions::new().read(true).write(true).open("/dev/tty") { + Ok(terminal) => Ok(terminal), + Err(dev_tty_error) => duplicate_terminal(libc::STDOUT_FILENO).ok_or(dev_tty_error), + } +} + +fn duplicate_terminal(fd: libc::c_int) -> Option { + // SAFETY: fcntl receives a valid command and does not retain pointers. + let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) }; + if duplicate == -1 { + return None; + } + // SAFETY: duplicate is a fresh owned descriptor returned by fcntl. + let file = unsafe { File::from_raw_fd(duplicate) }; + // SAFETY: file owns a live descriptor for the duration of the call. + (unsafe { libc::isatty(file.as_raw_fd()) } == 1).then_some(file) +} + +fn terminal_mode(terminal: &File) -> io::Result { + // SAFETY: libc::termios contains only C scalar and array fields. + let mut mode = unsafe { std::mem::zeroed::() }; + // SAFETY: terminal is open and mode points to writable storage. + if unsafe { libc::tcgetattr(terminal.as_raw_fd(), &mut mode) } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(mode) + } +} + +fn set_terminal_mode(terminal: &File, mode: &libc::termios) -> io::Result<()> { + // SAFETY: terminal is open and mode points to initialized termios storage. + if unsafe { libc::tcsetattr(terminal.as_raw_fd(), libc::TCSANOW, mode) } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn mode_is_raw(mode: &libc::termios) -> bool { + let disabled_input = libc::BRKINT | libc::ICRNL | libc::INPCK | libc::ISTRIP | libc::IXON; + let disabled_local = libc::ECHO | libc::ICANON | libc::IEXTEN; + mode.c_iflag & disabled_input == 0 + && mode.c_oflag & libc::OPOST == 0 + && mode.c_lflag & disabled_local == 0 + && mode.c_cflag & libc::CSIZE == libc::CS8 + && mode.c_cc[libc::VMIN] == 1 + && mode.c_cc[libc::VTIME] == 0 +} + +pub(super) fn terminal_size(terminal: &File) -> io::Result<(u16, u16)> { + // SAFETY: libc::winsize contains only C scalar fields. + let mut size = unsafe { std::mem::zeroed::() }; + // SAFETY: terminal is open and size points to writable winsize storage. + if unsafe { libc::ioctl(terminal.as_raw_fd(), libc::TIOCGWINSZ, &mut size) } == -1 { + Err(io::Error::last_os_error()) + } else { + Ok((size.ws_col, size.ws_row)) + } +} + +#[cfg(not(target_os = "macos"))] +fn poll_readable(input: &File, timeout: Duration) -> io::Result { + let mut descriptor = libc::pollfd { + fd: input.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: descriptor points to one initialized pollfd for the duration of the call. + let result = unsafe { libc::poll(&mut descriptor, 1, poll_milliseconds(timeout)) }; + if result == -1 { + let error = io::Error::last_os_error(); + return if error.kind() == io::ErrorKind::Interrupted { + Ok(false) + } else { + Err(error) + }; + } + if result == 0 { + return Ok(false); + } + if descriptor.revents & libc::POLLNVAL != 0 { + return Err(input_closed()); + } + Ok(descriptor.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0) +} + +#[cfg(target_os = "macos")] +fn poll_readable(input: &File, timeout: Duration) -> io::Result { + let fd = input.as_raw_fd(); + if fd < 0 + || usize::try_from(fd) + .ok() + .is_none_or(|fd| fd >= libc::FD_SETSIZE) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal input descriptor exceeds the select limit", + )); + } + + // Darwin poll(2) reports POLLNVAL for valid PTYs, so use select(2) on this platform. + // SAFETY: fd_set is initialized by FD_ZERO before it is passed to FD_SET or select. + let mut readable = unsafe { std::mem::zeroed::() }; + // SAFETY: readable points to initialized fd_set storage and fd passed the FD_SETSIZE bound. + unsafe { + libc::FD_ZERO(&mut readable); + libc::FD_SET(fd, &mut readable); + } + let mut limit = (timeout != Duration::MAX).then(|| libc::timeval { + tv_sec: libc::time_t::try_from(timeout.as_secs()).unwrap_or(libc::time_t::MAX), + tv_usec: libc::suseconds_t::try_from(timeout.subsec_micros()) + .expect("subsecond microseconds fit suseconds_t"), + }); + // SAFETY: readable and the optional timeout remain valid and exclusive for the call. + let result = unsafe { + libc::select( + fd + 1, + &mut readable, + std::ptr::null_mut(), + std::ptr::null_mut(), + limit + .as_mut() + .map_or(std::ptr::null_mut(), std::ptr::from_mut), + ) + }; + if result == -1 { + let error = io::Error::last_os_error(); + return if error.kind() == io::ErrorKind::Interrupted { + Ok(false) + } else { + Err(error) + }; + } + if result == 0 { + return Ok(false); + } + // SAFETY: readable was populated by select and fd remains within FD_SETSIZE. + Ok(unsafe { libc::FD_ISSET(fd, &readable) }) +} + +#[cfg(not(target_os = "macos"))] +fn poll_milliseconds(timeout: Duration) -> libc::c_int { + if timeout == Duration::MAX { + return -1; + } + let milliseconds = + timeout.as_millis() + u128::from(!timeout.subsec_nanos().is_multiple_of(1_000_000)); + libc::c_int::try_from(milliseconds).unwrap_or(libc::c_int::MAX) +} + +fn remaining_until(deadline: Option, now: Instant) -> Option { + match deadline { + Some(deadline) if now >= deadline => None, + Some(deadline) => Some(deadline.duration_since(now)), + None => Some(Duration::MAX), + } +} + +fn input_closed() -> io::Error { + io::Error::new(io::ErrorKind::UnexpectedEof, "terminal input closed") +} + +fn sequence_too_large() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "terminal input sequence exceeded 65536 bytes", + ) +} + +enum Parsed { + Complete(Event, usize), + Incomplete, +} + +fn parse_one(bytes: &[u8], escape_expired: bool) -> io::Result { + let Some(&first) = bytes.first() else { + return Ok(Parsed::Incomplete); + }; + if first == 0x1b { + return parse_escape(bytes, escape_expired); + } + parse_plain_key(bytes) +} + +fn parse_plain_key(bytes: &[u8]) -> io::Result { + let byte = bytes[0]; + let event = match byte { + b'\r' | b'\n' => key_event(KeyCode::Enter, KeyModifiers::NONE), + 0x7f => key_event(KeyCode::Backspace, KeyModifiers::NONE), + 0x01..=0x1a => { + let character = char::from(b'a' + byte - 1); + key_event(KeyCode::Char(character), KeyModifiers::CONTROL) + } + 0x00 | 0x1c..=0x1f => Event::Ignored, + 0x20..=0x7e => { + let character = char::from(byte); + let modifiers = if character.is_ascii_uppercase() + || matches!( + character, + '!' | '@' + | '#' + | '$' + | '%' + | '^' + | '&' + | '*' + | '(' + | ')' + | '_' + | '+' + | '{' + | '}' + | '|' + | ':' + | '"' + | '<' + | '>' + | '?' + ) { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + key_event(KeyCode::Char(character), modifiers) + } + _ => { + let width = utf8_width(byte).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid UTF-8 in terminal input", + ) + })?; + if bytes.len() < width { + return Ok(Parsed::Incomplete); + } + let text = std::str::from_utf8(&bytes[..width]).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid UTF-8 in terminal input", + ) + })?; + let character = text + .chars() + .next() + .expect("a complete UTF-8 scalar contains one character"); + return Ok(Parsed::Complete( + if character.is_control() { + Event::Ignored + } else { + key_event(KeyCode::Char(character), KeyModifiers::NONE) + }, + width, + )); + } + }; + Ok(Parsed::Complete(event, 1)) +} + +fn parse_escape(bytes: &[u8], escape_expired: bool) -> io::Result { + if bytes.len() == 1 { + return if escape_expired { + Ok(Parsed::Complete( + key_event(KeyCode::Esc, KeyModifiers::NONE), + 1, + )) + } else { + Ok(Parsed::Incomplete) + }; + } + + if bytes[1] == 0x1b { + return Ok(Parsed::Complete( + key_event(KeyCode::Esc, KeyModifiers::NONE), + 1, + )); + } + + match bytes[1] { + b'[' => parse_csi(bytes), + b'O' => parse_ss3(bytes), + b']' => parse_control_string(bytes, true), + b'P' | b'_' | b'^' => parse_control_string(bytes, false), + _ => match parse_plain_key(&bytes[1..])? { + Parsed::Complete(Event::Key(mut key), used) => { + key.modifiers = key.modifiers.union(KeyModifiers::ALT); + Ok(Parsed::Complete(Event::Key(key), used + 1)) + } + Parsed::Complete(_, used) => Ok(Parsed::Complete(Event::Ignored, used + 1)), + Parsed::Incomplete => Ok(Parsed::Incomplete), + }, + } +} + +fn parse_ss3(bytes: &[u8]) -> io::Result { + if bytes.len() < 3 { + return Ok(Parsed::Incomplete); + } + let code = match bytes[2] { + b'A' => Some(KeyCode::Up), + b'B' => Some(KeyCode::Down), + b'H' => Some(KeyCode::Home), + b'F' => Some(KeyCode::End), + b'P' => Some(KeyCode::F(1)), + _ => None, + }; + Ok(Parsed::Complete( + code.map_or(Event::Ignored, |code| key_event(code, KeyModifiers::NONE)), + 3, + )) +} + +fn parse_control_string(bytes: &[u8], bell_terminated: bool) -> io::Result { + let tail = &bytes[2..]; + let bell = bell_terminated.then(|| tail.iter().position(|byte| *byte == 0x07)); + let string_terminator = tail.windows(2).position(|window| window == b"\x1b\\"); + let used = match (bell.flatten(), string_terminator) { + (Some(bell), Some(st)) => (bell + 1).min(st + 2), + (Some(bell), None) => bell + 1, + (None, Some(st)) => st + 2, + (None, None) => return Ok(Parsed::Incomplete), + }; + Ok(Parsed::Complete(Event::Ignored, 2 + used)) +} + +fn parse_csi(bytes: &[u8]) -> io::Result { + if bytes.len() < 3 { + return Ok(Parsed::Incomplete); + } + if bytes.starts_with(b"\x1b[M") { + return if bytes.len() < 6 { + Ok(Parsed::Incomplete) + } else { + Ok(Parsed::Complete(Event::Ignored, 6)) + }; + } + // The Linux virtual console uses ESC [[ A through ESC [[ E for F1 through F5. Handle that + // legacy form before the general CSI final-byte scan treats the second '[' as the terminator. + if bytes.starts_with(b"\x1b[[") { + if bytes.len() < 4 { + return Ok(Parsed::Incomplete); + } + let event = if bytes[3] == b'A' { + key_event(KeyCode::F(1), KeyModifiers::NONE) + } else { + Event::Ignored + }; + return Ok(Parsed::Complete(event, 4)); + } + + let Some(final_index) = bytes[2..] + .iter() + .position(|byte| (0x40..=0x7e).contains(byte)) + .map(|index| index + 2) + else { + return Ok(Parsed::Incomplete); + }; + let sequence = &bytes[..=final_index]; + let final_byte = bytes[final_index]; + let body = &sequence[2..final_index]; + + if final_byte == b'~' && body == b"200" { + let Some(end) = bytes[final_index + 1..] + .windows(6) + .position(|window| window == b"\x1b[201~") + else { + return Ok(Parsed::Incomplete); + }; + return Ok(Parsed::Complete(Event::Ignored, final_index + 1 + end + 6)); + } + + let event = match final_byte { + b'A' => modified_csi_key(KeyCode::Up, body), + b'B' => modified_csi_key(KeyCode::Down, body), + b'H' => modified_csi_key(KeyCode::Home, body), + b'F' => modified_csi_key(KeyCode::End, body), + b'P' => modified_csi_key(KeyCode::F(1), body), + b'~' => parse_tilde_key(body), + b'u' => parse_csi_u(body), + _ => None, + } + .unwrap_or(Event::Ignored); + Ok(Parsed::Complete(event, final_index + 1)) +} + +fn modified_csi_key(code: KeyCode, body: &[u8]) -> Option { + let text = std::str::from_utf8(body).ok()?; + let (modifiers, kind, state) = match text.rsplit_once(';') { + Some((_, field)) => parse_modifier_field(field)?, + None => (KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE), + }; + Some(Event::Key(KeyEvent { + code, + modifiers, + kind, + state, + })) +} + +fn parse_tilde_key(body: &[u8]) -> Option { + let text = std::str::from_utf8(body).ok()?; + let mut fields = text.split(';'); + let code = match fields.next()?.parse::().ok()? { + 1 | 7 => KeyCode::Home, + 4 | 8 => KeyCode::End, + 5 => KeyCode::PageUp, + 6 => KeyCode::PageDown, + 11 => KeyCode::F(1), + _ => return None, + }; + let (modifiers, kind, state) = match fields.next() { + Some(field) => parse_modifier_field(field)?, + None => (KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE), + }; + Some(Event::Key(KeyEvent { + code, + modifiers, + kind, + state, + })) +} + +fn parse_csi_u(body: &[u8]) -> Option { + let text = std::str::from_utf8(body).ok()?; + let mut fields = text.split(';'); + let mut codepoints = fields.next()?.split(':'); + let primary = codepoints.next()?.parse::().ok()?; + let alternate = codepoints + .next() + .and_then(|codepoint| codepoint.parse::().ok()) + .and_then(char::from_u32); + let (mut modifiers, kind, state) = match fields.next() { + Some(field) => parse_modifier_field(field)?, + None => (KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NONE), + }; + + let code = match primary { + 57344 => KeyCode::Esc, + 57345 => KeyCode::Enter, + 57347 => KeyCode::Backspace, + 57352 => KeyCode::Up, + 57353 => KeyCode::Down, + 57354 => KeyCode::PageUp, + 57355 => KeyCode::PageDown, + 57356 => KeyCode::Home, + 57357 => KeyCode::End, + 57358 => KeyCode::CapsLock, + 57359 => KeyCode::ScrollLock, + 57360 => KeyCode::NumLock, + 57364 => KeyCode::F(1), + 13 => KeyCode::Enter, + 27 => KeyCode::Esc, + 127 => KeyCode::Backspace, + 57441..=57454 => { + let modifier = match primary { + 57441 | 57447 => KeyModifiers::SHIFT, + 57442 | 57448 => KeyModifiers::CONTROL, + 57443 | 57449 => KeyModifiers::ALT, + 57444 | 57450 => KeyModifiers::SUPER, + 57445 | 57451 => KeyModifiers::HYPER, + 57446 | 57452 => KeyModifiers::META, + _ => KeyModifiers::NONE, + }; + modifiers = modifiers.union(modifier); + KeyCode::Modifier + } + 0..=31 | 127..=159 | 57344..=57454 => return Some(Event::Ignored), + _ => { + let mut character = char::from_u32(primary)?; + if character.is_control() { + return Some(Event::Ignored); + } + if modifiers.contains(KeyModifiers::SHIFT) + && let Some(shifted) = alternate + { + character = shifted; + modifiers.set(KeyModifiers::SHIFT, false); + } + KeyCode::Char(character) + } + }; + Some(Event::Key(KeyEvent { + code, + modifiers, + kind, + state, + })) +} + +fn parse_modifier_field(field: &str) -> Option<(KeyModifiers, KeyEventKind, KeyEventState)> { + let mut parts = field.split(':'); + let encoded = parts.next()?.parse::().ok()?; + let mask = encoded.checked_sub(1)?; + let kind = match parts.next() { + None | Some("1") => KeyEventKind::Press, + Some("2") => KeyEventKind::Repeat, + Some("3") => KeyEventKind::Release, + Some(_) => return None, + }; + if parts.next().is_some() { + return None; + } + let mut bits = 0; + for (kitty, modifier) in [ + (1, KeyModifiers::SHIFT), + (2, KeyModifiers::ALT), + (4, KeyModifiers::CONTROL), + (8, KeyModifiers::SUPER), + (16, KeyModifiers::HYPER), + (32, KeyModifiers::META), + ] { + if mask & kitty != 0 { + bits |= modifier.bits(); + } + } + let state = if mask & 64 != 0 { + KeyEventState::CAPS_LOCK + } else { + KeyEventState::NONE + }; + Some((KeyModifiers::from_bits_truncate(bits), kind, state)) +} + +fn key_event(code: KeyCode, modifiers: KeyModifiers) -> Event { + Event::Key(KeyEvent { + code, + modifiers, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }) +} + +const fn utf8_width(first: u8) -> Option { + match first { + 0xc2..=0xdf => Some(2), + 0xe0..=0xef => Some(3), + 0xf0..=0xf4 => Some(4), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use rustix::{ + fs::{self, Mode, OFlags}, + pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}, + termios::{self, Winsize}, + }; + + use super::*; + + fn test_terminal_input() -> (File, TerminalInput) { + let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY).unwrap(); + grantpt(&master).unwrap(); + unlockpt(&master).unwrap(); + let name = ptsname(&master, Vec::new()).unwrap(); + let slave = fs::open( + name.as_c_str(), + OFlags::RDWR | OFlags::NOCTTY, + Mode::empty(), + ) + .unwrap(); + termios::tcsetwinsize( + &slave, + Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }, + ) + .unwrap(); + let input = File::from(slave); + let screen = input.try_clone().unwrap(); + ( + File::from(master), + TerminalInput { + input, + screen, + original_mode: None, + input_disconnected: false, + buffer: Vec::with_capacity(READ_CHUNK_BYTES), + start: 0, + ready: None, + incomplete_since: None, + last_size: (80, 24), + }, + ) + } + + fn parsed(bytes: &[u8]) -> Event { + match parse_one(bytes, true).unwrap() { + Parsed::Complete(event, used) => { + assert_eq!(used, bytes.len()); + event + } + Parsed::Incomplete => panic!("expected a complete event"), + } + } + + #[test] + fn parses_plain_control_and_navigation_keys() { + assert_eq!( + parsed(b"G"), + key_event(KeyCode::Char('G'), KeyModifiers::SHIFT) + ); + assert_eq!( + parsed(b"\x15"), + key_event(KeyCode::Char('u'), KeyModifiers::CONTROL) + ); + assert_eq!( + parsed(b"\x1b[A"), + key_event(KeyCode::Up, KeyModifiers::NONE) + ); + assert!(matches!( + parse_one(b"\x1b[[", false), + Ok(Parsed::Incomplete) + )); + assert_eq!( + parsed(b"\x1b[[A"), + key_event(KeyCode::F(1), KeyModifiers::NONE) + ); + assert_eq!(parsed(b"\x1b[[B"), Event::Ignored); + for control in [0x00, 0x1c, 0x1d, 0x1e, 0x1f] { + assert_eq!(parsed(&[control]), Event::Ignored); + } + } + + #[test] + fn escape_and_utf8_boundaries_are_incremental_and_nonfatal() { + assert!(matches!(parse_one(b"\x1b", false), Ok(Parsed::Incomplete))); + assert_eq!(parsed(b"\x1b"), key_event(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!(parse_one(&[0xc3], false), Ok(Parsed::Incomplete))); + assert_eq!( + parsed("é".as_bytes()), + key_event(KeyCode::Char('é'), KeyModifiers::NONE) + ); + assert_eq!( + parsed("\u{1b}é".as_bytes()), + key_event(KeyCode::Char('é'), KeyModifiers::ALT) + ); + assert!(parse_one(&[0xc3, b'x'], false).is_err()); + assert!(matches!( + parse_one(b"\x1b\x1b", false), + Ok(Parsed::Complete( + Event::Key(KeyEvent { + code: KeyCode::Esc, + .. + }), + 1 + )) + )); + } + + #[test] + fn parses_the_supported_kitty_keyboard_fields() { + assert_eq!( + parsed(b"\x1b[103:71;2:2u"), + Event::Key(KeyEvent { + code: KeyCode::Char('G'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Repeat, + state: KeyEventState::NONE, + }) + ); + assert_eq!( + parsed(b"\x1b[103;66u"), + Event::Key(KeyEvent { + code: KeyCode::Char('g'), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: KeyEventState::CAPS_LOCK, + }) + ); + assert_eq!( + parsed(b"\x1b[57352;1:2u"), + Event::Key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Repeat, + state: KeyEventState::NONE, + }) + ); + assert_eq!(parsed(b"\x1b[57361u"), Event::Ignored); + assert_eq!(parsed(b"\x1b[9u"), Event::Ignored); + assert_eq!(parsed(b"\x1b[113;:u"), Event::Ignored); + } + + #[test] + fn legacy_csi_navigation_preserves_event_metadata() { + assert_eq!( + parsed(b"\x1b[1;1:3A"), + Event::Key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }) + ); + assert_eq!( + parsed(b"\x1b[1;66:2P"), + Event::Key(KeyEvent { + code: KeyCode::F(1), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Repeat, + state: KeyEventState::CAPS_LOCK, + }) + ); + assert_eq!(parsed(b"\x1b[1;1:4A"), Event::Ignored); + } + + #[test] + fn zero_timeout_checks_ready_input_and_suspend_reset_discards_it() { + let (mut master, mut terminal) = test_terminal_input(); + terminal.enable_raw_mode().unwrap(); + + master.write_all(b"q").unwrap(); + assert!(terminal.poll(Duration::ZERO).unwrap()); + terminal.discard_buffered_events(); + assert_eq!( + terminal.read().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + + master.write_all(b"q").unwrap(); + assert!(terminal.poll(Duration::ZERO).unwrap()); + assert_eq!( + terminal.read().unwrap(), + key_event(KeyCode::Char('q'), KeyModifiers::NONE) + ); + terminal.disable_raw_mode().unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn raw_cleanup_accepts_only_a_confirmed_pty_disconnect() { + let (master, mut unconfirmed) = test_terminal_input(); + unconfirmed.enable_raw_mode().unwrap(); + drop(master); + assert_eq!( + unconfirmed.disable_raw_mode().unwrap_err().raw_os_error(), + Some(libc::EIO) + ); + + let (master, mut confirmed) = test_terminal_input(); + confirmed.enable_raw_mode().unwrap(); + drop(master); + let read_error = confirmed.read_available().unwrap_err(); + assert_eq!(read_error.kind(), io::ErrorKind::UnexpectedEof); + assert_eq!(read_error.to_string(), "terminal input closed"); + confirmed.disable_raw_mode().unwrap(); + assert!(confirmed.original_mode.is_none()); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn poll_timeout_rounds_partial_milliseconds_up() { + assert_eq!(poll_milliseconds(Duration::ZERO), 0); + assert_eq!(poll_milliseconds(Duration::from_nanos(1)), 1); + assert_eq!(poll_milliseconds(Duration::from_millis(1)), 1); + assert_eq!(poll_milliseconds(Duration::from_micros(1_001)), 2); + assert_eq!(poll_milliseconds(Duration::MAX), -1); + } + + #[test] + fn consumes_mouse_reports_and_complete_private_replies() { + assert_eq!(parsed(b"\x1b[M\x20\x20\x20"), Event::Ignored); + assert_eq!(parsed(b"\x1b[<0;0;0M"), Event::Ignored); + assert_eq!(parsed(b"\x1b[?997;1n"), Event::Ignored); + } + + #[test] + fn bracketed_paste_is_one_bounded_ignored_sequence() { + assert!(matches!( + parse_one(b"\x1b[200~draft", false), + Ok(Parsed::Incomplete) + )); + assert_eq!(parsed(b"\x1b[200~draft\x1b[201~"), Event::Ignored); + } +} diff --git a/src/tui/view.rs b/src/tui/view.rs index ad239f4..6988e22 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -10,10 +10,11 @@ use unicode_segmentation::UnicodeSegmentation; use crate::{ app::{ - Highlight, MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS, MatchCursor, PendingState, + BookState, Highlight, MIN_TERMINAL_COLUMNS, MIN_TERMINAL_ROWS, MatchCursor, PendingState, RenderProjectionKind, RenderRow, RenderRowsView, RenderSpan, RenderState, RepeatStatus, SearchStatus, ViewActivity, ViewState, ViewportBoundary, }, + document::BookHeading, error::{TutError, sanitize_text}, layout::{ContentWidth, DisplayAtoms, DisplayColumn}, tui::FrameSymbolMeter, @@ -64,6 +65,42 @@ const COMMITTED_SEARCH_FOOTER: &[CopyTier] = &[ text: "Esc clear q quit", }, ]; +const BOOK_READER_FOOTER: &[CopyTier] = &[ + CopyTier { + min_columns: 80, + text: "q quit F1 help / search [/] sections j/k lines Space/b pages n/N matches", + }, + CopyTier { + min_columns: 40, + text: "q quit F1 help [/] sections / search", + }, + CopyTier { + min_columns: 20, + text: "q quit [/] F1 help", + }, + CopyTier { + min_columns: 16, + text: "q quit [/] F1", + }, +]; +const BOOK_COMMITTED_SEARCH_FOOTER: &[CopyTier] = &[ + CopyTier { + min_columns: 80, + text: "Esc clear n/N matches [/] sections q quit F1 help / new search", + }, + CopyTier { + min_columns: 40, + text: "Esc clear n/N [/] sections q quit F1", + }, + CopyTier { + min_columns: 20, + text: "Esc [/] n/N q", + }, + CopyTier { + min_columns: 16, + text: "Esc [/] n/N q", + }, +]; const SEARCH_INPUT_FOOTER: &[CopyTier] = &[ CopyTier { min_columns: 80, @@ -178,6 +215,9 @@ const FULL_HELP: &[&str] = &[ " q quit from reader mode", " Ctrl-C interrupt", ]; +const BOOK_COMPACT_HELP: &str = "[/] sections previous / next"; +const BOOK_FULL_HELP: &str = " [/] previous / next section"; +const BOOK_FULL_HELP_INDEX: usize = 10; pub(super) fn render( frame: &mut Frame<'_>, @@ -198,7 +238,7 @@ pub(super) fn render( match state { ViewState::Reader(state) => render_reader(frame, state, symbols), ViewState::Pending(state) => render_pending(frame, state, symbols), - ViewState::Help { q_closes } => render_help(frame, *q_closes, symbols), + ViewState::Help { q_closes, book } => render_help(frame, *q_closes, *book, symbols), } } @@ -208,17 +248,28 @@ fn render_reader( symbols: &mut FrameSymbolMeter, ) -> Result<(), TutError> { let area = frame.area(); - let header_text = header_text(state.filename, state.path, area.width)?; - let status_text = status_text(state, area.width)?; - let help_text = footer_for(state.status, area.width); + let content = centered_column(area, state.body_width); + let header_text = view_header_text(state.filename, state.path, state.book, content.width)?; + let status_text = status_text(state, content.width)?; + let help_text = view_footer_for( + state.status, + state.book.is_some_and(|book| book.section_total != 0), + area.width, + ); let body_height = area.height - 3; - let header = Rect::new(area.x, area.y, area.width, 1); - let body = Rect::new(area.x, area.y + 1, area.width, body_height); - let status = Rect::new(area.x, area.y + 1 + body_height, area.width, 1); + let header = Rect::new(content.x, area.y, content.width, 1); + let body = Rect::new(content.x, area.y + 1, content.width, body_height); + let status = Rect::new(content.x, area.y + 1 + body_height, content.width, 1); let help = Rect::new(area.x, area.y + 2 + body_height, area.width, 1); render_projected_line(frame.buffer_mut(), header, &header_text, symbols)?; - render_body(frame.buffer_mut(), body, state.rows, symbols)?; + render_body( + frame.buffer_mut(), + body, + state.rows, + state.book.map_or(&[], |book| book.heading_ranges), + symbols, + )?; render_projected_line(frame.buffer_mut(), status, &status_text, symbols)?; render_projected_line(frame.buffer_mut(), help, help_text, symbols)?; Ok(()) @@ -230,13 +281,18 @@ fn render_pending( symbols: &mut FrameSymbolMeter, ) -> Result<(), TutError> { let area = frame.area(); - let header_text = header_text(state.filename, state.path, area.width)?; - let status_text = pending_status_text(state.status, state.repeat, area.width)?; - let footer_text = footer_for(state.status, area.width); + let content = centered_column(area, state.body_width); + let header_text = view_header_text(state.filename, state.path, state.book, content.width)?; + let status_text = pending_status_text(state.status, state.repeat, content.width)?; + let footer_text = view_footer_for( + state.status, + state.book.is_some_and(|book| book.section_total != 0), + area.width, + ); let body_height = area.height - 3; - let header = Rect::new(area.x, area.y, area.width, 1); - let body = Rect::new(area.x, area.y + 1, area.width, body_height); - let status = Rect::new(area.x, area.y + 1 + body_height, area.width, 1); + let header = Rect::new(content.x, area.y, content.width, 1); + let body = Rect::new(content.x, area.y + 1, content.width, body_height); + let status = Rect::new(content.x, area.y + 1 + body_height, content.width, 1); let footer = Rect::new(area.x, area.y + 2 + body_height, area.width, 1); render_projected_line(frame.buffer_mut(), header, &header_text, symbols)?; @@ -245,6 +301,16 @@ fn render_pending( render_projected_line(frame.buffer_mut(), footer, footer_text, symbols) } +fn centered_column(area: Rect, width: u16) -> Rect { + let width = width.min(area.width); + Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y, + width, + area.height, + ) +} + fn pick_copy(columns: u16, tiers: &[CopyTier]) -> &'static str { tiers .iter() @@ -260,23 +326,38 @@ fn footer_for(status: SearchStatus<'_>, columns: u16) -> &'static str { } } +fn view_footer_for(status: SearchStatus<'_>, sections: bool, columns: u16) -> &'static str { + if !sections || matches!(status, SearchStatus::Draft { .. }) { + return footer_for(status, columns); + } + match status { + SearchStatus::None => pick_copy(columns, BOOK_READER_FOOTER), + SearchStatus::Committed { .. } => pick_copy(columns, BOOK_COMMITTED_SEARCH_FOOTER), + SearchStatus::Draft { .. } => unreachable!("draft footer returned above"), + } +} + fn render_help( frame: &mut Frame<'_>, q_closes: bool, + book: bool, symbols: &mut FrameSymbolMeter, ) -> Result<(), TutError> { let area = frame.area(); let title = Rect::new(area.x, area.y, area.width, 1); let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1); let body_height = area.height - 2; - let lines = if usize::from(body_height) >= FULL_HELP.len() { - FULL_HELP + let full = usize::from(body_height) >= FULL_HELP.len() + usize::from(book); + let line_count = if full { + FULL_HELP.len() + usize::from(book) } else { - COMPACT_HELP + COMPACT_HELP.len() + usize::from(book) }; render_projected_line(frame.buffer_mut(), title, HELP_TITLE, symbols)?; - for (relative_y, line) in lines.iter().take(usize::from(body_height)).enumerate() { + for relative_y in 0..line_count.min(usize::from(body_height)) { + let line = help_line(full, book, relative_y) + .expect("help line indexes remain within the selected copy"); let y = area.y + 1 + u16::try_from(relative_y).expect("help rows fit the terminal height"); render_projected_line( frame.buffer_mut(), @@ -296,30 +377,92 @@ fn render_help( render_projected_line(frame.buffer_mut(), footer, footer_text, symbols) } +fn help_line(full: bool, book: bool, index: usize) -> Option<&'static str> { + if !full { + if book && index == 0 { + return Some(BOOK_COMPACT_HELP); + } + return COMPACT_HELP + .get(index.saturating_sub(usize::from(book))) + .copied(); + } + if book && index == BOOK_FULL_HELP_INDEX { + return Some(BOOK_FULL_HELP); + } + let index = index.saturating_sub(usize::from(book && index > BOOK_FULL_HELP_INDEX)); + FULL_HELP.get(index).copied() +} + fn render_body( buffer: &mut Buffer, area: Rect, rows: RenderRowsView<'_>, + heading_ranges: &[BookHeading], symbols: &mut FrameSymbolMeter, ) -> Result<(), TutError> { let mut highlights = rows.highlight_cursor(); + let mut headings = HeadingCursor::new(heading_ranges); for (relative_y, row) in rows.iter().take(usize::from(area.height)).enumerate() { let y = area.y + u16::try_from(relative_y).expect("visible row count fits terminal height"); - write_render_row(buffer, area, y, row, &mut highlights, symbols)?; + let heading_level = headings.level_for(row); + write_render_row( + buffer, + area, + y, + row, + heading_level, + &mut highlights, + symbols, + )?; } Ok(()) } +struct HeadingCursor<'a> { + ranges: &'a [BookHeading], + index: usize, +} + +impl<'a> HeadingCursor<'a> { + const fn new(ranges: &'a [BookHeading]) -> Self { + Self { ranges, index: 0 } + } + + fn level_for(&mut self, row: RenderRow<'_>) -> Option { + let row_start = row.spans.first()?.source().start(); + let row_end = row.spans.last()?.source().end(); + while self + .ranges + .get(self.index) + .is_some_and(|range| range.end() <= row_start) + { + self.index += 1; + } + let range = self.ranges.get(self.index)?; + (range.start() < row_end && row_start < range.end()).then_some(range.level()) + } +} + fn write_render_row( buffer: &mut Buffer, area: Rect, y: u16, row: RenderRow<'_>, + heading_level: Option, highlights: &mut MatchCursor<'_>, symbols: &mut FrameSymbolMeter, ) -> Result<(), TutError> { + let x = if matches!(heading_level, Some(1) | Some(2)) { + let row_width = row.spans.iter().fold(0_u32, |width, span| { + width.saturating_add(span.cell_width.get()) + }); + let row_width = u16::try_from(row_width).unwrap_or(u16::MAX); + area.x + area.width.saturating_sub(row_width) / 2 + } else { + area.x + }; let mut cursor = RowCursor { - x: area.x, + x, y, right: area.right(), }; @@ -339,6 +482,7 @@ fn write_render_row( row, ¤t, current_highlight, + heading_level, symbols, )? { @@ -347,7 +491,15 @@ fn write_render_row( pending = Some((span.clone(), highlight)); } if let Some((span, highlight)) = pending { - write_render_run(buffer, &mut cursor, row, &span, highlight, symbols)?; + write_render_run( + buffer, + &mut cursor, + row, + &span, + highlight, + heading_level, + symbols, + )?; } Ok(()) } @@ -364,6 +516,7 @@ fn write_render_run( row: RenderRow<'_>, span: &RenderSpan, highlight: Highlight, + heading_level: Option, symbols: &mut FrameSymbolMeter, ) -> Result { let width = @@ -377,7 +530,7 @@ fn write_render_run( cursor.y, row.span_text(span), span, - highlight, + style_for(highlight, heading_level), symbols, )?; Ok(true) @@ -389,12 +542,11 @@ fn write_render_span( y: u16, text: &str, span: &RenderSpan, - highlight: Highlight, + style: Style, symbols: &mut FrameSymbolMeter, ) -> Result { let width = u16::try_from(span.cell_width.get()).expect("projected width fits the terminal width"); - let style = style_for(highlight); let one = NonZeroU16::new(1).expect("one is nonzero"); if span.projection == RenderProjectionKind::Spaces { @@ -430,12 +582,17 @@ fn write_render_span( Ok(width) } -fn style_for(highlight: Highlight) -> Style { - match highlight { +fn style_for(highlight: Highlight, heading_level: Option) -> Style { + let style = match highlight { Highlight::None => Style::default(), Highlight::Match => Style::default().add_modifier(Modifier::REVERSED), Highlight::Current => Style::default() .add_modifier(Modifier::REVERSED | Modifier::BOLD | Modifier::UNDERLINED), + }; + match heading_level { + Some(1) => style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + Some(2..=6) => style.add_modifier(Modifier::BOLD), + _ => style, } } @@ -471,7 +628,7 @@ fn render_projected_line( area.y, span.standalone_text(&row), &span, - Highlight::None, + Style::default(), symbols, )?; column = DisplayColumn::new(column.get() + u32::from(width)); @@ -525,10 +682,80 @@ fn header_text(filename: &str, path: &str, width: u16) -> Result>, + width: u16, +) -> Result { + match book { + Some(book) => book_header_text(book, filename, width), + None => header_text(filename, path, width), + } +} + +fn book_header_text( + book: BookState<'_>, + fallback_name: &str, + width: u16, +) -> Result { + let title = book + .title + .map(sanitize_text) + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| sanitize_text(fallback_name)); + let Some(creator) = book + .creator + .map(sanitize_text) + .filter(|creator| !creator.is_empty()) + else { + return ellipsize_end(&title, width); + }; + const SEPARATOR: &str = " — "; + let separator_width = display_width(SEPARATOR); + if display_width(&title) + .saturating_add(separator_width) + .saturating_add(display_width(&creator)) + <= width + { + let mut output = String::new(); + output + .try_reserve_exact(title.len() + SEPARATOR.len() + creator.len()) + .map_err(|_| TutError::Allocation("book header text"))?; + output.push_str(&title); + output.push_str(SEPARATOR); + output.push_str(&creator); + return Ok(output); + } + if width <= separator_width.saturating_add(2) { + return ellipsize_end(&title, width); + } + + let title_budget = (width.saturating_mul(2) / 3) + .max(1) + .min(width - separator_width - 1); + let shown_title = ellipsize_end(&title, title_budget)?; + let creator_budget = width + .saturating_sub(display_width(&shown_title)) + .saturating_sub(separator_width); + let shown_creator = ellipsize_end(&creator, creator_budget)?; + let mut output = String::new(); + output + .try_reserve_exact(shown_title.len() + SEPARATOR.len() + shown_creator.len()) + .map_err(|_| TutError::Allocation("book header text"))?; + output.push_str(&shown_title); + output.push_str(SEPARATOR); + output.push_str(&shown_creator); + Ok(output) +} + fn status_text(state: &RenderState<'_>, width: u16) -> Result { if let Some(repeat) = state.repeat { return repeat_status_text(repeat); } + if let Some(book) = state.book { + return book_status_text(state, book, width); + } let mut prefix = String::new(); prefix .try_reserve_exact(50) @@ -550,6 +777,46 @@ fn status_text(state: &RenderState<'_>, width: u16) -> Result compose_status(prefix, state.status, state.activity, width) } +fn book_status_text( + state: &RenderState<'_>, + book: BookState<'_>, + width: u16, +) -> Result { + let mut prefix = String::new(); + prefix + .try_reserve_exact(64) + .map_err(|_| TutError::Allocation("book status text"))?; + write!(prefix, "{}%", state.progress.min(100)) + .expect("reserved String formatting is infallible"); + if book.section_total != 0 { + match book.section_ordinal { + Some(ordinal) => write!(prefix, " section {ordinal}/{}", book.section_total), + None => write!(prefix, " front matter"), + } + .expect("reserved String formatting is infallible"); + } + + if book.section_total != 0 + && let Some(title) = book.section_title + { + let title = sanitize_text(title); + let search_reserve = match state.status { + SearchStatus::None => 0, + SearchStatus::Committed { .. } | SearchStatus::Draft { .. } => width / 3, + }; + let title_budget = width + .saturating_sub(display_width(&prefix)) + .saturating_sub(2) + .saturating_sub(search_reserve); + if title_budget != 0 && !title.is_empty() { + let shown = ellipsize_end(&title, title_budget)?; + prefix.push_str(" "); + prefix.push_str(&shown); + } + } + compose_status(prefix, state.status, state.activity, width) +} + fn pending_status_text( status: SearchStatus<'_>, repeat: Option, @@ -786,7 +1053,11 @@ mod tests { use ratatui::{Terminal, backend::TestBackend}; use super::*; - use crate::app::{Action, Geometry, app_from_text}; + use crate::{ + app::{Action, Geometry, RenderRows, app_from_text}, + document::{BookSection, BookStructure, Document}, + source::SourceOffset, + }; fn prepare_frame(app: &mut crate::app::App) { for _ in 0..1024 { @@ -818,6 +1089,15 @@ mod tests { terminal.backend().buffer().clone() } + fn draw_state(state: &ViewState<'_>, width: u16, height: u16) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut symbols = FrameSymbolMeter::new(u64::MAX); + terminal + .draw(|frame| render(frame, state, &mut symbols).unwrap()) + .unwrap(); + terminal.backend().buffer().clone() + } + fn draw_available(app: &mut crate::app::App, width: u16, height: u16) -> Buffer { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); @@ -831,7 +1111,7 @@ mod tests { prepare_frame(app); let state = app.render_state().unwrap(); let mut symbols = FrameSymbolMeter::new(u64::MAX); - render_body(&mut buffer, area, state.rows, &mut symbols).unwrap(); + render_body(&mut buffer, area, state.rows, &[], &mut symbols).unwrap(); buffer } @@ -884,6 +1164,8 @@ mod tests { for tiers in [ READER_FOOTER, COMMITTED_SEARCH_FOOTER, + BOOK_READER_FOOTER, + BOOK_COMMITTED_SEARCH_FOOTER, SEARCH_INPUT_FOOTER, READER_HELP_FOOTER, SEARCH_HELP_FOOTER, @@ -937,6 +1219,19 @@ mod tests { assert_eq!(pick_copy(6, TINY_COPY), "resize"); assert_eq!(pick_copy(12, TINY_COPY), "resize q"); assert_eq!(pick_copy(16, TINY_COPY), "resize q quit"); + + assert_eq!( + view_footer_for(SearchStatus::None, true, 80), + "q quit F1 help / search [/] sections j/k lines Space/b pages n/N matches" + ); + assert_eq!( + view_footer_for(committed, true, 40), + "Esc clear n/N [/] sections q quit F1" + ); + assert_eq!( + view_footer_for(draft, true, 40), + "Esc cancel Enter Up recall Ctrl-U clear" + ); } #[test] @@ -951,7 +1246,7 @@ mod tests { let area = Rect::new(0, 0, 16, 1); let mut body = Buffer::empty(area); let mut body_symbols = FrameSymbolMeter::new(u64::MAX); - render_body(&mut body, area, state.rows, &mut body_symbols).unwrap(); + render_body(&mut body, area, state.rows, &[], &mut body_symbols).unwrap(); assert_eq!(body.cell((0, 0)).unwrap().symbol(), heap_grapheme); assert_eq!(body_symbols.used(), 32); assert_eq!(body_symbols.used(), actual_symbol_heap_bytes(&body)); @@ -1034,6 +1329,47 @@ mod tests { ); } + #[test] + fn book_chrome_and_body_share_the_centered_reading_column() { + let rows = RenderRows::from_prebuilt_runs("paragraph", 1, 9).unwrap(); + let book = BookState { + title: Some("The Book"), + creator: Some("Ada Author"), + section_title: Some("Second Chapter"), + section_ordinal: Some(2), + section_total: 12, + heading_ranges: &[], + }; + let state = ViewState::Reader(RenderState { + filename: "opaque-download-name.epub", + path: "/tmp/opaque-download-name.epub", + book: Some(book), + body_width: 80, + rows: RenderRowsView::from_rows(&rows), + progress: 37, + current_line: Some(91), + total_lines: Some(2_178), + status: SearchStatus::None, + activity: None, + boundary: Some(ViewportBoundary::Top), + repeat: None, + }); + + let buffer = draw_state(&state, 100, 4); + assert_eq!(row_text(&buffer, 0), " The Book — Ada Author"); + assert_eq!(row_text(&buffer, 1), " paragraph"); + assert_eq!( + row_text(&buffer, 2), + " 37% section 2/12 Second Chapter" + ); + assert!(!row_text(&buffer, 0).contains("opaque-download-name")); + assert!(!row_text(&buffer, 2).contains("91/2178")); + assert_eq!( + row_text(&buffer, 3), + "q quit F1 help / search [/] sections j/k lines Space/b pages n/N matches" + ); + } + #[test] fn repeat_status_preempts_reader_chrome_and_clears_shorter_updates() { let backend = TestBackend::new(16, 4); @@ -1266,6 +1602,34 @@ mod tests { assert_eq!(row_text(&compact, 3), "Esc/q/F1 close"); } + #[test] + fn book_help_adds_section_navigation_without_changing_plain_help() { + let full = draw_state( + &ViewState::Help { + q_closes: true, + book: true, + }, + 80, + 24, + ); + assert_eq!( + row_text(&full, 11), + " [/] previous / next section" + ); + assert_eq!(row_text(&full, 12), "Search"); + + let compact = draw_state( + &ViewState::Help { + q_closes: true, + book: true, + }, + 16, + 4, + ); + assert!(row_text(&compact, 1).starts_with("[/] sections")); + assert!(row_text(&compact, 2).starts_with("1-9 + j/k")); + } + #[test] fn dismissing_help_restores_reader_content_and_clears_overlay_rows() { let backend = TestBackend::new(40, 8); @@ -1314,6 +1678,92 @@ mod tests { assert!(current.modifier.contains(Modifier::BOLD)); } + #[test] + fn toc_targets_and_heading_ranges_remain_independent_during_styled_rendering() { + let section_target = SourceOffset::from_usize(10); + let headings = vec![ + BookHeading::new(1, SourceOffset::from_usize(0), SourceOffset::from_usize(4)).unwrap(), + BookHeading::new(2, SourceOffset::from_usize(5), SourceOffset::from_usize(9)).unwrap(), + BookHeading::new( + 3, + SourceOffset::from_usize(16), + SourceOffset::from_usize(20), + ) + .unwrap(), + ]; + let book = BookStructure::new( + Some("The Book".to_owned()), + None, + vec![BookSection::new("Chapter".to_owned(), 1, section_target).unwrap()], + headings, + ); + let document = Document::from_book_text( + Path::new("/tmp/book.epub"), + "head\nhead\nplain\nbody".to_owned(), + book, + ); + let mut app = crate::app::App::new(document); + app.update(Action::Resize(Geometry::new(20, 7))).unwrap(); + prepare_frame(&mut app); + app.update(Action::BeginSearch).unwrap(); + for character in "head".chars() { + app.update(Action::SearchInsert(character)).unwrap(); + } + app.update(Action::SearchCommit).unwrap(); + for _ in 0..2 { + let _ = app.render_state().unwrap(); + for _ in 0..32 { + if !app.has_background_work() { + break; + } + app.advance_background().unwrap(); + } + assert!(!app.has_background_work()); + } + + let state = app.render_state().unwrap(); + let book = state.book.expect("book metadata remains attached"); + assert_eq!(book.section_ordinal, None); + assert_eq!(book.heading_ranges[0].start(), SourceOffset::from_usize(0)); + let area = Rect::new(0, 0, 20, 4); + let mut buffer = Buffer::empty(area); + let mut symbols = FrameSymbolMeter::new(u64::MAX); + render_body( + &mut buffer, + area, + state.rows, + book.heading_ranges, + &mut symbols, + ) + .unwrap(); + + let primary = buffer.cell((8, 0)).unwrap(); + assert_eq!(primary.symbol(), "head"); + assert!(primary.modifier.contains(Modifier::REVERSED)); + assert!(primary.modifier.contains(Modifier::BOLD)); + assert!(primary.modifier.contains(Modifier::UNDERLINED)); + + let secondary = buffer.cell((8, 1)).unwrap(); + assert_eq!(secondary.symbol(), "head"); + assert!(secondary.modifier.contains(Modifier::REVERSED)); + assert!(secondary.modifier.contains(Modifier::BOLD)); + assert!(!secondary.modifier.contains(Modifier::UNDERLINED)); + + let toc_target = buffer.cell((0, 2)).unwrap(); + assert_eq!(toc_target.symbol(), "plain"); + assert_eq!(toc_target.modifier, Modifier::empty()); + + let tertiary = buffer.cell((0, 3)).unwrap(); + assert_eq!(tertiary.symbol(), "body"); + assert!(tertiary.modifier.contains(Modifier::BOLD)); + assert!(!tertiary.modifier.contains(Modifier::UNDERLINED)); + for level in 3..=6 { + let style = style_for(Highlight::None, Some(level)); + assert!(style.add_modifier.contains(Modifier::BOLD)); + assert!(!style.add_modifier.contains(Modifier::UNDERLINED)); + } + } + #[test] fn compacted_spans_clear_cells_when_the_next_frame_is_shorter() { let mut long = app_from_text(Path::new("/tmp/book.txt"), "abcdef".to_owned()); @@ -1379,6 +1829,8 @@ mod tests { let state = RenderState { filename: "book.txt", path: "/tmp/book.txt", + book: None, + body_width: 18, rows: RenderRowsView::empty(), progress: 12, current_line: Some(7), @@ -1400,6 +1852,8 @@ mod tests { let state = RenderState { filename: "book.txt", path: "/tmp/book.txt", + book: None, + body_width: 48, rows: RenderRowsView::empty(), progress: 12, current_line: Some(7), @@ -1431,6 +1885,8 @@ mod tests { let state = RenderState { filename: "book.txt", path: "/tmp/book.txt", + book: None, + body_width: 18, rows: RenderRowsView::empty(), progress: 12, current_line: Some(7), @@ -1453,6 +1909,8 @@ mod tests { let mut state = RenderState { filename: "book.txt", path: "/tmp/book.txt", + book: None, + body_width: 40, rows: RenderRowsView::empty(), progress: 12, current_line: Some(7), @@ -1479,6 +1937,8 @@ mod tests { let mut state = RenderState { filename: "book.txt", path: "/tmp/book.txt", + book: None, + body_width: 40, rows: RenderRowsView::empty(), progress: 12, current_line: Some(7), @@ -1526,6 +1986,8 @@ mod tests { let state = RenderState { filename: "book.txt", path: "/tmp/book.txt", + book: None, + body_width: 40, rows: RenderRowsView::empty(), progress: 12, current_line: Some(7), @@ -1549,6 +2011,8 @@ mod tests { let state = RenderState { filename: "standard input", path: "standard input", + book: None, + body_width: 40, rows: RenderRowsView::empty(), progress: 100, current_line: Some(1), @@ -1564,6 +2028,78 @@ mod tests { ); } + #[test] + fn book_header_falls_back_to_the_sanitized_filename_without_a_title() { + let book = BookState { + title: None, + creator: Some("Ada Author"), + section_title: None, + section_ordinal: None, + section_total: 0, + heading_ranges: &[], + }; + assert_eq!( + view_header_text("unsafe\u{001b}.epub", "/tmp/unsafe.epub", Some(book), 40,).unwrap(), + "unsafe\\x1b.epub — Ada Author" + ); + } + + #[test] + fn books_without_sections_show_progress_without_section_commands() { + let state = RenderState { + filename: "book.epub", + path: "/tmp/book.epub", + book: Some(BookState { + title: Some("The Book"), + creator: None, + section_title: None, + section_ordinal: None, + section_total: 0, + heading_ranges: &[], + }), + body_width: 40, + rows: RenderRowsView::empty(), + progress: 55, + current_line: None, + total_lines: None, + status: SearchStatus::None, + activity: None, + boundary: Some(ViewportBoundary::Top), + repeat: None, + }; + assert_eq!(status_text(&state, 40).unwrap(), "55%"); + assert_eq!( + view_footer_for(SearchStatus::None, false, 40), + "q quit F1 help / search j/k Space/b" + ); + } + + #[test] + fn book_status_names_content_before_the_first_section_as_front_matter() { + let state = RenderState { + filename: "book.epub", + path: "/tmp/book.epub", + book: Some(BookState { + title: Some("The Book"), + creator: None, + section_title: None, + section_ordinal: None, + section_total: 26, + heading_ranges: &[], + }), + body_width: 40, + rows: RenderRowsView::empty(), + progress: 4, + current_line: None, + total_lines: None, + status: SearchStatus::None, + activity: None, + boundary: Some(ViewportBoundary::Top), + repeat: None, + }; + assert_eq!(status_text(&state, 40).unwrap(), "4% front matter"); + } + #[test] fn tiny_frames_prioritize_recovery_and_exit_hints() { let mut app = app_from_text(Path::new("/tmp/book.txt"), "body".to_owned()); diff --git a/tests/cli.rs b/tests/cli.rs index b3176ef..f0f22cb 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -149,7 +149,6 @@ mod pty { time::{Duration, Instant}, }; - use crossterm::terminal::{disable_raw_mode, enable_raw_mode, is_raw_mode_enabled}; use rustix::{ fs::{self, Mode, OFlags}, process::{ @@ -158,7 +157,8 @@ mod pty { pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}, termios::{self, SpecialCodeIndex, Termios, Winsize}, }; - use tempfile::{NamedTempFile, tempdir}; + use tempfile::{Builder, NamedTempFile, tempdir}; + use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions}; use super::tut; @@ -172,6 +172,43 @@ mod pty { const FIRST_RUN_EOF: &[u8] = b"TUT_TEST_FIRST_RUN_EOF"; const TIMEOUT: Duration = Duration::from_secs(5); + fn epub_fixture() -> NamedTempFile { + let file = Builder::new().suffix(".epub").tempfile().unwrap(); + let mut writer = ZipWriter::new(file.reopen().unwrap()); + let stored = SimpleFileOptions::default().compression_method(CompressionMethod::Stored); + let deflated = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + writer.start_file("mimetype", stored).unwrap(); + writer.write_all(b"application/epub+zip").unwrap(); + writer + .start_file("META-INF/container.xml", deflated) + .unwrap(); + writer + .write_all( + br#""#, + ) + .unwrap(); + writer.start_file("content.opf", deflated).unwrap(); + writer + .write_all( + br#"PTY EPUBPTY AUTHOR"#, + ) + .unwrap(); + writer.start_file("linear.xhtml", deflated).unwrap(); + writer + .write_all( + br#"

EPUB_FRONT_SENTINEL

[Image: [Image unavailable.]]

EPUB_LINEAR_SENTINEL

terminal book text

PLATE_ALT_SENTINEL

EPUB_SECOND_SECTION_SENTINEL

second section body

"#, + ) + .unwrap(); + writer.start_file("aux.xhtml", deflated).unwrap(); + writer + .write_all( + br#"

EPUB_NONLINEAR_SENTINEL

"#, + ) + .unwrap(); + writer.finish().unwrap(); + file + } + fn reset_signal_state() -> io::Result<()> { // SAFETY: sigaction is fully initialized before each synchronous libc call. let mut action = unsafe { std::mem::zeroed::() }; @@ -328,7 +365,7 @@ mod pty { command .args([ "--exact", - "pty::nested_crossterm_raw_owner_is_preserved", + "pty::nested_raw_owner_is_preserved", "--nocapture", ]) .env(NESTED_RAW_HELPER, path) @@ -516,7 +553,10 @@ mod pty { "PTY child exited before accepting terminal input", )); } - thread::sleep(Duration::from_millis(10)); + // Darwin PTYs expose a small input queue. Keep feeding it promptly so a + // test-side backpressure sleep cannot consume the reader's sequence + // deadline while sending an otherwise immediate terminal report. + thread::sleep(Duration::from_millis(1)); } Err(error) if error.kind() == io::ErrorKind::Interrupted => {} Err(error) => return Err(error), @@ -817,34 +857,51 @@ mod pty { )) } - struct RawModeGuard; + struct RawModeGuard { + terminal: File, + original: Termios, + } + + impl RawModeGuard { + fn acquire() -> io::Result { + let terminal = OpenOptions::new().read(true).write(true).open("/dev/tty")?; + let original = termios::tcgetattr(&terminal)?; + let mut raw = original.clone(); + raw.make_raw(); + termios::tcsetattr(&terminal, termios::OptionalActions::Now, &raw)?; + Ok(Self { terminal, original }) + } + } impl Drop for RawModeGuard { fn drop(&mut self) { - let _ = disable_raw_mode(); + let _ = termios::tcsetattr( + &self.terminal, + termios::OptionalActions::Now, + &self.original, + ); } } fn exercise_nested_raw_rejection(path: &Path) { - let terminal = std::fs::OpenOptions::new() + let terminal = OpenOptions::new() .read(true) .write(true) .open("/dev/tty") .unwrap(); let initial = termios::tcgetattr(&terminal).unwrap(); - enable_raw_mode().unwrap(); - let guard = RawModeGuard; - assert!(is_raw_mode_enabled().unwrap()); + let guard = RawModeGuard::acquire().unwrap(); let owned = termios::tcgetattr(&terminal).unwrap(); + assert!(!owned.local_modes.contains( + termios::LocalModes::ECHO | termios::LocalModes::ICANON | termios::LocalModes::IEXTEN + )); let error = tut::run([path.as_os_str().to_owned()]).unwrap_err(); assert!(matches!(error, tut::TutError::TerminalInUse)); - assert!(is_raw_mode_enabled().unwrap()); let after = termios::tcgetattr(&terminal).unwrap(); assert_terminal_configuration(&after, &owned); drop(guard); - assert!(!is_raw_mode_enabled().unwrap()); let restored = termios::tcgetattr(&terminal).unwrap(); assert_terminal_configuration(&restored, &initial); } @@ -875,7 +932,7 @@ mod pty { } #[test] - fn nested_crossterm_raw_owner_is_preserved() { + fn nested_raw_owner_is_preserved() { if let Some(path) = std::env::var_os(NESTED_RAW_HELPER) { exercise_nested_raw_rejection(Path::new(&path)); return; @@ -1112,6 +1169,83 @@ mod pty { pty.assert_restored(); } + #[test] + fn epub_linear_content_works_through_a_real_terminal() { + let file = epub_fixture(); + let mut pty = PtyChild::spawn(file.path()).unwrap(); + pty.wait_for(b"PTY EPUB").unwrap(); + pty.wait_for(b"PTY AUTHOR").unwrap(); + pty.wait_for(b"EPUB_LINEAR_SENTINEL").unwrap(); + pty.wait_for(b"PLATE_ALT_SENTINEL").unwrap(); + assert!( + !pty.output + .windows(b"EPUB_FRONT_SENTINEL".len()) + .any(|window| window == b"EPUB_FRONT_SENTINEL") + ); + assert!( + !pty.output + .windows(b"Image unavailable".len()) + .any(|window| window == b"Image unavailable") + ); + + let front_start = pty.output.len(); + pty.write_command(b"g").unwrap(); + pty.wait_for_after(front_start, b"EPUB_FRONT_SENTINEL") + .unwrap(); + pty.wait_for_after(front_start, b"front matter").unwrap(); + assert!( + !pty.output[front_start..] + .windows(b"Image unavailable".len()) + .any(|window| window == b"Image unavailable") + ); + + let first_start = pty.output.len(); + pty.write_command(b"]").unwrap(); + pty.wait_for_after(first_start, b"EPUB_LINEAR_SENTINEL") + .unwrap(); + pty.wait_for_after(first_start, b"section 1/2").unwrap(); + + let second_start = pty.output.len(); + pty.write_command(b"]").unwrap(); + pty.wait_for_after(second_start, b"EPUB_SECOND_SECTION_SENTINEL") + .unwrap(); + pty.wait_for_after(second_start, b"section 2/2").unwrap(); + + let previous_start = pty.output.len(); + pty.write_command(b"[").unwrap(); + pty.wait_for_after(previous_start, b"EPUB_LINEAR_SENTINEL") + .unwrap(); + + let search_start = pty.output.len(); + pty.write_command(b"/EPUB_NONLINEAR_SENTINEL\r").unwrap(); + pty.wait_for_after(search_start, b"no matches").unwrap(); + pty.write_command(b"q").unwrap(); + + assert_eq!(pty.wait().unwrap().code(), Some(0)); + assert!(pty.stderr_output.is_empty()); + assert_session_terminal_restored(&pty); + } + + #[test] + fn epub_input_cannot_be_reused_as_its_session_log() { + let file = epub_fixture(); + let before = std::fs::read(file.path()).unwrap(); + let mut pty = PtyChild::spawn_logged(file.path(), Some(file.path()), None).unwrap(); + + assert_eq!(pty.wait().unwrap().code(), Some(1)); + assert!( + String::from_utf8_lossy(&pty.stderr_output) + .starts_with("tut: session log is the input document:") + ); + assert_eq!(std::fs::read(file.path()).unwrap(), before); + assert!( + !pty.output + .windows(ENTER_ALT.len()) + .any(|window| window == ENTER_ALT) + ); + pty.assert_restored(); + } + #[test] fn zero_mouse_coordinates_remain_bounded_through_a_real_terminal() { let file = NamedTempFile::new().unwrap(); @@ -1146,8 +1280,8 @@ mod pty { let mut pty = PtyChild::spawn(file.path()).unwrap(); pty.wait_for(b"START_SENTINEL").unwrap(); - // Kitty alternate-key reporting supplies the shifted codepoint and Crossterm consumes the - // Shift modifier. The command must still agree with legacy uppercase G. + // Kitty alternate-key reporting supplies the shifted codepoint and consumes the Shift + // modifier. The command must still agree with legacy uppercase G. pty.master.write_all(b"\x1b[103:71;2:2u").unwrap(); pty.wait_for(b"END_SENTINEL").unwrap(); @@ -1163,7 +1297,7 @@ mod pty { let search_start = pty.output.len(); // Kitty CSI-u without alternate reporting encodes shifted letters as lower-case codepoints // plus Shift. Repeat text is inserted, release text is ignored, and an alternate codepoint - // preserves shifted punctuation after Crossterm consumes its Shift modifier. + // preserves shifted punctuation after the protocol consumes its Shift modifier. pty.master .write_all(b"/\x1b[97;2u\x1b[108;2:2uPHA\x1b[120;1:3u\x1b[47:63;2u\r") .unwrap(); @@ -1237,13 +1371,9 @@ mod pty { pty.write_command_with_timeout(&sequence).unwrap(); assert_eq!(pty.wait().unwrap().code(), Some(1)); - assert!( - pty.stderr_output - == b"tut: failed to poll terminal events: terminal input sequence exceeded 65536 bytes\n" - || pty.stderr_output - == b"tut: failed to poll terminal events: terminal input sequence did not finish within 2 seconds\n", - "unexpected terminal input bound: {:?}", - String::from_utf8_lossy(&pty.stderr_output) + assert_eq!( + pty.stderr_output, + b"tut: failed to poll terminal events: terminal input sequence exceeded 65536 bytes\n" ); assert_session_terminal_restored(&pty); assert!( @@ -1253,6 +1383,26 @@ mod pty { ); } + #[test] + fn exact_limit_terminal_sequence_is_consumed_atomically() { + let file = NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "bounded input\n").unwrap(); + let mut pty = PtyChild::spawn(file.path()).unwrap(); + pty.wait_for(HIDE_CURSOR).unwrap(); + + let mut sequence = Vec::with_capacity(64 * 1024); + sequence.extend_from_slice(b"\x1b[200~"); + sequence.resize(64 * 1024 - b"\x1b[201~".len(), b'x'); + sequence.extend_from_slice(b"\x1b[201~"); + assert_eq!(sequence.len(), 64 * 1024); + pty.write_command_with_timeout(&sequence).unwrap(); + pty.master.write_all(b"q").unwrap(); + + assert_eq!(pty.wait().unwrap().code(), Some(0)); + assert!(pty.stderr_output.is_empty()); + assert_session_terminal_restored(&pty); + } + #[test] fn orphan_bracketed_paste_times_out_and_restores_the_terminal() { let file = NamedTempFile::new().unwrap(); @@ -1600,6 +1750,38 @@ mod pty { assert!(log.ends_with(" terminal_sessions=2 suspensions=1\n")); } + #[test] + fn keyboard_suspend_restores_and_continuation_reenters_the_terminal() { + let directory = tempdir().unwrap(); + let input = directory.path().join("input.txt"); + let log = directory.path().join("session.log"); + std::fs::write(&input, "KEYBOARD_SUSPEND_SENTINEL\n").unwrap(); + let mut pty = PtyChild::spawn_logged(&input, None, Some(&log)).unwrap(); + pty.wait_for(b"KEYBOARD_SUSPEND_SENTINEL").unwrap(); + + let suspension_start = pty.output.len(); + pty.write_command(b"\x1a").unwrap(); + pty.wait_until_stopped().unwrap(); + pty.wait_for_after(suspension_start, SHOW_CURSOR).unwrap(); + pty.wait_for_after(suspension_start, LEAVE_ALT).unwrap(); + pty.assert_restored(); + + let continuation_start = pty.output.len(); + pty.signal(Signal::CONT).unwrap(); + pty.wait_for_after(continuation_start, ENTER_ALT).unwrap(); + pty.wait_for_after(continuation_start, HIDE_CURSOR).unwrap(); + pty.wait_for_after(continuation_start, b"KEYBOARD_SUSPEND_SENTINEL") + .unwrap(); + pty.write_command(b"q").unwrap(); + + let status = pty.wait().unwrap(); + assert_eq!(status.code(), Some(0)); + assert!(pty.stderr_output.is_empty()); + pty.assert_restored(); + let log = std::fs::read_to_string(log).unwrap(); + assert!(log.ends_with(" terminal_sessions=2 suspensions=1\n")); + } + #[test] fn stopped_reader_uses_inherited_default_termination_actions() { let input = NamedTempFile::new().unwrap(); diff --git a/tests/documentation.rs b/tests/documentation.rs index 5c9bd51..88e648e 100644 --- a/tests/documentation.rs +++ b/tests/documentation.rs @@ -6,6 +6,7 @@ const PROJECT: &str = include_str!("../docs/PROJECT.md"); const CHANGELOG: &str = include_str!("../CHANGELOG.md"); const CONTRIBUTING: &str = include_str!("../CONTRIBUTING.md"); const SECURITY: &str = include_str!("../SECURITY.md"); +const CI: &str = include_str!("../.github/workflows/ci.yml"); const SECURITY_AUDIT: &str = include_str!("../.github/workflows/security-audit.yml"); const MANIFEST: &str = include_str!("../Cargo.toml"); @@ -37,7 +38,7 @@ fn primary_documents_cover_the_public_cli_contract() { assert!(manual.contains(interface), "manual omits {interface}"); } - for interface in ["standard input", "/dev/tty", "UTF-8"] { + for interface in ["standard input", "/dev/tty", "UTF-8", "EPUB", "NCX"] { assert!(README.contains(interface), "README omits {interface}"); assert!(manual.contains(interface), "manual omits {interface}"); } @@ -47,7 +48,7 @@ fn primary_documents_cover_the_public_cli_contract() { fn package_metadata_describes_the_documented_product() { assert_eq!( env!("CARGO_PKG_DESCRIPTION"), - "A local plain-text reader for the terminal" + "A local text and EPUB reader for the terminal" ); assert_eq!( env!("CARGO_PKG_HOMEPAGE"), @@ -70,6 +71,7 @@ fn user_documents_cover_the_interactive_contract() { for key in [ "F1", "Ctrl-C", + "Ctrl-Z", "Ctrl-D", "Ctrl-U", "PageDown", @@ -90,7 +92,7 @@ fn user_documents_cover_the_interactive_contract() { fn user_documents_record_supported_limits_and_exit_statuses() { assert_eq!(MAX_FILE_BYTES, 32 * 1024 * 1024); let manual = plain_manual(); - for limit in ["32 MiB", "4096", "16 columns by 4 rows"] { + for limit in ["32 MiB", "128 MiB", "4096", "16 columns by 4 rows"] { assert!(README.contains(limit), "README omits {limit}"); assert!(manual.contains(limit), "manual omits {limit}"); } @@ -147,4 +149,9 @@ fn dependency_audit_commands_stay_pinned_and_discoverable() { README.contains("cargo audit --file Cargo.lock"), "README local checks omit the dependency audit gate" ); + for workflow in [CI, SECURITY_AUDIT] { + assert!(!workflow.contains("schedule:")); + assert!(!workflow.contains("cron:")); + } + assert!(!CONTRIBUTING.contains("weekly")); }