diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5cfee8d55..75ebd1d7c 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (272) +## In this repository (276) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -280,6 +280,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | +| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 1a934d419..36e83c25d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2c4db646dd92672a4a9ba459165a75d63b4ecfa59bf6a3959fbcdcb6aace8a8c" +sha256 = "2467170262e96951caf3d4ad9e83a591902944d051d8ae39c413366c7157e18e" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fb4e629411b1f6ab6638d146f52ae6e5df072e4a361f6bbbe7c3447a5d3e4470" +sha256 = "8f84b8f5e85c3ca8ea969d1c50ab65d3c9c078270cec824c7938a67cb3371ce9" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "672e8b3c67eef7f88d5786b61141f63266fe469b3ef4f152ca4cb79c4ee2325a" +sha256 = "af2bce2c9841fdf9d6bdfac4c62ca3df9f8fe649d14dfbae9309376115221c4e" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1dadcc143efc901193649ccd8e1999c79fcd2ce04933e338db508e196269efae" +sha256 = "0b6ab36d0e091e2a1f100a9e7c7ab40a5e1fe899e5e41791fd7ca92f1cdafe02" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7a5ad176c089fcf4b34f1eb2aa1fbb7497c5be7a105319f2555cd6794aa4b08e" +sha256 = "52608c5321d89d186718e3662b5d91402bcfbf0a2571fdf4d86a0f90c29c29a1" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c16a452306b04b5d2e43ee703ff1af9b33cd6971be1711e1afa2411954db402b" +sha256 = "ab943a038ee0afca3cd073147f120761aefab228496dda63fa93afa18ceafa35" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1c514e34f01a6523ae35f30c69fd7c224bcdb7eb84ea56b4b13e99ca49f98e62" +sha256 = "eeca738456153c44de0d6cc4df390c3805fd3c37962a7f4d647a398cfc0da8c4" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ea4019ccb45272759b33bbd7767b0fb63b3ba05194737a23f498e31e1513f4b7" +sha256 = "1b983f4e0cb1437b92a235e30da1c17dc7ea978443fbd84cc3833072550ca560" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "46c1d612080b7cd976d78ea8f43f0bdd738a13080f8fd978e626d92a233259e5" +sha256 = "be1f667cd49464d2e058567e50a7a3a270f835909943648a4f37fe5f973c1eab" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a6d5e0acbda6c12d6945590d590d307a04a57a10c41253fc931a901bc20ed716" +sha256 = "ec617f2072377a8b3ae23c11aa0eb0cca76e0a92d10e1d5db83b0d6d3869d096" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4198ccc5c430d900ad7bb94fb18204403f75dd8f96c7b2cc8aec7d83b4d7c025" +sha256 = "8fd65bace058e349bd9d89a6deae5b3cf06e0ed8749d8c6f54760730c7da6161" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a523d73af1f5289e79f646115009cb612e30f78456edb2fb89a4cb3a8f58e040" +sha256 = "267555e46134c4f7a4aeba8d656472f0619e6daffb6b2b29e9b66357a706a0c7" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5a9c5f04aeb668f82daee1cc65318424d1a4d2ffeebff40ab9b28d2f06929bd6" +sha256 = "cac77f0b917fa70249ead076b9655f83781df3cfc613ffadfd70380a3372b2ec" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d33a4e1fb3d7d62077886b15e011370db590e2f54cebb5ff8bad0a82f0443e0b" +sha256 = "bb3e7bdefef4e76eeda6102c321e26bb0ff675ed483034f939e7436c8cb59829" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e2a417d4bfc9492a8e2504bb916745c2cb7158bec9c5308c388226b2e4fc13b0" +sha256 = "be9199e9c56f4d9e4d3a9658af200e9c3e5af1f4cb0098ef8f8e8bfa561af655" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c76b8a968a5602bf1aa3d08d5f411a6e3ae2935d6ea3450d5765dcef86ec3651" +sha256 = "5ff6d32391d32f03812b43ef5af830f15b471a9f4fe9196f0a5f84bd71b9d5b8" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "da3a37ccfe4ed177034d44e7891e8ae713123c3e2861354c545a9a8d147f30a5" +sha256 = "2d584badafc8ee97fbf3907e4f90bb38142f8f71f63df25eaf5771fe0c732f50" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1bb8a48b9041c7ae6b252def603298b1199f307063ccb5ec51df1afaaf56d8b9" +sha256 = "acadf8416bc32bc291d8da07f8823eebaa97a222ba86dafb7327a99fbfe4f21d" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9d18524f3ffa8f862a0cfc9606476bcf01b40113facc65679c5e46ea96522cbf" +sha256 = "d98b7dbc264bb258cfce859c3f584194cc9799d8ab1e2e849fd9f62d92f97c95" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "074375f4017fba26735223640d4e52d1e8cba7edbbb7e0f4329a76856a952a0f" +sha256 = "27ca01ab2e878e0a775cdb8eb96a9f510c11a7356ec3e34b89be55156d6002ee" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "729eacc77ef4ffcf2dadb13976f84d2b7016a7ddb3cf06eded18224b9230c3f8" +sha256 = "68d69ea18b1b498a195a1911a1208a824dc08d25eb2752443a8ba554a717072d" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "716d8819c188d0fce66f9f53ce3fdf9651190def48df11b3d1cf957c4348831b" +sha256 = "df6d9dd83c645d9eae1c0722c0fbd527866e48a3702d66d391c93e357a321621" [[asset]] @@ -3376,3 +3376,47 @@ licence_file = "vendor/vello_common/LICENSE-MIT" kind = "image" usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" + +[[asset]] +path = "assets/icons/superscript.svg" +title = "Lucide icon \"superscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0" + +[[asset]] +path = "assets/icons/subscript.svg" +title = "Lucide icon \"subscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4" + +[[asset]] +path = "assets/icons/pilcrow.svg" +title = "Lucide icon \"pilcrow\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8" + +[[asset]] +path = "assets/icons/move-horizontal.svg" +title = "Lucide icon \"move-horizontal\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" diff --git a/ROADMAP.md b/ROADMAP.md index b910ce0b6..49a8b27cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -216,6 +216,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. +- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged. - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg new file mode 100644 index 000000000..7be9e3af6 --- /dev/null +++ b/assets/icons/move-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg new file mode 100644 index 000000000..1a1b38b15 --- /dev/null +++ b/assets/icons/pilcrow.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg new file mode 100644 index 000000000..a37e9c42d --- /dev/null +++ b/assets/icons/subscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg new file mode 100644 index 000000000..c7978fb09 --- /dev/null +++ b/assets/icons/superscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index c10ab74d1..d4e054cf7 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4612,6 +4612,88 @@ trailer << /Root 1 0 R >> pdf } + /// A document whose `pages` page dictionaries all live in one (uncompressed) object stream, + /// found through a cross-reference stream: every page shows the content `0 0 1 rg 10 10 80 80 + /// re f` on a 100 pt square, except the last, which is 50 × 100 pt. + fn pages_in_one_object_stream(pages: usize) -> Vec { + // 1 catalog, 2 page tree, 3 content, 4 the object stream, 5.. the pages, then the xref. + let page = |n: usize| { + let size = if n + 1 == pages { "50 100" } else { "100 100" }; + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size}] /Contents 3 0 R >>\n") + }; + let (mut index, mut body) = (String::new(), String::new()); + for n in 0..pages { + index.push_str(&format!("{} {} ", n + 5, body.len())); + body.push_str(&page(n)); + } + index.push('\n'); + let kids: Vec = (0..pages).map(|n| format!("{} 0 R", n + 5)).collect(); + let content = "0 0 1 rg 10 10 80 80 re f"; + let mut pdf = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (num, obj) in [ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string()), + (2, format!("<< /Type /Pages /Kids [{}] /Count {pages} >>", kids.join(" "))), + (3, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())), + ( + 4, + format!( + "<< /Type /ObjStm /N {pages} /First {} /Length {} >>\nstream\n{index}{body}\nendstream", + index.len(), + index.len() + body.len() + ), + ), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{num} 0 obj\n{obj}\nendobj\n").as_bytes()); + } + // Cross-reference stream rows of /W [1 4 4]: free 0, objects 1–4 in the file, the pages + // at their index in object 4, and the cross-reference stream itself. + let xref_num = pages + 5; + let xref_at = pdf.len(); + let mut rows = vec![0u8, 0, 0, 0, 0, 0, 0, 0xff, 0xff]; + let row = |kind: u8, a: usize, b: usize| [[kind].as_slice(), &(a as u32).to_be_bytes(), &(b as u32).to_be_bytes()].concat(); + for at in &offsets { + rows.extend(row(1, *at, 0)); + } + for n in 0..pages { + rows.extend(row(2, 4, n)); + } + rows.extend(row(1, xref_at, 0)); + pdf.extend_from_slice( + format!("{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 4 4] /Root 1 0 R /Length {} >>\nstream\n", xref_num + 1, rows.len()).as_bytes(), + ); + pdf.extend_from_slice(&rows); + pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); + pdf + } + + /// An object stream's index of `objnum offset` pairs is read once, not for every object + /// fetched from it (vendored hayro-syntax patch 22). Read again each time, all of a stream of + /// N objects took N² steps: a Firefox page keeping 65,542 objects in one stream took 37 s to + /// draw and 40 s to extract its text. These 40,000 pages in one stream took about a minute + /// to open that way; now a fraction of a second. The last page, at the end of the index, + /// still has its own size. + #[test] + fn objects_in_one_large_object_stream_are_read_in_linear_time() { + const PAGES: usize = 40_000; + let pdf = pages_in_one_object_stream(PAGES); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + let last = r.render(RenderRequest { page: PAGES - 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + let _ = tx.send((first, last)); + }); + let (first, last) = + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("reading every object of one object stream must not take quadratic time"); + assert!(first.error.is_none(), "{:?}", first.error); + assert_eq!((first.width, first.height), (100, 100)); + assert_eq!(rgb_at(&first.rgba, first.width as usize, 50, 50), [0, 0, 255]); + assert!(last.error.is_none(), "{:?}", last.error); + assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset"); + } + /// A page whose content expands far beyond its compressed stream stops at the expansion budget, /// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a28..62b29fea8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2433,9 +2433,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } // No dialog, close prompt or palette over the page: only then does page input count. let unobstructed = !app.modal_open() && !app.palette_open; + let organize_zoom = app.organize_zoom; let view = &mut app.views[index]; if view.organize { + // One remembered grid size for every document, kept in the settings. + view.set_grid_zoom(organize_zoom); organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + app.organize_zoom = view.grid_zoom(); return; } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f7..87f4c7756 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -4,7 +4,7 @@ //! Delete to remove it. Each change is one undoable engine edit. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -605,51 +605,6 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { } } -/// Format text (shown in the Edit panel while a text item is selected, or for new text). -/// Returns the changed style; for a selected item the caller turns it into an update. -pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { - let mut s = style.clone(); - widgets::section_title(ui, tl!("Format text")); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { - for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { - ui.selectable_value(&mut s.family, f, f.label()); - } - }); - // A list rather than a drag value: every change is an undo step. - egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| { - for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { - ui.selectable_value(&mut s.size, size, format!("{size} pt")); - } - }); - }); - ui.horizontal(|ui| { - if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() { - s.bold = !s.bold; - } - if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() { - s.italic = !s.italic; - } - ui.separator(); - for (a, icon, tip) in [ - (TextAlign::Left, "align-left", tl!("Align left")), - (TextAlign::Center, "align-center", tl!("Centre")), - (TextAlign::Right, "align-right", tl!("Align right")), - (TextAlign::Justify, "align-justify", tl!("Justify")), - ] { - if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { - s.align = a; - } - } - }); - let c = s.color; - if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { - s.color = picked; - } - ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint)); - (s != *style).then_some(s) -} - /// How to use the tools, under the Add content list. pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 58fd71a67..453170904 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -396,12 +396,17 @@ pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { // Availability is a render projection: the saved group, selection and width survive Home. let _ = rendered.apply(Action::Close { panel: Panel::Inspector }); } + // Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped + // with another panel, or floating, it keeps its tab so it can be dragged away again; moving it + // to the other side stays a preference and a command. + let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_))); + let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) }; let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits( ui, &rendered, &style, |panel| crate::i18n::t(panel.label()).into(), - permissions, + render_permissions, panel_limits, |ui, panel| match panel { Panel::Canvas => { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 66ad98ffe..d272480d2 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -162,6 +162,10 @@ pub struct LineEditor { /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: pdfcraft_engine::AddedText, look0: pdfcraft_engine::AddedText, + /// A standard family was picked from the font list. The family guessed from a PDF font name + /// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone + /// would miss the choice. + family_chosen: bool, /// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and /// horizontal scale (%), and what they were. pub extras: Extras, @@ -182,36 +186,42 @@ impl Default for Extras { } } -/// Underline, line spacing, character spacing and horizontal scale for the paragraph being -/// edited (under Format text). Returns `true` when something changed. -pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { - let before = *e; - ui.horizontal(|ui| { - if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() { - e.underline = !e.underline; - } - let zero = tl!("Line spacing").to_string(); - let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") }; - egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { - for v in [1.0, 1.15, 1.5, 2.0] { - ui.selectable_value(&mut e.line_spacing, v, label(v)); - } - }); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Character spacing")); - ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Horizontal scale")); - ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); - }); - *e != before -} - impl LineEditor { + /// What Format text's font list shows: the chosen page font, a standard family picked + /// instead, or the paragraph's own font (its `/BaseFont` without the subset tag). + pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String { + if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) { + return face.clone(); + } + if self.look.family != self.look0.family || self.family_chosen { + return self.look.family.label().to_string(); + } + let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default(); + // "ABCDEF+Calibri" is a subset of Calibri. + let name = match base.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => base, + }; + if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } + } + + /// Back to the paragraph's own font: no page font chosen and the family it started with. + pub(crate) fn keep_source_font(&mut self) { + self.font_resource = None; + self.look.family = self.look0.family; + self.family_chosen = false; + } + + /// A standard family picked from the font list, replacing the paragraph's font even when it + /// is the family the paragraph's font name reads as. + pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) { + self.font_resource = None; + self.look.family = family; + self.family_chosen = true; + } + pub(crate) fn has_changes(&self) -> bool { - self.text != self.original || self.look != self.look0 || self.extras != self.extras0 + self.text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen } /// Extra width permitted by the editor. Source containers now remain fixed. @@ -229,7 +239,7 @@ impl LineEditor { let (l, o) = (&self.look, &self.look0); let mut style = pdfcraft_engine::BlockStyle { resource: self.font_resource.clone(), - family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), + family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), size: (l.size != o.size).then_some(l.size), color: (l.color != o.color).then_some(l.color), align: (l.align != o.align).then_some(l.align), @@ -267,6 +277,7 @@ impl LineEditor { pub fn applied(&mut self) { self.selection = None; self.font_resource = None; + self.family_chosen = false; self.look0 = self.look.clone(); self.extras0 = self.extras; self.original = self.text.clone(); @@ -965,6 +976,7 @@ fn open_editor( focus: true, look: look_of(l), look0: look_of(l), + family_chosen: false, extras: Extras::default(), extras0: Extras::default(), }); @@ -1177,7 +1189,7 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32 let Some(line) = lines.get(index) else { continue }; let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic); look.size = line.size; - if ed.look.family != ed.look0.family { + if ed.look.family != ed.look0.family || ed.family_chosen { look.family = ed.look.family; } if ed.look.bold != ed.look0.bold { @@ -1193,7 +1205,8 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32 look.size = ed.look.size; } let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0)); - let original_face = ed.look.family == ed.look0.family && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; + let original_face = + ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; let embedded = original_face && ed.fonts.contains_key(&line.font); if embedded && let Some(name) = ed.fonts.get(&line.font) { font.family = FontFamily::Name(name.clone().into()); @@ -1474,12 +1487,31 @@ mod tests { focus: true, look: look_of(&block), look0: look_of(&block), + family_chosen: false, extras: Extras::default(), extras0: Extras::default(), }; (app, ed) } + #[test] + fn picking_the_family_the_font_name_reads_as_still_changes_the_font() { + // A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave + // `look` equal to what the paragraph had, so no family change was sent and Calibri stayed. + let (_app, mut ed) = preview_app(); + let family = ed.look0.family; + assert_eq!(ed.style().family, None, "nothing chosen yet"); + ed.choose_family(family); + assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic))); + assert!(ed.has_changes()); + assert_eq!(ed.font_shown(&[]), family.label()); + ed.keep_source_font(); + assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice"); + ed.choose_family(family); + ed.applied(); + assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now"); + } + #[test] fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { // Loading a font used to only register it on the page: the paragraph kept its source diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs new file mode 100644 index 000000000..798b844a9 --- /dev/null +++ b/crates/ui-egui/src/format_text_ui.rs @@ -0,0 +1,333 @@ +//! Format text, laid out as Acrobat's panel: the font across the panel; size, colour, lists and +//! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing; +//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the +//! fonts already on its page and the spacing controls) and added text (the standard families, +//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and +//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so. +//! +//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. + +use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::{AddedText, FontFamily, TextAlign}; + +use crate::edit_text_ui::Extras; +use crate::theme::Tokens; +use crate::{icons, widgets}; + +/// A font picked from the font list. The caller applies it, since keeping the paragraph's own +/// font also means restoring the family it started with. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FontChoice { + /// The fonts the paragraph already uses. + KeepSource, + /// One of the page's fonts, by resource name. + Resource(String), + /// A standard family. + Family(FontFamily), + /// Load font…: pick a font file. + Load, + /// A font file installed on this computer. + Installed(std::path::PathBuf), +} + +/// What the font list offers and shows. +pub(crate) struct Fonts<'a> { + /// The fonts on the paragraph's page (resource, face); `None` for added text, which has the + /// standard families only. + pub sources: Option<&'a [(String, String)]>, + /// The name the list shows for the current choice. + pub shown: String, +} + +/// What the panel changed: the style (in place) and a font picked from the list. +#[derive(Default)] +pub(crate) struct Outcome { + pub changed: bool, + pub font: Option, +} + +const SIZES: [f64; 14] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0]; +const LINE_SPACINGS: [f64; 7] = [1.0, 1.15, 1.2, 1.5, 2.0, 2.5, 3.0]; +const SCALES: [f64; 8] = [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0]; +const CHAR_SPACINGS: [f64; 8] = [-1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 3.0, 5.0]; +/// A row's height: the tallest control in it (the 30 pt icon buttons). Every control is centred +/// on the row, whatever its own height, so lists, swatches and buttons line up (#3037). +const ROW: f32 = 30.0; +/// The gap between a row's controls. +const GAP: f32 = 6.0; +/// The icon naming a list or field ([`labelled_icon`]). +const LABEL_ICON: f32 = 22.0; +/// The button beside a number field that opens its list of common values: just an arrow, so the +/// field keeps the room its value needs. +const PRESET_LIST: f32 = 22.0; +/// What can be typed: horizontal scale in percent, character spacing in points. +const SCALE_RANGE: std::ops::RangeInclusive = 10.0..=400.0; +const CHAR_SPACING_RANGE: std::ops::RangeInclusive = -5.0..=50.0; + +/// A number as the lists show it: no trailing zeros ("1.2", "100", "-0.5"). +fn number(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome { + let before = (look.clone(), extras.as_deref().copied()); + let mut out = Outcome::default(); + widgets::section_title(ui, tl!("Format text")); + + // The font, across the panel. + let width = ui.available_width(); + egui::ComboBox::from_id_salt("format-font") + .selected_text(fonts.shown.as_str()) + .width(width) + .show_ui(ui, |ui| { + if let Some(sources) = fonts.sources { + if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { + out.font = Some(FontChoice::KeepSource); + } + for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { + if ui.selectable_label(false, face).clicked() { + out.font = Some(FontChoice::Resource(resource.clone())); + } + } + ui.separator(); + } + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { + out.font = Some(FontChoice::Family(f)); + } + } + if fonts.sources.is_some() { + ui.separator(); + if ui.selectable_label(false, tl!("Load font…")).clicked() { + out.font = Some(FontChoice::Load); + } + #[cfg(not(target_arch = "wasm32"))] + ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { + Some(list) if list.is_empty() => { + ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + } + Some(list) => { + for font in list { + if ui.button(&font.face).clicked() { + out.font = Some(FontChoice::Installed(font.path)); + ui.close(); + } + } + } + None => { + ui.label(tl!("Finding document fonts…")); + } + }); + } + }) + .response + .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))); + ui.add_space(4.0); + + // Size and colour | bullets, numbering and alignment. + row(ui, |ui| { + let _ = egui::ComboBox::from_id_salt("format-size") + .selected_text(number(look.size)) + .width(64.0) + .show_ui(ui, |ui| { + for size in SIZES { + ui.selectable_value(&mut look.size, size, number(size)); + } + }) + .response + .on_hover_text(tl!("Font size")); + let swatch = colour_button(ui, t, look.color); + egui::Popup::menu(&swatch).show(|ui| { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) { + look.color = picked; + ui.close(); + } + }); + let (sep, _) = ui.allocate_exact_size(vec2(9.0, 26.0), Sense::hover()); + ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider)); + planned(ui, "list", tl!("Bulleted list")); + planned(ui, "list-ordered", tl!("Numbered list")); + let current = match look.align { + TextAlign::Left => "align-left", + TextAlign::Center => "align-center", + TextAlign::Right => "align-right", + TextAlign::Justify => "align-justify", + }; + let align = icons::button(ui, current, 30.0, false, tl!("Alignment")); + let align = flyout(ui, t, align); + egui::Popup::menu(&align).show(|ui| { + ui.horizontal(|ui| { + for (a, icon, tip) in [ + (TextAlign::Left, "align-left", tl!("Align left")), + (TextAlign::Center, "align-center", tl!("Centre")), + (TextAlign::Right, "align-right", tl!("Align right")), + (TextAlign::Justify, "align-justify", tl!("Justify")), + ] { + if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() { + look.align = a; + ui.close(); + } + } + }); + }); + }); + + // Bold, italic, underline, superscript, subscript. + let mut extras = extras; + row(ui, |ui| { + if icons::button(ui, "bold", 30.0, look.bold, tl!("Bold")).clicked() { + look.bold = !look.bold; + } + if icons::button(ui, "italic", 30.0, look.italic, tl!("Italic")).clicked() { + look.italic = !look.italic; + } + match extras.as_deref_mut() { + Some(e) => { + if icons::button(ui, "underline", 30.0, e.underline, tl!("Underline")).clicked() { + e.underline = !e.underline; + } + } + None => planned(ui, "underline", tl!("Underline")), + } + planned(ui, "superscript", tl!("Superscript")); + planned(ui, "subscript", tl!("Subscript")); + }); + + // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an + // icon and its control, `half` wide. + let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); + ui.add_enabled_ui(extras.is_some(), |ui| { + let mut fallback = Extras::default(); + let e = extras.as_deref_mut().unwrap_or(&mut fallback); + row(ui, |ui| { + labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); + let own = tl!("Auto").to_string(); + let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; + egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + ui.add_space(8.0); + ui.add_enabled_ui(false, |ui| { + labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); + let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); + let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing") + .selected_text("0") + .width(half) + .show_ui(ui, |_| {}) + .response + .on_disabled_hover_text(tip); + }); + }); + // Any value the engine takes can be typed; the lists hold the common ones. + row(ui, |ui| { + let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale")); + let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" }; + number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale"))); + ui.add_space(8.0); + let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); + let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; + number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); + }); + }); + + out.changed = (look.clone(), extras.as_deref().copied()) != before; + ui.add_space(6.0); + ui.separator(); + out +} + +/// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it. +fn row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let size = vec2(ui.available_width(), ROW); + ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = GAP; + // A list lays itself out from the default control height and grows downward, which leaves + // it below the row's middle; at a list's own height it is centred like the rest. + let list = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + ui.spacing_mut().interact_size.y = ui.spacing().interact_size.y.max(list); + add(ui) + }) + .inner +} + +/// A control PdfCraft doesn't have yet: its icon, disabled, saying so when hovered. +fn planned(ui: &mut egui::Ui, icon: &str, label: &str) { + let _ = ui + .add_enabled_ui(false, |ui| icons::button(ui, icon, 30.0, false, label)) + .inner + .on_disabled_hover_text(format!("{label} — {}", tl!("Planned"))); +} + +/// The small corner mark Acrobat puts on a button that opens a menu. +fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response { + let r = resp.rect; + let tri = vec![r.right_bottom() + vec2(-3.0, -3.0), r.right_bottom() + vec2(-8.0, -3.0), r.right_bottom() + vec2(-3.0, -8.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); + resp +} + +/// An icon naming the list beside it, for screen readers too. Returns it, for the control it +/// labels. +fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + let tint = if ui.is_enabled() { t.icon } else { t.text_faint }; + icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint); + resp.on_hover_text(label) +} + +/// A value typed in a number field: what it edits, what may be typed, and how it reads. +struct Number<'a> { + value: &'a mut f64, + range: std::ops::RangeInclusive, + speed: f64, + suffix: &'static str, +} + +/// A number field with an arrow beside it that lists common values, in `width` all told: type any +/// value in its range, or pick one. Both are named by `label`: the icon before them and its text. +fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) { + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix); + let height = ui.spacing().interact_size.y; + ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0); + let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1); + egui::Popup::menu(&list).show(|ui| { + for &v in presets { + if ui.selectable_label(*n.value == v, number(v)).clicked() { + *n.value = v; + ui.close(); + } + } + }); + }); +} + +/// The text colour as a square swatch that opens the palette. +fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let fill = rect.shrink(4.0); + ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b)); + let stroke = if resp.hovered() { t.text } else { t.border }; + ui.painter().rect_stroke(fill, CornerRadius::same(2), Stroke::new(1.0, stroke), egui::StrokeKind::Outside); + crate::theme::hand(resp).on_hover_text(tl!("Colour")) +} + +#[cfg(test)] +mod tests { + #[test] + fn numbers_read_like_the_lists() { + assert_eq!(super::number(1.2), "1.2"); + assert_eq!(super::number(100.0), "100"); + assert_eq!(super::number(-0.5), "-0.5"); + assert_eq!(super::number(0.0), "0"); + assert_eq!(super::number(1.15), "1.15"); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 5ef94ef28..f6d85e897 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2716,5 +2716,11 @@ certificate Version الإصدار Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد Attach {name} إرفاق {name} Delete attachment {key} حذف المرفق {key} + Bulleted list قائمة نقطية + Numbered list قائمة مرقمة + Alignment المحاذاة + Superscript نص مرتفع + Subscript نص منخفض + Paragraph spacing تباعد الفقرات Less أقل Other options خيارات أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d44feeeac..a6500c588 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2402,5 +2402,12 @@ animation effects Off Изключени Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията Attach {name} Прикачване на {name} Delete attachment {key} Изтриване на прикачения файл {key} + Font Шрифт + Bulleted list Списък с водещи символи + Numbered list Номериран списък + Alignment Подравняване + Superscript Горен индекс + Subscript Долен индекс + Paragraph spacing Разстояние между абзаците Less По-малко Other options Други опции diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e5757db26..84c2c31e1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2513,5 +2513,12 @@ animation effects Off Vypnuto Attaching files is not available on the web yet Připojování souborů zatím na webu není k dispozici Attach {name} Připojit {name} Delete attachment {key} Odstranit přílohu {key} + Font Písmo + Bulleted list Odrážkový seznam + Numbered list Číslovaný seznam + Alignment Zarovnání + Superscript Horní index + Subscript Dolní index + Paragraph spacing Mezera mezi odstavci Less Méně Other options Další možnosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b5bb6ebed..abe4a111c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2440,5 +2440,12 @@ animation effects Off Aus Attaching files is not available on the web yet Dateien anhängen ist im Web noch nicht verfügbar Attach {name} {name} anhängen Delete attachment {key} Anhang {key} löschen + Font Schrift + Bulleted list Aufzählung + Numbered list Nummerierte Liste + Alignment Ausrichtung + Superscript Hochgestellt + Subscript Tiefgestellt + Paragraph spacing Absatzabstand Less Weniger Other options Weitere Optionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 374e486ce..ebab8a1b3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2445,5 +2445,12 @@ animation effects Off Ανενεργά Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό Attach {name} Επισύναψη {name} Delete attachment {key} Διαγραφή συνημμένου {key} + Font Γραμματοσειρά + Bulleted list Λίστα με κουκκίδες + Numbered list Αριθμημένη λίστα + Alignment Στοίχιση + Superscript Εκθέτης + Subscript Δείκτης + Paragraph spacing Απόσταση παραγράφων Less Λιγότερα Other options Άλλες επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 49ff0fc30..4bed2bc8b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2570,6 +2570,13 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Font Fuente + Bulleted list Lista con viñetas + Numbered list Lista numerada + Alignment Alineación + Superscript Superíndice + Subscript Subíndice + Paragraph spacing Espaciado entre párrafos Less Menos Other options Otras opciones diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c7781331f..9d876c221 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2439,5 +2439,12 @@ animation effects Off Désactivés Attaching files is not available on the web yet Joindre des fichiers n'est pas encore disponible sur le web Attach {name} Joindre {name} Delete attachment {key} Supprimer la pièce jointe {key} + Font Police + Bulleted list Liste à puces + Numbered list Liste numérotée + Alignment Alignement + Superscript Exposant + Subscript Indice + Paragraph spacing Espacement des paragraphes Less Moins Other options Autres options diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3f1d474f6..1a33cd46a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2438,5 +2438,12 @@ animation effects Off Ki Attaching files is not available on the web yet Fájlok csatolása a weben még nem érhető el Attach {name} {name} csatolása Delete attachment {key} {key} melléklet törlése + Font Betűtípus + Bulleted list Felsorolás + Numbered list Számozott lista + Alignment Igazítás + Superscript Felső index + Subscript Alsó index + Paragraph spacing Bekezdéstávolság Less Kevesebb Other options Egyéb beállítások diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 79540ca7a..f5317d351 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2651,5 +2651,12 @@ certificate SHA-256 digest Impronta SHA-256 Attaching files is not available on the web yet Allegare file non è ancora disponibile sul web Attach {name} Allega {name} Delete attachment {key} Elimina allegato {key} + Font Carattere + Bulleted list Elenco puntato + Numbered list Elenco numerato + Alignment Allineamento + Superscript Apice + Subscript Pedice + Paragraph spacing Spaziatura paragrafi Less Meno Other options Altre opzioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9e3ddcc80..995fd145c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2439,5 +2439,12 @@ animation effects Off オフ Attaching files is not available on the web yet Web 版ではまだファイルを添付できません Attach {name} {name} を添付 Delete attachment {key} 添付ファイル {key} を削除 + Font フォント + Bulleted list 箇条書き + Numbered list 番号付きリスト + Alignment 配置 + Superscript 上付き + Subscript 下付き + Paragraph spacing 段落間隔 Less 折りたたむ Other options その他のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 1abff77e9..f47ed4031 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2437,5 +2437,12 @@ animation effects Off Өшірулі Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз Attach {name} {name} тіркеу Delete attachment {key} {key} тіркемесін жою + Font Қаріп + Bulleted list Таңбалауыш тізім + Numbered list Нөмірленген тізім + Alignment Туралау + Superscript Жоғарғы индекс + Subscript Төменгі индекс + Paragraph spacing Абзац аралығы Less Азырақ Other options Басқа параметрлер diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 7484a7969..38703869c 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2690,5 +2690,11 @@ theme Appearance Išvaizda Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas Attach {name} Pridėti {name} Delete attachment {key} Ištrinti priedą {key} + Bulleted list Sąrašas su ženkleliais + Numbered list Numeruotas sąrašas + Alignment Lygiuotė + Superscript Viršutinis indeksas + Subscript Apatinis indeksas + Paragraph spacing Tarpas tarp pastraipų Less Mažiau Other options Kitos parinktys diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a00e8daa2..6de0d1958 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2415,5 +2415,12 @@ animation effects Off Wyłączone Attaching files is not available on the web yet Dołączanie plików nie jest jeszcze dostępne w wersji webowej Attach {name} Dołącz {name} Delete attachment {key} Usuń załącznik {key} + Font Czcionka + Bulleted list Lista punktowana + Numbered list Lista numerowana + Alignment Wyrównanie + Superscript Indeks górny + Subscript Indeks dolny + Paragraph spacing Odstęp między akapitami Less Mniej Other options Inne opcje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6f83ad01e..c4a9e274c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2453,5 +2453,12 @@ animation effects Off Desativados Attaching files is not available on the web yet Anexar arquivos ainda não está disponível na web Attach {name} Anexar {name} Delete attachment {key} Excluir anexo {key} + Font Fonte + Bulleted list Lista com marcadores + Numbered list Lista numerada + Alignment Alinhamento + Superscript Sobrescrito + Subscript Subscrito + Paragraph spacing Espaçamento entre parágrafos Less Menos Other options Outras opções diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index bc86b22c9..d40e9d588 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2352,5 +2352,12 @@ animation effects Off Выкл Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно Attach {name} Прикрепить {name} Delete attachment {key} Удалить вложение {key} + Font Шрифт + Bulleted list Маркированный список + Numbered list Нумерованный список + Alignment Выравнивание + Superscript Надстрочный + Subscript Подстрочный + Paragraph spacing Интервал между абзацами Less Меньше Other options Другие параметры diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index abdc99e0e..49f7a519b 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2444,5 +2444,12 @@ animation effects Off Izklopljeni Attaching files is not available on the web yet Prilaganje datotek na spletu še ni na voljo Attach {name} Priloži {name} Delete attachment {key} Izbriši prilogo {key} + Font Pisava + Bulleted list Označen seznam + Numbered list Oštevilčen seznam + Alignment Poravnava + Superscript Nadpisano + Subscript Podpisano + Paragraph spacing Razmik med odstavki Less Manj Other options Druge možnosti diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index f67e54db2..f1e0c046f 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2348,5 +2348,12 @@ animation effects Off ఆఫ్ Attaching files is not available on the web yet వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు Attach {name} {name}ను జోడించు Delete attachment {key} జోడింపు {key}ను తొలగించు + Font ఫాంట్ + Bulleted list బుల్లెట్ జాబితా + Numbered list సంఖ్యల జాబితా + Alignment అమరిక + Superscript సూపర్‌స్క్రిప్ట్ + Subscript సబ్‌స్క్రిప్ట్ + Paragraph spacing పేరా అంతరం Less తక్కువ Other options ఇతర ఎంపికలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4d9f322ab..8ded246f6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2435,5 +2435,12 @@ animation effects Off Kapalı Attaching files is not available on the web yet Dosya eklemek web'de henüz kullanılamıyor Attach {name} {name} ekle Delete attachment {key} {key} ekini sil + Font Yazı tipi + Bulleted list Madde işaretli liste + Numbered list Numaralı liste + Alignment Hizalama + Superscript Üst simge + Subscript Alt simge + Paragraph spacing Paragraf aralığı Less Daha az Other options Diğer seçenekler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0e52a8abb..10a6f8439 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2461,5 +2461,12 @@ animation effects Off Вимкнено Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне Attach {name} Прикріпити {name} Delete attachment {key} Видалити вкладення {key} + Font Шрифт + Bulleted list Маркований список + Numbered list Нумерований список + Alignment Вирівнювання + Superscript Надрядковий + Subscript Підрядковий + Paragraph spacing Інтервал між абзацами Less Менше Other options Інші параметри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7896b7798..e7d4cb47d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2683,6 +2683,12 @@ key Space Space Words Từ “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này. @id file.word_count Đếm từ… + Bulleted list Danh sách dấu đầu dòng + Numbered list Danh sách đánh số + Alignment Căn lề + Paragraph spacing Khoảng cách đoạn + Superscript Chỉ số trên + Subscript Chỉ số dưới Less Ít hơn Other options Tùy chọn khác diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index bf7152779..6b8fea666 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2403,5 +2403,12 @@ animation effects Off 关 Attaching files is not available on the web yet 网页版尚不支持附加文件 Attach {name} 附加 {name} Delete attachment {key} 删除附件 {key} + Font 字体 + Bulleted list 项目符号列表 + Numbered list 编号列表 + Alignment 对齐 + Superscript 上标 + Subscript 下标 + Paragraph spacing 段落间距 Less 收起 Other options 其他选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0218646d6..c4e2c3af6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2389,5 +2389,12 @@ animation effects Off 關 Attaching files is not available on the web yet 網頁版尚不支援附加檔案 Attach {name} 附加 {name} Delete attachment {key} 刪除附件 {key} + Font 字型 + Bulleted list 項目符號清單 + Numbered list 編號清單 + Alignment 對齊 + Superscript 上標 + Subscript 下標 + Paragraph spacing 段落間距 Less 收起 Other options 其他選項 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 6e3f26f57..f986cd6bf 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -185,6 +185,10 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("user-round", include_bytes!("../../../assets/icons/user-round.svg")), ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("wrench", include_bytes!("../../../assets/icons/wrench.svg")), + ("move-horizontal", include_bytes!("../../../assets/icons/move-horizontal.svg")), + ("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")), + ("subscript", include_bytes!("../../../assets/icons/subscript.svg")), + ("superscript", include_bytes!("../../../assets/icons/superscript.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), ("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")), ("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bd084333b..79254afa2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn shared_catalog_preserves_existing_icons_and_fallback_uri() { - assert_eq!(ICONS.len(), 186); + assert_eq!(ICONS.len(), 190); assert!(exists("minimize-2")); for (name, original) in ICONS { let expected = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 153647f44..7fd075dd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ mod files; pub mod fill_sign; mod fling; pub mod folders_ui; +mod format_text_ui; pub mod forms_ui; mod home; mod icon_data; @@ -698,6 +699,9 @@ pub struct PdfCraftApp { /// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set /// with [`PdfCraftApp::set_combine_zoom`]. pub combine_zoom: f32, + /// How large Organize pages draws its grid, for every document (1.0 = the usual; kept in + /// the settings). + pub organize_zoom: f32, /// The Combine grid's thumbnails: rendered off the UI thread, a few at a time. pub(crate) combine_thumbs: combine_grid::Thumbs, /// Images waiting for the resolution choice (released on cancel). @@ -1013,6 +1017,7 @@ impl PdfCraftApp { combine_columns: Default::default(), combine_view: Default::default(), combine_zoom: 1.0, + organize_zoom: 1.0, combine_thumbs: Default::default(), image_import: None, custom_stamps: Vec::new(), @@ -2341,6 +2346,7 @@ impl PdfCraftApp { v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + v["organize_zoom"] = serde_json::json!(self.organize_zoom); v.to_string() } @@ -2363,6 +2369,10 @@ impl PdfCraftApp { if let Some(zoom) = v["combine_zoom"].as_f64() { self.set_combine_zoom(zoom as f32); } + // Clamped like any grid zoom when the grid next shows; nonsense keeps the usual size. + if let Some(zoom) = v["organize_zoom"].as_f64().map(|z| z as f32).filter(|z| z.is_finite()) { + self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end()); + } if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) { // Only keep entries whose files still exist. #[cfg(not(target_arch = "wasm32"))] @@ -2673,6 +2683,8 @@ impl PdfCraftApp { return Err("grid-zoom must be between 50 and 300".into()); } v.set_grid_zoom(percent / 100.0); + // The grid's size is one for every document, as it is remembered. + self.organize_zoom = v.grid_zoom(); } ("rotate", Some(v)) => { let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 804a0dd15..60c18f3ff 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -516,50 +516,42 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some((i, _)) = app.active_ids() && let Some(ed) = app.views[i].line_editor.clone() { + use crate::format_text_ui::{FontChoice, Fonts}; let mut ed = ed; - let mut changed = false; - let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default(); - egui::ComboBox::from_id_salt("document-font-resource") - .selected_text( - ed.font_resource - .as_ref() - .and_then(|r| font_choices.iter().find(|f| &f.resource == r)) - .map_or(tl!("Keep source fonts"), |f| f.face.as_str()), - ) - .show_ui(ui, |ui| { - changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed(); - for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { - changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed(); - } - }); - if ui.button(tl!("Load font…")).clicked() { - app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); - } - #[cfg(not(target_arch = "wasm32"))] - egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| { - if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) { - if fonts.is_empty() { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - for font in fonts { - if ui.selectable_label(false, &font.face).clicked() { - app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]); - } - } - } else { - ui.label(tl!("Finding document fonts…")); + let sources: Vec<(String, String)> = app + .session + .get(app.views[i].id) + .map(|d| d.text_editor_fonts(ed.page)) + .unwrap_or_default() + .into_iter() + .map(|f| (f.resource, f.face)) + .collect(); + let shown = ed.font_shown(&sources); + let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }); + let mut changed = out.changed; + match out.font { + Some(FontChoice::KeepSource) => { + ed.keep_source_font(); + changed = true; } - }); - if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { - ed.look = look; - changed = true; + Some(FontChoice::Resource(resource)) => { + ed.font_resource = Some(resource); + changed = true; + } + Some(FontChoice::Family(family)) => { + ed.choose_family(family); + changed = true; + } + Some(FontChoice::Load) => app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false), + #[cfg(not(target_arch = "wasm32"))] + Some(FontChoice::Installed(path)) => app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[path]), + #[cfg(target_arch = "wasm32")] + Some(FontChoice::Installed(_)) => {} + None => {} } - changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { app.apply_line_format(i, ed, Some(ui.ctx())); } - ui.add_space(6.0); - ui.separator(); return; } let image = app.active_ids().and_then(|(i, id)| { @@ -593,21 +585,32 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { }); match selected { Some((page, index, text)) => { - if let Some(style) = crate::content_ui::format_panel(ui, t, &text) { - app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style.clone() }; - app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style) }); + let mut style = text.clone(); + added_text_format(ui, t, &mut style); + if style != text { + // The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later. + if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) { + app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style }; + } } } None if app.quick_tool == crate::QuickTool::AddText => { crate::content_ui::hint(ui, t); - if let Some(style) = crate::content_ui::format_panel(ui, t, &app.text_style) { - app.text_style = style; - } + let mut style = app.text_style.clone(); + added_text_format(ui, t, &mut style); + app.text_style = style; } - None => return, + None => {} + } +} + +/// Format text for added text: the standard families, size, colour, style and alignment. +fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) { + let shown = style.family.label().to_string(); + let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }); + if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { + style.family = family; } - ui.add_space(6.0); - ui.separator(); } impl PdfCraftApp { diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032ef..70e5a0dcb 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -236,7 +236,7 @@ fn typing_moving_styling_and_deleting_added_content() { h.run_steps(2); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected"); // Bold from the Format panel: one undoable change. - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(3); let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() }; @@ -585,3 +585,60 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() { let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); } + +/// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and +/// the controls PdfCraft can't apply yet present but disabled. +/// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and +/// the alignment button and is as tall as the font list, each spacing row's controls share one +/// line, and nothing reaches past the font list, which spans the panel. +#[test] +fn format_text_rows_line_up_and_fit_the_panel() { + use egui::accesskit::Role; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.get_by_label("FORMAT TEXT"); + let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value) + }; + let (font, size) = (combo(&h, "Helvetica"), combo(&h, "12")); + let middle = |r: egui::Rect| r.center().y; + for label in ["Colour", "Alignment"] { + let other = h.get_by_label(label).rect(); + assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})"); + } + assert_eq!(size.height(), font.height(), "the font and size lists are the same height"); + // Line and paragraph spacing; scale and character spacing with their lists. + let line = [combo(&h, "Auto"), combo(&h, "0")]; + assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}"); + // Each: its number field and the arrow that lists common values. + let scale_row: Vec = + ["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect(); + assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}"); + for r in &scale_row { + assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}"); + } + for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row) { + assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})"); + } +} + +#[test] +fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { + use egui_kittest::kittest::NodeT; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.get_by_label("FORMAT TEXT"); + for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] { + assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled"); + } + h.get_by_label("Alignment").click(); + h.run_steps(2); + h.get_by_label("Centre").click(); + h.run_steps(2); + assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center); + h.get_by_label("Bold").click(); + h.run_steps(2); + assert!(h.state().text_style.bold); +} diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 20fa4cb23..7de5c5999 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1299,19 +1299,13 @@ fn docking_pointer_group_float_close_restore_and_document_commands() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); h.run_steps(4); - let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").rect().center(); - let to = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); - h.event(egui::Event::PointerMoved(from)); - h.run_steps(2); - h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); - h.run_steps(2); - for step in 1..=8 { - h.event(egui::Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); - h.run_steps(1); - } - h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + // Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command. + assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none()); + ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); h.run_steps(4); assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools)); + // Grouped, Tools has its tab again. + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools"); h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary); h.run_steps(3); h.get_by_label("Float panel").click(); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 1890c1c32..6885a2bb6 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1880,6 +1880,39 @@ fn page_grid_zooms_with_its_buttons_keys_and_pinch() { assert!(!dirty(&h), "zooming is not an edit"); } +/// As in Acrobat, Organize pages keeps the size last chosen: every document's grid opens at it, +/// and so does the next start of the app. +#[test] +fn page_grid_size_is_remembered_for_every_document_and_restart() { + let mut h = organize(3); + h.get_by_label("Larger pages").click(); + h.run_steps(3); + assert_eq!(grid_zoom(&h), 1.25); + h.state_mut().open_bytes("other.pdf", None, fixture(2)).expect("second fixture opens"); + h.state_mut().set_option("organize", "on").unwrap(); + h.run_steps(4); + let other = h.state().active.expect("the new document is active"); + assert_ne!(other, 0); + assert_eq!(h.state().views[other].grid_zoom(), 1.25, "the other document"); + let settings = h.state().persist(); + let h = harness(3, move |app| { + app.restore(&settings); + app.set_option("organize", "on").unwrap(); + }); + assert_eq!(grid_zoom(&h), 1.25, "after a restart"); +} + +#[test] +fn a_damaged_remembered_page_grid_size_is_ignored_or_clamped() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"organize_zoom": "huge"}"#); + assert_eq!(app.organize_zoom, 1.0); + app.restore(r#"{"organize_zoom": 40.0}"#); + assert_eq!(app.organize_zoom, 3.0); + app.restore(r#"{"organize_zoom": -2}"#); + assert_eq!(app.organize_zoom, 0.5); +} + #[test] fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() { // A long document: sharpness must not depend on how many pages there are. @@ -2995,7 +3028,7 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(h.state().views[0].line_editor.is_some()); // The Format text panel shows the paragraph's look; making it bold applies at once. h.get_by_label("FORMAT TEXT"); - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(4); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); @@ -3011,6 +3044,62 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline)); } +/// Horizontal scale and Character spacing take any value in their ranges, not only the listed +/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved +/// file as the engine writes them. +#[test] +fn the_format_panel_takes_any_scale_and_character_spacing() { + let mut h = harness(1, |_| {}); + let id = h.state().views[0].id; + let width = |h: &Harness<'static, PdfCraftApp>, view: usize| { + let s = h.state(); + let r = s.session.get(s.views[view].id).unwrap().text_blocks(0)[0].rect; + r[2] - r[0] + }; + let plain = width(&h, 0); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some()); + // Typed the way a person does: click the field, type, Enter. + let type_in = |h: &mut Harness<'static, PdfCraftApp>, label: &str, value: &str| { + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run_steps(2); + h.event(egui::Event::Text(value.into())); + h.run_steps(1); + h.key_press(Key::Enter); + h.run_steps(4); + }; + let extras = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| (e.extras.scale, e.extras.char_spacing)); + // Between the listed values (50…200 %, −1…5 pt) and beyond them, within what was always allowed. + type_in(&mut h, "Horizontal scale", "80"); + assert_eq!(extras(&h), Some((80.0, 0.0))); + type_in(&mut h, "Horizontal scale", "250"); + assert_eq!(extras(&h), Some((250.0, 0.0))); + type_in(&mut h, "Character spacing", "0.25"); + assert_eq!(extras(&h), Some((250.0, 0.25))); + type_in(&mut h, "Character spacing", "-2"); + assert_eq!(extras(&h), Some((250.0, -2.0))); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text")); + // Saved and reopened, the paragraph is as wide as the engine draws it with those values. + let bytes = h.state().session.save_bytes(id).unwrap(); + h.state_mut().open_bytes("reference.pdf", None, fixture(1)).expect("opens"); + let style = pdfcraft_engine::BlockStyle { scale: Some(250.0), char_spacing: Some(-2.0), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: "Page 1".into(), style })); + h.state_mut().open_bytes("saved.pdf", None, bytes.as_ref().clone()).expect("the saved file opens"); + h.run_steps(3); + let (reference, saved) = (width(&h, 1), width(&h, 2)); + assert!(saved > plain * 1.5, "250 % widens it: {plain} → {saved}"); + assert!((saved - reference).abs() < 0.5, "saved {saved}, the engine's own {reference}"); +} + #[test] fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { let mut h = harness(1, |_| {}); @@ -3029,7 +3118,7 @@ fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); h.run_steps(2); assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, Some(5..6)); - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(5); let id = h.state().views[0].id; let doc = h.state().session.get(id).unwrap(); diff --git a/docs/roadmap.md b/docs/roadmap.md index ed56c8aea..c10d91d20 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,6 +11,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -221,6 +222,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | | 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | +| 2026-10-15 | minor | M2: an object stream's index is parsed once per stream (hayro-syntax patch 22), not once per object fetched from it; a page whose 65,542 objects share one stream draws in 0.5 s instead of 37 s. Estimates unchanged. | | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | | 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cac558c0b..ff6faf0cb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1986,8 +1986,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] -notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped" +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] +notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list" [[feature]] id = "edit.lists" @@ -2592,8 +2592,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open" +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/content.rs::format_text_rows_line_up_and_fit_the_panel"] +notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text." [[feature]] id = "edit.adobe-express-edits" @@ -2884,8 +2884,8 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"] -notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control." +evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"] +notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing: a slider as in Acrobat; the Pages side panel has no size control." [[feature]] id = "organize.split-every-n" diff --git a/vendor/README.md b/vendor/README.md index 824e84419..57bdd516c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device spac | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`) +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time` | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 20e33a410..673d516d2 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -289,6 +289,28 @@ impl XRef { Some(f(&mut cache)) } + /// PdfCraft patch: (hayro-syntax (22)) run `f` on this PDF's parsed object stream indexes + /// ([`ObjectStreamIndexes`]). `None` when there is none to use (the dummy xref, or a no_std + /// cache already borrowed), and the caller then parses the index without it. A poisoned lock + /// is still used: entries are complete `Arc`s inserted in one step, so a panic elsewhere + /// cannot have left one half-written. + fn with_object_stream_indexes( + &self, + f: impl FnOnce(&mut ObjectStreamIndexes) -> T, + ) -> Option { + let Inner::Some(repr) = &self.0 else { + return None; + }; + #[cfg(feature = "std")] + let mut indexes = repr + .object_stream_indexes + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + #[cfg(not(feature = "std"))] + let mut indexes = repr.object_stream_indexes.try_borrow_mut().ok()?; + Some(f(&mut indexes)) + } + fn new( data: PdfData, xref_map: XrefMap, @@ -310,6 +332,7 @@ impl XRef { trailer_data, password: password.to_vec(), dict_cache: Arc::new(Mutex::new(DictCache::default())), + object_stream_indexes: Arc::new(Mutex::new(ObjectStreamIndexes::default())), }))); // We read the trailer twice, once to determine the encryption used and then a second @@ -622,10 +645,35 @@ impl XRef { return None; } - let stream = self.get_with::>(obj_stream_id, &ctx)?; + // PdfCraft patch: (hayro-syntax (22)) the stream's index of `objnum offset` pairs + // is parsed once and kept, as its decoded data is: parsed again for every object + // fetched from it, reading all of a stream of N objects took N² steps (one + // Firefox page kept 65,542 objects in one stream and took 37 s to draw). let data = repr.data.get_with(obj_stream_id, &ctx)?; - let object_stream = ObjectStream::new(stream, data, &ctx)?; - object_stream.get(index) + let known = self + .with_object_stream_indexes(|indexes| indexes.get(&obj_stream_id).cloned()) + .flatten(); + let offsets = match known { + // A stream whose pairs don't parse stays unreadable, as it was. + Some(offsets) => offsets?, + None => { + let stream = self.get_with::>(obj_stream_id, &ctx)?; + // `/N` and `/First` may be references that can't be followed while + // another stream loads: reading them fails without being remembered. + let num_objects = stream.dict().get::(N)?; + let first_offset = stream.dict().get::(FIRST)?; + let parsed = + read_object_stream_index(num_objects, first_offset, data).map(Arc::new); + self.with_object_stream_indexes(|indexes| { + indexes + .entry(obj_stream_id) + .or_insert_with(|| parsed.clone()) + .clone() + }) + .unwrap_or(parsed)? + } + }; + ObjectStream::with_offsets(data, &ctx, offsets).get(index) } } } @@ -698,6 +746,7 @@ impl TrailerData { #[derive(Debug, Clone)] struct SomeRepr { dict_cache: Arc>, + object_stream_indexes: Arc>, data: Arc, map: Arc>, metadata: Arc, @@ -1067,34 +1116,62 @@ fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result>>>; + +/// The `(object number, offset)` pairs at the start of an object stream's decoded `data`, the +/// offsets measured from the start of `data`. `None` when the pairs don't parse. Reads no +/// further than the data holds, whatever `/N` claims. +fn read_object_stream_index( + num_objects: usize, + first_offset: usize, + data: &[u8], +) -> Option> { + let mut r = Reader::new(data); + + let mut offsets = vec![]; + + for _ in 0..num_objects { + r.skip_white_spaces_and_comments(); + // Skip object number + let obj_num = r.read_without_context::()?; + r.skip_white_spaces_and_comments(); + let relative_offset = r.read_without_context::()?; + // PdfCraft patch: (hayro-syntax (22)) an offset past `usize` is malformed (it wrapped). + offsets.push((obj_num, first_offset.checked_add(relative_offset)?)); + } + + Some(offsets) +} + struct ObjectStream<'a> { data: &'a [u8], ctx: ReaderContext<'a>, - offsets: Vec<(u32, usize)>, + offsets: Arc>, } impl<'a> ObjectStream<'a> { fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option { let num_objects = inner.dict().get::(N)?; let first_offset = inner.dict().get::(FIRST)?; + let offsets = read_object_stream_index(num_objects, first_offset, data)?; - let mut r = Reader::new(data); - - let mut offsets = vec![]; - - for _ in 0..num_objects { - r.skip_white_spaces_and_comments(); - // Skip object number - let obj_num = r.read_without_context::()?; - r.skip_white_spaces_and_comments(); - let relative_offset = r.read_without_context::()?; - offsets.push((obj_num, first_offset + relative_offset)); - } + Some(Self::with_offsets(data, ctx, Arc::new(offsets))) + } + /// PdfCraft patch: (hayro-syntax (22)) an object stream whose index is already parsed. + fn with_offsets( + data: &'a [u8], + ctx: &ReaderContext<'a>, + offsets: Arc>, + ) -> Self { let mut ctx = ctx.clone(); ctx.set_in_object_stream(true); - Some(Self { data, ctx, offsets }) + Self { data, ctx, offsets } } fn get(&self, index: u32) -> Option @@ -1369,4 +1446,12 @@ mod tests { let mut reader = Reader::new(data); assert!(read_xref_table_trailer(&mut reader, &ReaderContext::dummy()).is_none()); } + + /// PdfCraft patch: (hayro-syntax (22)) an object's offset past `usize` is malformed: a plain + /// `+` there panics in a debug build and wraps in a release one. + #[test] + fn an_object_stream_offset_past_usize_is_malformed_not_a_panic() { + assert_eq!(read_object_stream_index(1, usize::MAX, b"5 1"), None); + assert_eq!(read_object_stream_index(1, 10, b"5 1"), Some(vec![(5, 11)])); + } } diff --git a/xtask/baselines/pdfjs.json b/xtask/baselines/pdfjs.json index a441ad872..212da3373 100644 --- a/xtask/baselines/pdfjs.json +++ b/xtask/baselines/pdfjs.json @@ -4,7 +4,6 @@ "auth-event-ef-open.pdf": "open-error", "bug1020226.pdf": "open-error", "bug1782186.pdf": "open-error", - "bug1978317.pdf": "timeout", "encrypted-attachment.pdf": "open-error", "issue15590.pdf": "open-error", "issue15893_reduced.pdf": "open-error",