diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index b9341385ec..c7fd72098b 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 = "c70caadef8f52c88dda53acae29525d06eecee2b7ff2f37dcd97fcb02ac5285a" +sha256 = "26ba9056f4216689d309bd8c3c698e018953bdc9b9383afa7072206ae79286bc" # 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 = "f14bc3945d397c664c0d46c624568d0525864eb7c7aba001d1d434f571e1d8c6" +sha256 = "1bd912e94495bef2c336f90af14c18c994a1466438fc5805e52e3cc3f1fa6066" [[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 = "d1087a3191cf50bb8ca75c2d01b4091fe5d0c0987da4263e69cd81f2f2b3f815" +sha256 = "7bfdfd3ae806a40d1966d4c1cefa44f0b9b5f10034b7c5e729a6446e414a1b01" [[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 = "cb5ac03334bcbc05db290a5a5ed948c0172869bf9e64cc050a621b1a90d880cd" +sha256 = "2692063e82a568a94185a39664ab5da06cbd8b0329cca6aa00b4a5d6a55d69f3" [[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 = "0592951b23cab160003cf0a4d57764a0beaea8fcdbdb2e30dac016548f80ee2e" +sha256 = "abd852ed86a3418735a669fb02fe13acdfd40d51b0e91f3acdf32c3ae6530b75" [[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 = "c6889458db78e160fe9381549d7b9be9801f42a5bb8fe8367615ebc18df01c2d" +sha256 = "63f96f0313b80d4abad65f15c0f5dbbe3591e07f63dd1ba160c0f2e771e17c4a" [[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 = "bb2bc27d42a857ec7afce94644e5407f5676a31fd1ecceb38a4cf6cfe37d64b5" +sha256 = "5c7cb183dfbba4fa8d332610aa4cc2d7f31f2d41704c77b0641a6b96f9f71048" [[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 = "ab5ce70d22ed63a8a1f96c730468fabf6a411283b63120b651e2539dd4cfbdb3" +sha256 = "73d966644239f00e303be78d1f0973f00b54a65da761e373ab5cc98464e80676" [[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 = "67f95b2a38eb99af3d67833854068fad7bca4cc1214e2dd3becb25e4085a9bbd" +sha256 = "047d97de01912a4f87740c2df0bf2880d094468dfe57c8f4e27d33219e686aa4" [[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 = "9862c4a53240985a0fdb5acf052aec5f537f71380f8c0d29a5a4fdc6e8b5ef41" +sha256 = "b9eb0d2a3cc9f35b6a0e22261a2ffc43a08b1a4627ae0fd8b618ea344a26f6af" [[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 = "9716ac3bebc449962eb3f15ce6f9a72a2ed05535e721c04bb3c7dc1ec8577985" +sha256 = "13e9af888d96c5daeada90e688048312f4347cbea415f8af930de467253cf6be" [[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 = "a47c41d1e5559386f9a94444f9ec2e3a48d51d5fa587aa372ec030556c9ecd17" +sha256 = "f6606550f1705ecd00682ebc44238e74b570d79e206266a67247cefa93f7a877" [[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 = "911c2a48182efcce05af453803b94d6f66f7d2f37ddf1ca9b01e912f47db1a66" +sha256 = "986f7453dfeb6feeccc24a72537b60345eb1f7bb5be46143f68515f9550979a6" [[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 = "66887fab11c900680136d528385f8928ad673b63baf6f171acd16c6b0245cc4a" +sha256 = "48d34433d6c17358d9f934d60735914feb4433c70c5e86712a971794345c6c81" [[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 = "0ba6933fec3aea7528cabcb68901ea5ad81bc6cb93dc9d01767ad46b5bdf00dc" +sha256 = "c68c9d3e3dd0bea92c9a0e0213ac86e43ce229fffb4f8152a5fa25e2bfca6eb8" [[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 = "432c818f2c78780418237d7d88dec1e268be2bf2c336ac40a11926f841f8af46" +sha256 = "3cebc4f5753da0e15fa8d36957e0aaac2ff8272aed9e192e88e97667920045c7" [[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 = "5c2f293d7a3c124ad6fbb19ea63b37711ae76ec5e0180bd466586c739a108094" +sha256 = "55eec8795d88a5e61476a9e3fc198303f04300b49198445a4a7156e3f44f68b2" [[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 = "7d12586bb4cb268d4e353dcebf20a4a190737821a34a2e8fab05c1d119b5fc24" +sha256 = "164436e95395bd0cbed63fb7e419a8c2a960252b03c0bff8cb827b968816f895" [[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 = "74bff0a4ed87b103df409309ba3898403d8cd1d29934583d527ba9327c4edf9c" +sha256 = "d59cfeba4163af3febcf60127e7f8128318d3129868c3614d8aa689239e80f22" [[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 = "efe5caf1ef8b313a5728ec9a20596d523c9cebb847ed3f20fa5a81dec9e7a9f2" +sha256 = "e9b1cffd8900390f98d325a0c9fcfd30e113bfe57a265bba274d4826520979de" [[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 = "19283a0a5ae0636e3539fa143076f811b6ea602338b14d6214694d61f70a337b" +sha256 = "1c1bbee82bc1a1e47bff99f0a321c80508442b47af243ced7f81cc0a1d146be4" [[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 = "cd64ce98d111cc3cf5a0daa2874ffbce78f314e8d1cba3b2297983151b600864" +sha256 = "59527f6e73ff2e10aa9aefe96bf32b162e2d75085e6f69b6be4ffcc4779d9b96" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index 5108635d18..4ca7710938 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,12 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **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 (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; 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 (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; 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; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -161,6 +164,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. +- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. +- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. +- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background puts the original back unless the copy was edited since. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. @@ -169,6 +176,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. +- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **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. @@ -177,6 +185,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. @@ -505,8 +514,12 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 0e24800d39..119aaf4acb 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -298,14 +298,21 @@ fn info(args: &[String]) -> Result<(), CliError> { let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); - let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); // Dynamic XFA forms: say what laying the template out gives (what the app shows). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { let mut s = pdfcraft_engine::Session::new(); let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; let d = s.get(id)?; - d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) }) .flatten(); let json = serde_json::json!({ @@ -313,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "pdf_version": info.pdf_version, "file_size": info.file_size, "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, @@ -348,7 +356,19 @@ fn text(args: &[String]) -> Result<(), CliError> { } } let password = flag(args, "--password"); - let bytes = read(path)?; + let mut bytes = read(path)?; + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); // A document the renderer could not open (wrong or missing password, unparseable file) has // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask @@ -359,15 +379,6 @@ fn text(args: &[String]) -> Result<(), CliError> { return Err("text: the document could not be parsed".into()); } } - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - } let pages: Vec = match selected_page { Some(page) => vec![page], None => (0..r.page_count()).collect(), @@ -588,10 +599,15 @@ fn render(args: &[String]) -> Result<(), CliError> { // The app renders a display copy that draws comments missing their /AP streams; the CLI // must agree with it (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; - let mut r = PageRenderer::new( - pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), - RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, - ); + let password = flag(args, "--password"); + let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() }); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); if let Some(e) = p.error { return Err(e.into()); diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/xfa.rs new file mode 100644 index 0000000000..1b5ff7bc9d --- /dev/null +++ b/apps/pdfcraft-cli/tests/xfa.rs @@ -0,0 +1,136 @@ +//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645). + +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::Arc; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn fixture(test: &str) -> PathBuf { + let xdp = r#" +"#; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("form.pdf"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn success(output: &Output) { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +#[test] +fn info_reports_the_laid_out_pages_and_fields() { + let path = fixture("info"); + let original = std::fs::read(&path).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages"); + assert_eq!(info["pages"], 2, "top-level page count must agree with the app"); + assert_eq!(info["fields"], 1); + assert_eq!(info["pdf_pages"], 1); + assert_eq!(info["acroform_fields"], 0); + assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0])); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); +} + +#[test] +fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { + let path = fixture("text"); + let output = Command::new(BIN).arg("text").arg(&path).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}"); + let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}"); +} + +#[test] +fn render_matches_the_engine_layout_on_the_second_page() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let path = fixture("render"); + let original = std::fs::read(&path).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page"))); + let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default()); + let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(expected.error.is_none(), "{:?}", expected.error); + assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink"); + let out = path.with_extension("pam"); + let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap(); + success(&output); + let actual = std::fs::read(out).unwrap(); + let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height); + assert!(actual.starts_with(header.as_bytes())); + assert_eq!(&actual[header.len()..], &expected.rgba[..]); + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn a_laid_out_form_keeps_the_original_copy_permissions() { + let path = fixture("permissions"); + let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "openme", + owner_password: "boss", + permissions: -17, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying")); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap(); + success(&output); + assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page")); + let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["pages"], 2); + assert_eq!(info["encrypted"], true); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original); +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 4d9857ce19..b274dc53a8 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -565,7 +565,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in - // Surface::configure before the app starts. Keep the renderer's other device requirements, + // Surface::configure before the app starts. Keep the renderer's other compatible requirements, // but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters). let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor); setup.device_descriptor = std::sync::Arc::new(move |adapter| { @@ -606,8 +606,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { } /// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. -/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements. +/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent. fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits { + required = required.or_worse_values_from(supported); required.max_texture_dimension_2d = supported.max_texture_dimension_2d; required } @@ -912,16 +913,51 @@ mod tests { fn surface_limits_preserve_other_requirements_and_never_overrequest() { for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() }; + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; let enabled = super::surface_texture_limits(required.clone(), &supported); let mut expected = required.clone(); expected.max_texture_dimension_2d = extent; assert_eq!(enabled, expected); assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); } } } + #[test] + fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); + } + + #[test] + fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); + } + #[test] fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { use eframe::egui_wgpu::winit::surface_fit; diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1e8d71bd1c..8130ed084d 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -918,11 +918,16 @@ impl Automation { let list: Vec = found .iter() .map(|(p, c)| { - let kind = match c.kind { - pdfcraft_engine::detect::Kind::Text => "text", - pdfcraft_engine::detect::Kind::CheckBox => "checkbox", + let (kind, comb) = match c.kind { + pdfcraft_engine::detect::Kind::Text => ("text", None), + pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), + pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), }; - json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }) + let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); + if let Some(cells) = comb { + v["comb"] = json!(cells); + } + v }) .collect(); if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 710952d7c2..faefc5fa7c 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -138,7 +138,7 @@ impl Automation { .added() .iter() .filter(|x| x.page == page) - .nth((index - 1).max(0) as usize) + .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX)) .filter(|_| index >= 1) .ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?; Ok((page, index as usize - 1, it.content.clone(), ph)) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index ba4bf7ced8..c136afee51 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -283,6 +283,13 @@ impl Automation { let to = self.position(&a, "to")?; self.apply(&a, Edit::MovePages { pages, to })? } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } "page_insert_blank" => self.insert_blank(&a)?, "page_insert_file" => self.insert_file(&a)?, "page_extract" => self.page_extract(&a)?, @@ -3125,4 +3132,23 @@ mod tests { std::fs::set_permissions(&target, perms).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + + /// A recovered document, or a new one, rotated and then undone still has never been saved: + /// `doc_close` refuses it unless told to discard it (#2346 review). + #[test] + fn closing_a_recovered_or_new_document_after_undo_is_refused() { + let mut a = Automation::new(); + let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); + a.session.mark_recovered(recovered, Some("Report.pdf".into())); + let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); + for id in [recovered, new] { + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + json(&mut a, "edit_undo", json!({"doc": id.0})); + let refused = a.call("doc_close", &json!({"doc": id.0})); + assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); + assert!(a.session.get(id).is_some(), "still open"); + json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); + assert!(a.session.get(id).is_none()); + } + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e520b7bb63..aacb8104b7 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -224,6 +224,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }), &["doc", "pages", "to"], )), + t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.") + .cmd("page.reverse") + .with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])), t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.") .cmd("page.insert_blank") .with(schema( @@ -1102,7 +1105,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..30658fdfd7 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2082,6 +2082,21 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +#[test] +fn reversing_pages_through_tools() { + let dir = workdir("reverse"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_reverse", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document"); + ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] })); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse"); +} + #[test] fn duplicating_and_cropping_through_tools() { let dir = workdir("boxes"); @@ -3582,6 +3597,24 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable +/// failure for `content_update` and `content_delete`, not a panic. +#[test] +fn content_index_out_of_range_is_an_error_not_a_panic() { + let dir = workdir("content-index-range"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] })); + for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] { + for tool in ["content_delete", "content_update"] { + let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" }); + let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args }; + assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}"); + } + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] @@ -4959,6 +4992,41 @@ fn detecting_form_fields_through_tools() { assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}"); } +#[test] +fn detecting_comb_boxes_through_tools() { + let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("detect-comb"); + std::fs::write(dir.join("comb.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc })); + assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001"); +} + #[test] fn comparing_documents_through_tools() { let dir = workdir("compare"); diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56eeb..e943555905 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -337,6 +337,40 @@ impl PartialEq for SourceIdentity { impl Eq for SourceIdentity {} +/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed +/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every +/// change makes a different one. Changed objects are compared as allocations, which the state +/// keeps alive, so a later object can't take an address one of them had. +#[derive(Clone)] +pub struct ReadState { + source: SourceIdentity, + trailer: Dict, + overlay: BTreeMap, +} + +impl ReadState { + /// Whether `doc` reads as this state. + pub fn matches(&self, doc: &Document) -> bool { + self.source == doc.source_identity() + && self.trailer == doc.trailer + && self.overlay.len() == doc.overlay.len() + && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { + a == b + && match (x, y) { + (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::Freed(g), Slot::Freed(h)) => g == h, + _ => false, + } + }) + } +} + +impl std::fmt::Debug for ReadState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() + } +} + #[derive(Clone)] pub struct Document { data: Arc>, @@ -801,6 +835,12 @@ impl Document { SourceIdentity(Arc::downgrade(&self.source_identity)) } + /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it + /// changes with every edit, so a value derived from the document can be cached against it. + pub fn read_state(&self) -> ReadState { + ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } + } + pub fn version(&self) -> &str { &self.version } @@ -1113,7 +1153,19 @@ impl Document { if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return Ok(s.clone()); } - let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) }; + // A file entry that cannot place the stream (one that says it is itself compressed + // inside another stream, as in #2367, or points nowhere) is tried by the stream's own + // header, as a plain object with a wrong offset is. An edited or freed stream, in the + // overlay or in a saved revision's table, is what that says it is: the file's copy + // must not come back. + let loaded = match self.try_get(num) { + Ok(o) if matches!(*o, Object::Stream(_)) => o, + _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { + return Err(CosError::MissingObject(num)); + } + _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), + }; + let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize; let data = s.decoded_within(self.stream_limit)?; @@ -1935,6 +1987,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607; mod tests { use super::*; + #[test] + fn a_read_state_matches_a_clone_and_no_changed_document() { + let mut doc = Document::new_empty(); + let a = doc.add(Object::Int(1)); + let state = doc.read_state(); + let snapshot = doc.clone(); + assert!(state.matches(&doc) && state.matches(&snapshot)); + // The same value set again is still a change: another allocation. + doc.set(a, Object::Int(1)); + assert!(!state.matches(&doc) && state.matches(&snapshot)); + let edited = doc.read_state(); + doc.free(a); + assert!(!edited.matches(&doc)); + let mut trailer_changed = snapshot.clone(); + trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); + assert!(!state.matches(&trailer_changed)); + // Another document's state never matches, even with the same changes. + let mut other = Document::new_empty(); + other.add(Object::Int(1)); + assert!(!other.read_state().matches(&snapshot)); + } + /// Build a classic-xref file from object bodies (object i+1 = bodies[i]). pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); @@ -2535,6 +2609,93 @@ mod tests { assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream + /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. + fn compressed_in_compressed_fixture() -> Document { + let mut out = b"%PDF-1.5\n".to_vec(); + let at = |out: &mut Vec, num: u32, body: &str| { + let off = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + off as u16 + }; + let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); + let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); + let inner = "<< /Type /Outlines /Count 0 >>"; + at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); + // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. + let rows: Vec<[u8; 4]> = vec![ + [0, 0, 0, 255], + [1, 0, o1 as u8, 0], + [1, 0, o2 as u8, 0], + [2, 0, 99, 0], + [2, 0, 3, 0], + [1, (out.len() >> 8) as u8, out.len() as u8, 0], + ]; + let rows: Vec = rows.concat(); + let xref = out.len(); + out.extend_from_slice( + format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + /// An object stream whose own cross-reference entry says it is compressed inside another + /// stream (which the format forbids, and which does not exist here: qpdf's + /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it + /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own + /// header, as a plain object with a wrong offset is. + #[test] + fn an_object_stream_marked_as_compressed_is_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); + let outlines = doc.get(ObjRef::new(4, 0)); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); + let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); + } + + /// The header fallback is for file entries only: once the stream is freed (or replaced) in + /// the overlay, its objects must not come back from the file's copy of it. + #[test] + fn a_freed_object_stream_is_not_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 0)); + let fresh = edited.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(3, 0), Object::Int(0)); + let fresh = replaced.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + } + + /// The same once the free is saved: the revision's table says the stream is free, and + /// neither reading the file back nor rewriting it in full brings its objects back. + #[test] + fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { + let mut edited = compressed_in_compressed_fixture(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(reopened.revisions().len(), 2); + assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); + assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); + for object_streams in [true, false] { + let opts = crate::SaveOptions { object_streams, ..Default::default() }; + let full = crate::write_full(&reopened, &opts).unwrap(); + let back = Document::open(Arc::new(full)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); + assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); + } + } + #[test] fn damaged_xref_is_reconstructed() { let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 213ff341be..152daed2de 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod security; mod writer; pub use bytes::Bytes; -pub use document::{Document, Revision, SourceIdentity, XrefEntry}; +pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a42ec84fa8..5717977b1e 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,6 +5,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; +mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use pdfcraft_fonts::{literal, win_ansi, wrap}; @@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { - Ok(vec![jpeg(name, bytes)?]) + Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f3..a996b703f3 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -949,3 +949,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { } } } + +/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees +/// clockwise). +pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { + let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap()); + // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt. + let m0 = media(&pages(&doc)[0]); + assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}"); + let img = image_of(&doc, 0); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60))); + // "As stored" stays as it is. + let m1 = media(&pages(&doc)[1]); + assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}"); + let img = image_of(&doc, 1); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); +} diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs similarity index 62% rename from crates/engine/src/upright_image.rs rename to crates/create/src/upright_image.rs index dc2b9cdbfc..9816dfc4b2 100644 --- a/crates/engine/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -1,6 +1,6 @@ //! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have -//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page -//! (stamps, added images, replacements) go through here first. +//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this +//! crate (pages made from images, stamps, added images, replacements) goes through here first. use std::borrow::Cow; use std::io::Cursor; @@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::{Edit, MarkFile}; - - /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 - /// degrees clockwise). - fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { - let mut jpeg = Vec::new(); - let mut encoder = JpegEncoder::new(&mut jpeg); - encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); - encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); - let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); - exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); - let mut out = jpeg[..2].to_vec(); - out.extend_from_slice(&[0xff, 0xe1]); - out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); - out.extend_from_slice(&exif); - out.extend_from_slice(&jpeg[2..]); - out - } + use crate::tests::phone_jpeg; #[test] fn orientation_is_applied_and_plain_images_are_left_alone() { @@ -111,23 +94,4 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } - - #[test] - fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { - let mut session = crate::Session::new(); - let blank = session.create_blank(300.0, 400.0, 1).unwrap(); - let id = session.open_new("form.pdf", blank).unwrap(); - // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. - // Named "Signature" so `signature_image` finds its picture. - let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; - session.apply(id, stamp).unwrap(); - let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; - let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); - let obj = cos.get(picture); - let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); - let rect = pdfcraft_annot::summaries(cos)[0].rect; - assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); - } } diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d83..3ac5468057 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -63,9 +63,15 @@ enum Which { fn selected(doc: &Document, d: &Dict, which: Which) -> bool { let subtype = d.name(b"Subtype").unwrap_or_default(); match which { + // Comments are what the comment list calls comments: links, pop-ups and the non-markup + // multimedia and print-production annotations (3D, Screen, Movie, RichMedia, + // PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087). Which::Comments { comments, fields } => { - let widget = subtype == b"Widget"; - !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments) + if subtype == b"Widget" { + fields + } else { + comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) + } } Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d), } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index fb62125880..62bef8bc5e 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -784,7 +784,16 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("the image move is too large".into())); }; plans.remove(&(stream, index)); - clips.insert((stream, at), c); + // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the + // same CTM as `prev` followed by another `cm c`, without one more operator per move. + let folded = at + .checked_sub(1) + .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?))))) + .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)); + match folded { + Some((i, m)) => clips.insert((stream, i), (m, false)), + None => clips.insert((stream, at), (c, true)), + }; } // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. let mut folds = std::collections::BTreeMap::new(); @@ -799,8 +808,8 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize folds.insert(at, folded); } let changed = crate::text::splice(&pieces, &ops, |index| { - if let Some(c) = clips.remove(&(stream, index)) { - return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); + if let Some((c, keep)) = clips.remove(&(stream, index)) { + return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); } if let Some(m) = folds.remove(&index) { return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 88e3d78065..299356b293 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1921,6 +1921,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() { } } +#[test] +fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { + // The crop is smaller than the image, so a clip left behind would shrink `visible_rect`. + let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"); + let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut first = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + if i == 0 { + first = operators(&doc); + } + } + let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let now = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect); + assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect); + assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one"); + assert_eq!(q_depth(&doc), 1); + let doc = reopen(&doc); + assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap()))); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption, ["Caption"]); +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); @@ -4473,3 +4498,43 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } + +/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup +/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, +/// TrapNet, Watermark), which it used to bake into the page and delete with their data. +/// Flattening fields only leaves them alone too; real comments are still flattened. +#[test] +fn flattening_comments_keeps_annotations_that_are_not_comments() { + let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"]; + let flattened = ["Text", "Square", "FileAttachment"]; + let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect(); + let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream"; + let annots: Vec = all + .iter() + .enumerate() + .map(|(i, s)| { + format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i) + }) + .collect(); + let refs: Vec = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" ")); + let content = stream("", " "); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content]; + objs.extend(annots.iter().map(String::as_str)); + let left = |doc: &Document| -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() }; + a.as_array() + .unwrap() + .iter() + .filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned())) + .collect() + }; + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len()); + assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened"); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget"); + let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); + assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b46d88acf1..3d2ba30820 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Crop pages", "crop", "page.crop", Ready), item("Duplicate pages", "copy-plus", "page.duplicate", Ready), + item("Reverse pages", "arrow-up-down", "page.reverse", Ready), item("Page labels", "tag", "page.number", Ready), item("Page transitions", "presentation", "page.transitions", Planned("M4")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index b28758e241..fcd56efee4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"), c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"), c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"), + c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"), c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 8958688d2d..b797bc8d83 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -6,7 +6,7 @@ //! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it //! off, scripts don't run and AF calls keep working natively. -use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts}; +use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; pub use pdfcraft_js::{Limits, Outcome, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -343,19 +343,24 @@ impl Session { let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); let mut edits = Vec::with_capacity(found.len()); for (page, c, turn) in found { - // A turned field keeps its place: the rectangle is already the one on the page. - let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; - edits.push(Edit::AddField { - page, - rect: c.rect, - kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, - pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, - }, - name: Some(c.name.clone()), - }); - if let Some(props) = props { + let (kind, comb) = match c.kind { + pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None), + pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None), + pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)), + }; + let mut props = FieldProps::default(); + if turn != 0 { + // A turned field keeps its place: the rectangle is already the one on the page. + props.rotation = Some((0, turn)); + props.rect = Some((0, c.rect)); + } + if let Some(cells) = comb { + props.max_len = Some(Some(cells)); + props.flags = vec![(pdfcraft_forms::flags::COMB, true)]; + } + edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) }); + if turn != 0 || comb.is_some() { edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0967f56524..486d497364 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,7 +36,6 @@ pub mod signature_image; pub mod signature_scan; pub mod text_fonts; mod text_preview; -mod upright_image; pub mod word_count; pub mod xfa; @@ -306,6 +305,10 @@ struct Editor { saved: Arc>, /// Passwords that open [`Self::saved`]. saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + unsaved_file: bool, undo: Vec, redo: Vec, keys: Keys, @@ -317,15 +320,15 @@ struct Editor { text_blocks: TextBlocksCache, } -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they -/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long -/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit, -/// undo and save (each reopens the working file), so a stale entry never matches. +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. #[derive(Default)] struct TextBlocksCache(std::sync::Mutex>); /// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc>); +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); impl Clone for TextBlocksCache { fn clone(&self) -> Self { @@ -337,20 +340,16 @@ impl Clone for TextBlocksCache { impl Editor { /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. fn text_blocks(&self, page: usize) -> Result>, EditError> { - let source = (!self.cos.is_modified()).then(|| self.cos.source_identity()); - if let Some(source) = &source - && let Ok(cache) = self.text_blocks.0.lock() - && let Some((s, p, blocks)) = cache.as_ref() - && s == source + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() && *p == page + && state.matches(&self.cos) { return Ok(blocks.clone()); } let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Some(source) = source - && let Ok(mut cache) = self.text_blocks.0.lock() - { - *cache = Some((source, page, blocks.clone())); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); } Ok(blocks) } @@ -998,6 +997,27 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 } +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + /// Render `doc` with the layer visibility in its `info.layers`. fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); @@ -1261,6 +1281,10 @@ pub enum Edit { pages: Vec, to: usize, }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, InsertBlankPage { at: usize, width: f64, @@ -1791,6 +1815,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete pages", "Move page", "Move pages", + "Reverse pages", "Insert blank page", "Duplicate page", "Duplicate pages", @@ -1930,6 +1955,7 @@ impl Edit { Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()), Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), @@ -2102,6 +2128,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Edit::RotatePages { .. } | Edit::DeletePages { .. } | Edit::MovePages { .. } + | Edit::ReversePages { .. } | Edit::InsertBlankPage { .. } | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } @@ -2312,6 +2339,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, Edit::InsertBlankPage { at, width, height } => { pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } @@ -3540,6 +3568,7 @@ impl Session { cos, saved, saved_keys: keys.clone(), + unsaved_file: false, undo: Vec::new(), redo: Vec::new(), keys, @@ -3837,7 +3866,7 @@ impl Session { /// file is clean; undo past a later save is not, because the restored snapshot's own base /// is an earlier revision. fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); } /// Use the current state's password for viewing (protection edits change it). @@ -4122,6 +4151,7 @@ impl Session { pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; editor.saved = bytes; editor.saved_keys = keys; + editor.unsaved_file = false; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); @@ -4185,7 +4215,7 @@ impl Session { editor.undo.clear(); editor.redo.clear(); Self::adopt_keys(doc); - doc.dirty = false; + Self::sync_dirty(doc); doc.generation += 1; Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) } @@ -4346,12 +4376,18 @@ impl Session { }; return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); } - let cos = match doc.editor.as_ref() { + let mut cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } @@ -4659,6 +4695,9 @@ impl Session { let id = self.open(name, None, bytes, None)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } // Unsaved work with no edit yet still needs its first recovery snapshot. d.generation += 1; } @@ -4711,6 +4750,9 @@ impl Session { if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.path = path; d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } d.generation += 1; d.snapshot_generation = d.generation; // its bytes are already in the recovery store } @@ -4920,7 +4962,7 @@ fn embed_image( name: &str, bytes: &[u8], ) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) + pdfcraft_create::image_xobject(doc, name, bytes) } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ec305b1ca5..1c473b7807 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1987,6 +1987,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() { assert_eq!(page_texts(&s, id), ["Page 1"]); } +/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the +/// layers untouched, the document's own configuration prints. +#[test] +fn printing_keeps_hidden_layers_hidden() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Layer A) >>".to_vec(), + b"<< /Type /OCG /Name (Layer B) >>".to_vec(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + // Red and green pixels on the first printed sheet. + let printed = |s: &Session, id: DocId| { + let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + let page = render_page(&out, printed); + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + (red, green) + }; + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green) = printed(&s, id); + assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green"); + let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap(); + assert!(s.set_layer_visible(id, a, false)); + let (red, green) = printed(&s, id); + assert_eq!(red, 0, "Layer A was hidden, so it doesn't print"); + assert!(green > 100, "Layer B still prints: {green} green"); + assert!(s.set_layer_visible(id, a, true)); + let (red, _) = printed(&s, id); + assert!(red > 100, "shown again, Layer A prints again: {red} red"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); @@ -3286,6 +3339,35 @@ fn recognize_text_reads_the_euro_sign() { assert!(text.contains("€250"), "{text}"); } +const SYNTHETIC_SCAN_DPI: f64 = 300.0; + +/// A scan of the OCR sentence at the source page's physical size, without a text layer. +fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) { + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let original = &s.get(id).unwrap().info.pages[0]; + let size = [original.width, original.height]; + // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. + let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap(); + // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge + // the page and change the PDF-point coordinates the placement assertions exercise. + let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap(); + (s.open("scan.pdf", None, scan, None).unwrap(), size) +} + +#[test] +fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, expected) = synthetic_ocr_scan(&mut s); + let page = &s.get(id).unwrap().info.pages[0]; + // Rounding to whole raster pixels may change a page edge by at most one pixel. + let tolerance = 72.0 / SYNTHETIC_SCAN_DPI; + for (actual, expected) in [page.width, page.height].into_iter().zip(expected) { + assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt"); + } + assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { @@ -3293,12 +3375,7 @@ fn recognize_text_makes_a_scanned_page_searchable() { return; } let mut s = Session::new().with_clock(|| 1_700_000_000); - let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); - let id = s.open("text.pdf", None, text, None).unwrap(); - // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap(); - let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); - let id = s.open("scan.pdf", None, scan, None).unwrap(); + let (id, _) = synthetic_ocr_scan(&mut s); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); @@ -3638,6 +3715,34 @@ fn detecting_fields_on_a_scanned_form() { } } +#[test] +fn detecting_comb_boxes_adds_one_comb_field_per_row() { + // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`. + let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18)); + } + for i in 0..10 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap(); + let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect(); + assert_eq!( + found, + [ + ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }), + ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 }) + ] + ); + s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let combs: Vec<(&str, FormFieldKind, bool, Option)> = + d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect(); + assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]); + assert_eq!(d.can_undo(), Some("Detect form fields")); +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -5801,3 +5906,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { // No area, or a box off the image, is refused. assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } + +/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). +fn phone_jpeg() -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); +} + +/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are +/// still read once per document state, and undo and redo never get another state's blocks. +#[test] +fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { + let (mut session, id) = session_with(1); + let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + let before = blocks(&session); + assert_eq!(before[0].editor_text, "Page 1"); + let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() }; + session.apply(id, edit).unwrap(); + let edited = blocks(&session); + assert_eq!(edited[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once"); + session.undo(id).unwrap(); + assert_eq!(blocks(&session)[0].editor_text, "Page 1"); + session.redo(id).unwrap(); + let redone = blocks(&session); + assert_eq!(redone[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&redone, &blocks(&session))); +} + +/// A new document, or one restored from recovery, has never been written where it belongs: undo +/// back to the bytes it was opened from must not make it clean, or closing it would discard the +/// whole document without asking. Saving makes it clean; Revert of a saved one too. +#[test] +fn undo_never_makes_an_unsaved_document_clean() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap(); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved"); + assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery"); + s.revert(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere"); + + let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.mark_recovered(recovered, Some("Report.pdf".into())); + s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(recovered).unwrap(); + assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved"); + + let saved = s.save_bytes(new).unwrap(); + s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap(); + assert!(!s.get(new).unwrap().dirty); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); +} diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0cc456cba6..26685866b3 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,8 +1,8 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact -//! published metrics ([`crate::Std14`]). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded +//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]). //! //! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph //! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own @@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { } } +/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded +/// program's own advances, so measure with them too; a standard-14 guess from the name can be +/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up +/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself +/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { + use skrifa::instance::{LocationRef, Size}; + use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; + for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] { + let Some(object) = descriptor.get(key) else { continue }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { continue }; + let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; + let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + if upem == 0.0 { + continue; + } + let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default()); + let (charmap, cmap) = (face.charmap(), face.cmap().ok()); + let found = |g: Option| g.filter(|g| *g != GlyphId::NOTDEF); + m.widths = (0..=255u32) + .map(|code| { + let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c))) + .or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o)))); + // A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest. + match glyph { + None if m.subset => 0.0, + _ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem), + } + }) + .collect(); + m.first = 0; + return; + } +} + /// Replace an existing cmap directory entry with a bounded Unicode format-12 table. /// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { @@ -607,6 +645,9 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite); + if m.std14.is_some() { + embedded_widths(doc, font, &mut m); + } let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -798,6 +839,11 @@ impl Metrics { fn has_glyph(&self, code: u32) -> bool { if self.composite { let cid = self.cid(code); + // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word + // maps it to a space and gives it a width, and typing with it draws boxes (#3223). + if cid == 0 { + return false; + } // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) @@ -1218,6 +1264,37 @@ mod tests { assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); } + /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name + /// ("Mono" would mean Courier, 600 units for every glyph). + #[test] + fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); + } + #[test] fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; @@ -1384,6 +1461,34 @@ mod tests { assert_eq!(m.encode("Z"), None); } + /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a + /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). + #[test] + fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); + } + /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is /// still in the font, but nothing is when the font gives no default width to point at. #[test] @@ -1682,7 +1787,8 @@ mod tests { assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); } let mut reverse: HashMap = HashMap::new(); - for (&code, text) in &expected { + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { if let Some(c) = only_char(text) { keep(&mut reverse, c, code); } diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index dbee788e3b..d5bf073b1e 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -1,6 +1,6 @@ //! Prepare a form ▸ automatic field detection: find where a printed form expects answers — -//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new -//! field from the label next to it (Acrobat's auto-naming). +//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares — +//! and name each new field from the label next to it (Acrobat's auto-naming). //! //! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads //! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). @@ -30,6 +30,11 @@ pub struct Shapes { pub enum Kind { Text, CheckBox, + /// A row of touching cells, one character each: a text field with the comb flag and + /// `cells` as its character limit. + Comb { + cells: usize, + }, } #[derive(Clone, Debug, PartialEq)] @@ -40,6 +45,10 @@ pub struct Candidate { pub name: String, } +/// The most points a subpath may have and still be read as a rectangle: its four corners plus +/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side). +const MAX_RECT_POINTS: usize = 16; + /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. #[derive(Default)] struct DetectionPath { @@ -65,12 +74,33 @@ impl DetectionPath { return None; } let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; - let mut points = self.points.as_slice(); + let mut points = self.points.clone(); let repeated_end = points.len() > 1 && near(points[0], *points.last()?); if repeated_end { - points = &points[..points.len() - 1]; + points.pop(); } - if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) { + if !(self.closed || implicitly_closed || repeated_end) { + return None; + } + // A point on a straight side, between its neighbours, doesn't change the shape: drop it, + // so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A + // point that turns back along the side is not between its neighbours and stays, so a + // path that doubles back is still not a rectangle. + let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6; + let mut i = 0; + while points.len() > 4 && i < points.len() { + let n = points.len(); + let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break }; + let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]); + let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]); + if on_row || on_column { + points.remove(i); + i = 0; + } else { + i += 1; + } + } + if points.len() != 4 { return None; } let mut horizontal = [false; 4]; @@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let (bx, by) = ctm.apply(x, y); let b = [bx, by]; if !path.complex { - if path.points.len() >= 5 { + if path.points.len() > MAX_RECT_POINTS { path.complex = true; } else { if path.points.is_empty() { @@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) } +/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is +/// enough: dates split into day and month groups of two cells. Cells are square or taller +/// than wide; an empty table of wider cells is not a comb. +fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + let cells: Vec<[f64; 4]> = boxes + .iter() + .copied() + .filter(|r| { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 + }) + .collect(); + // `b` comes right after `a` in a row: same size, its left edge on a's right edge. + let follows = |a: &[f64; 4], b: &[f64; 4]| { + (b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5 + }; + let mut out = Vec::new(); + for first in &cells { + if cells.iter().any(|a| follows(a, first)) { + continue; + } + let (mut last, mut n) = (first, 1usize); + // Each step moves at least 4 pt right, so this ends; the bound is for safety. + while let Some(b) = cells.iter().find(|b| follows(last, b)) + && n < cells.len() + { + last = b; + n += 1; + } + if n >= 2 { + out.push(([first[0], first[1], last[2], first[3]], n)); + } + } + out +} + /// Propose fields for a page. `existing` are the page's widget rectangles (no candidate /// overlaps them); names are unique against `taken`. pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec { @@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S let l = label_for(&labels, rect, false); push(Kind::Text, rect, l, &mut out); } + // Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before + // check boxes so the cells don't each become one. + for (rect, cells) in combs(&shapes.boxes) { + if labels.iter().any(|x| overlaps(x.rect, rect)) { + continue; + } + let l = label_for(&labels, rect, false); + push(Kind::Comb { cells }, rect, l, &mut out); + } // Small squares: check boxes, labelled by the text to their right. for r in &shapes.boxes { let (w, h) = (r[2] - r[0], r[3] - r[1]); @@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S for c in &mut out { let base = if c.name.is_empty() { match c.kind { - Kind::Text => "Text".to_string(), + Kind::Text | Kind::Comb { .. } => "Text".to_string(), Kind::CheckBox => "Check Box".to_string(), } } else { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ef545cd433..60066e4739 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() { assert!(again.is_empty(), "{again:?}"); } +#[test] +fn detection_makes_a_row_of_touching_cells_one_comb_field() { + use crate::detect::*; + let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] }; + let words = vec![ + w("Postcode", 40.0, 205.0, 85.0), + w("A", 88.0, 205.0, 95.0), + w("Postcode", 40.0, 105.0, 85.0), + w("B", 88.0, 105.0, 95.0), + w("Yes", 70.0, 41.0, 90.0), + ]; + let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]); + // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn + // right to left, and a lone square. + let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect(); + boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev()); + boxes.push([50.0, 40.0, 62.0, 52.0]); + let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]); + let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect(); + assert_eq!( + got, + [ + ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]), + ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]), + ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]), + ], + "{found:?}" + ); + // Squares with a gap between them stay check boxes, and so do touching squares of + // different sizes. + let apart = Shapes { + boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]], + rules: Vec::new(), + }; + let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]); + // An empty table of cells wider than tall (an attendance sheet) is not a comb. + let grid = |cw: f64, ch: f64| { + (0..3).flat_map(move |r| { + (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch]) + }) + }; + for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] { + let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]); + assert!(table.is_empty(), "{cw} x {ch}: {table:?}"); + } +} + #[test] fn page_shapes_reads_boxes_and_rules() { let mut doc = fixture(); @@ -2023,6 +2071,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() { } } +/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's +/// letter cells), or on every side, is the rectangle it looks like. A point that turns back +/// along a side is not on the way between its neighbours, so such a path is still not a box. +#[test] +fn page_shapes_reads_rectangles_with_points_along_their_sides() { + for content in [ + "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f", + "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S", + "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f", + ] { + let shapes = detection_test_shapes(content.as_bytes()); + assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}"); + } + for content in [ + // Out past the corner and back: a spike, not a side. + "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S", + // A sixth point off the sides. + "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S", + ] { + assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); + } +} + #[test] fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { for content in [ diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index 8d56fa4aa4..affa4ab163 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -215,7 +215,10 @@ fn num(v: f64) -> String { /// WinAnsiEncoding), each word stretched to its box so selection and search highlight the /// right place. Marked content `/OCR` so it can be told apart from the page's own text. pub fn text_layer(words: &[PlacedWord]) -> Vec { - let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); + // `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they + // would stay set for every later content stream on the page, so text added after OCR (a + // header or footer) drew invisibly (#3029). + let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); for w in words { let width = helvetica_width(&w.text, 1.0); let height = w.up[0].hypot(w.up[1]); @@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec { out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text))); out.extend_from_slice(b" Tj\n"); } - out.extend_from_slice(b"ET\nEMC\n"); + out.extend_from_slice(b"ET\nQ\nEMC\n"); out } @@ -272,6 +275,26 @@ mod tests { assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n")); } + /// #3029: the layer's invisible rendering mode and font end with the layer. Every operator + /// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn + /// after it on the page starts from the graphics state it had before. + #[test] + fn the_layer_restores_the_graphics_state_it_changes() { + let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] }; + let s = String::from_utf8(text_layer(&[w])).unwrap(); + let mut depth = 0_i32; + for token in s.split_ascii_whitespace() { + match token { + "q" => depth += 1, + "Q" => depth -= 1, + "Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"), + _ => {} + } + assert!(depth >= 0, "unbalanced Q in {s:?}"); + } + assert_eq!(depth, 0, "the layer leaves a q open: {s:?}"); + } + /// A word box is tight around the ink, so the text's size and baseline come from the letters: /// capitals, lowercase without ascenders, descenders. #[test] diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 3b2873abec..133bc03ecf 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -472,6 +472,26 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<() rebuild(doc, &rest) } +/// Reverse the order of the pages at `indices` among the positions they occupy; every other +/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change. +pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { + let mut all = walk(doc)?; + check(indices, all.len())?; + let mut slots = indices.to_vec(); + slots.sort_unstable(); + slots.dedup(); + if slots.len() < 2 { + return Err(OrganizeError::Invalid("reversing needs at least two pages".into())); + } + let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect(); + for (slot, page) in slots.iter().zip(pages.into_iter().rev()) { + if let Some(entry) = all.get_mut(*slot) { + *entry = page; + } + } + rebuild(doc, &all) +} + /// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing /// fonts and images with the originals. pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index d34b1e431d..c857cd4da7 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -244,6 +244,25 @@ fn move_pages_reorders() { assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]); } +#[test] +fn reverse_pages_reorders() { + let mut doc = open(fixture()); + reverse_pages(&mut doc, &[0, 1, 2]).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]); + // Unsorted, repeated indices name the same slots; page 1's slot stays put. + reverse_pages(&mut doc, &[2, 1, 2]).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]); +} + +#[test] +fn cannot_reverse_missing_or_fewer_than_two_pages() { + let mut doc = open(fixture()); + assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3))); + assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_)))); + assert!(!doc.is_modified(), "failed operations leave the document untouched"); +} + #[test] fn inserts_blank_page() { let mut doc = open(fixture()); diff --git a/crates/redact/README.md b/crates/redact/README.md index 8abac244a7..7cd5ded1e4 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); -- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a +- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream. diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 5c905610fa..a8dbf64d9b 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -455,6 +455,32 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } let kept: Vec = annots .into_iter() .filter(|a| { diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 18fc65e339..4da8236c22 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -140,14 +140,16 @@ fn address_char(c: char) -> bool { fn email(s: &[char], i: usize) -> Option { let local = |c: &char| address_char(*c) || "._%+-".contains(*c); - if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { + // A match starts at the first character of a run of local characters, which may be + // punctuation ("_admin@host.com"), but the run needs a letter or digit. + if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { return None; } let mut j = i; while j < s.len() && local(&s[j]) { j += 1; } - if s.get(j) != Some(&'@') { + if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) { return None; } let d0 = j + 1; @@ -181,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option { if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) { return None; } - let (end, n) = digit_run(s, i, &[' ', '-'], 23); - if !(13..=19).contains(&n) || digit(s.get(end)) { + // Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits. + let (end, n) = digit_run(s, i, &[' ', '-'], 29); + // A run longer than that is some other, longer number. + if end == i || run_continues(s, end - 1, 1, &[' ', '-']) { return None; } - let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect(); - luhn(&digits).then_some(end - i) + // The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then + // each shorter run ending at a group boundary, longest first. The dropped tail is at most four + // digits, so a longer number (an IBAN's digits) doesn't match on a prefix. + let mut count = n; + for e in (i + 1..=end).rev() { + if n - count > 4 { + break; + } + if !s.get(e - 1).is_some_and(char::is_ascii_digit) { + continue; + } + let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) }; + if boundary && (13..=19).contains(&count) { + let digits: Vec = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect(); + if luhn(&digits) { + return Some(e - i); + } + } + count = count.saturating_sub(1); + } + None } fn ssn(s: &[char], i: usize) -> Option { @@ -401,6 +424,18 @@ mod tests { ); } + #[test] + fn emails_with_a_leading_punctuation_character() { + for lead in ["_", "-", ".", "%", "+"] { + let address = format!("{lead}john@example.com"); + assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}"); + } + // A run of local characters is still matched whole, never from its middle. + assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]); + // Punctuation alone is no local part. + assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::::new()); + } + #[test] fn internationalized_emails() { assert_eq!( @@ -421,6 +456,18 @@ mod tests { ); } + #[test] + fn credit_card_followed_by_another_number() { + for tail in ["12/26", "2026", "123", "12 26"] { + assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}"); + } + assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]); + // A run that is not a card at any group boundary still matches nothing. + assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::::new()); + // A longer grouped number doesn't match on a card-shaped prefix. + assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::::new()); + } + #[test] fn social_security_numbers() { assert_eq!( diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 300aa90b50..a4e3530aba 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a +/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to +/// lie past a mark that covers all of it on screen, and was kept. +#[test] +fn a_whole_line_under_a_mark_goes_even_without_widths() { + let text = b"illicit little lilies"; + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()), + font.as_bytes().to_vec(), + b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(), + stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR), + ]); + // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126. + let line = [5.0, 95.0, 90.0, 112.0]; + mark(&mut doc, 0, &[line], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, text.len(), "every glyph of the line goes"); + assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. @@ -490,6 +514,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared"); } +#[test] +fn replies_to_a_redacted_comment_go_with_it() { + // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of + // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays"); + // A classic table, so the objects' strings are plain in the file. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] { + assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); + } + assert!(bytes.windows(9).any(|w| w == b"UNRELATED")); +} + +/// The replies listed before the annotations they answer, deepest first, and a pop-up on a +/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of +/// #2697). +#[test] +fn replies_listed_before_their_parent_and_their_pop_ups_go_too() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left = annots_of(&doc, &p.dict); + assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too"); +} + #[test] fn comments_links_and_fields_under_a_mark_go() { let mut doc = one_page(b"", "", vec![]); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index b767e28314..6784f14eb0 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu dense } -/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs -/// belongs to the span. -/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs -/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062). +/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Bidi { + L, + R, + Num, + N, +} + +fn bidi_class(text: &str) -> Bidi { + if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) { + Bidi::R + } else if text.chars().any(char::is_numeric) { + Bidi::Num + } else if text.chars().any(char::is_alphabetic) { + Bidi::L + } else { + Bidi::N + } +} + +/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the +/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has +/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals +/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones. fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) { - let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl); + // Glyphs and word gaps (None) in visual order. + let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2); + for (w, &i) in idx.iter().enumerate() { + if w > 0 && spaces.get(w).copied().unwrap_or(false) { + items.push((None, Bidi::N)); + } + items.push((Some(i), bidi_class(&glyphs[i].text))); + } + let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect(); + let text = |k: usize| texts.get(k).copied().unwrap_or(""); + let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count(); + let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L }; + let n = items.len(); + // W4: a single separator between two digits belongs to the number. + for k in 1..n.saturating_sub(1) { + let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}"); + if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num { + items[k].1 = Bidi::Num; + } + } + // W5: terminators (percent, currency) next to a number belong to it. + let term = + |s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}')); + for k in 1..n { + if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + for k in (0..n.saturating_sub(1)).rev() { + if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) { + items[k].1 = Bidi::Num; + } + } + // W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right. + let mut strong = para; + for it in &mut items { + match it.1 { + Bidi::L | Bidi::R => strong = it.1, + Bidi::Num if strong == Bidi::L => it.1 = Bidi::L, + _ => {} + } + } + // N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's. + let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R }; let mut k = 0; - while k < idx.len() { - if rtl(idx[k]) { + while k < n { + if items[k].1 != Bidi::N { + k += 1; + continue; + } + let mut e = k; + while e < n && items[e].1 == Bidi::N { + e += 1; + } + let before = if k == 0 { para } else { dir(items[k - 1].1) }; + let after = if e == n { para } else { dir(items[e].1) }; + let resolved = if before == after { before } else { para }; + for it in &mut items[k..e] { + it.1 = resolved; + } + k = e; + } + // Levels (I1/I2), then L2: reverse every run at or above each level, highest first. + let rtl_para = para == Bidi::R; + let levels: Vec = items + .iter() + .map(|it| match it.1 { + Bidi::R => 1, + Bidi::Num => 2, + _ if rtl_para => 2, + _ => 0, + }) + .collect(); + let mut order: Vec = (0..n).collect(); + for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() { + let mut k = 0; + while k < n { + if levels[order[k]] < level { + k += 1; + continue; + } let mut e = k; - while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) { + while e < n && levels[order[e]] >= level { e += 1; } - idx[k..=e].reverse(); - if let Some(gaps) = spaces.get_mut(k + 1..=e) { - gaps.reverse(); + order[k..e].reverse(); + k = e; + } + } + let mut j = 0; + let mut gap = false; + for &o in &order { + match items[o].0 { + None => gap = true, + Some(i) => { + if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) { + *slot = i; + *space = gap && j > 0; + } + gap = false; + j += 1; } - k = e + 1; - } else { - k += 1; } } } @@ -1293,6 +1401,7 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// /// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8): /// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap. +/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment. /// Segments on the same baseline that nearly touch are merged (text drawn out of order). /// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally. /// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left @@ -1345,7 +1454,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let Some(p) = s.first.end.checked_sub(1) else { return false }; let ph = height(p).min(height(i)); let gap = g[0] - glyphs[p].rect[2]; - (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 + // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by + // more than half its own height, but by less than half the height of the full-size glyph beside it. + let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 }; + (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 }); let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); if cont @@ -1528,6 +1640,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { mod tests { use super::*; + fn em_box(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> TextGlyph { + TextGlyph { text: text.into(), rect: [x0, y0, x1, y1], quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], direction: [1.0, 0.0] } + } + + #[test] + fn subscript_stays_in_its_word() { + // "CO2-E": an 8.1 pt "CO" and "-E", with a 4.7 pt "2" drawn 2.7 pt lower (#3174). + let glyphs = vec![ + em_box("C", 0.0, 92.5, 5.0, 100.6), + em_box("O", 5.0, 92.5, 11.0, 100.6), + em_box("2", 11.0, 97.9, 13.5, 102.6), + em_box("-", 13.5, 92.5, 16.0, 100.6), + em_box("E", 16.0, 92.5, 21.0, 100.6), + ]; + assert_eq!(layout(glyphs).plain_text(), "CO2-E"); + } + #[test] fn identity_v_columns_are_searchable_in_reading_order() { use lopdf::{Document, Object, Stream, dictionary}; @@ -2092,6 +2221,26 @@ mod tests { assert_eq!(layout(v).plain_text(), "שלום עולם"); } + /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words. + #[test] + fn rtl_line_with_number_and_punctuation_reads_in_logical_order() { + let line = |words: &[&str]| { + let mut v = Vec::new(); + let mut x = 10.0; + for w in words { + word(&mut v, w, x, 10.0, 6.0); + x += w.chars().count() as f32 * 6.0 + 6.0; + } + layout(v).plain_text() + }; + // Each line as drawn, left to right. + assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים"); + assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה."); + assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب"); + assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם"); + assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price"); + } + /// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends. #[test] fn words_and_lines_around_a_glyph() { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aa9fe54f66..42e9474f07 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen-futures = "0.4" wasm-bindgen = "0.2" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] pdfcraft-xfa = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5af17b446..855361f6f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -423,6 +423,8 @@ pub struct DocView { /// This frame's consumers, with visible cells ahead of prefetch. thumb_demand: HashMap, frame_queue: Vec, + /// A page-list edit after layout invalidates this frame's scales and page indexes. + frame_geometry_changed: bool, /// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport /// bounds them, and the byte and count limits apply to prefetch only. frame_visible: HashSet, @@ -552,6 +554,9 @@ pub struct DocView { pub pages_panel: Option, /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. pub panel_gap: Option, + /// The current page the Pages panel last followed: it scrolls to the current page's + /// thumbnail only when the page changes. + pub(crate) panel_followed: Option, /// How large the page grid draws its pages (1.0 = the usual thumbnails). grid_zoom: f32, /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid @@ -580,6 +585,8 @@ pub struct DocView { pub(crate) last_scroll: Vec2, /// Side-by-side compare: scroll to here this frame, following the other half. pub(crate) sync_scroll: Option, + /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out. + page_widget: Option, pan_hold_until: f64, /// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan /// settle holds. Fast pages render straight through, with no added latency. @@ -716,6 +723,7 @@ impl DocView { thumbs: HashMap::new(), thumb_demand: HashMap::new(), frame_queue: Vec::new(), + frame_geometry_changed: false, frame_visible: HashSet::new(), stale_thumbs: HashSet::new(), stale_tiles: HashSet::new(), @@ -765,6 +773,7 @@ impl DocView { grid_gap: None, pages_panel: None, panel_gap: None, + panel_followed: None, grid_zoom: 1.0, grid_zoom_request: None, grid_anchor: None, @@ -774,6 +783,7 @@ impl DocView { sharp_hold_until: 0.0, last_scroll: Vec2::ZERO, sync_scroll: None, + page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), comments: Default::default(), @@ -802,6 +812,7 @@ impl DocView { /// adopt the new page geometry, keeping the reader's place where possible. pub fn document_changed(&mut self, info: &DocInfo) { self.invalidate_content(); + self.frame_geometry_changed = true; self.page_count = info.pages.len(); self.page_heights = info.pages.iter().map(|p| p.height).collect(); let last = self.page_count.saturating_sub(1); @@ -1505,6 +1516,13 @@ impl DocView { } } + /// A zoom typed as a percentage ("150", "150%", " 62.5 % ") as a zoom factor; `None` unless it is a + /// finite, positive number. `set_zoom` clamps it to the supported range. + pub fn parse_zoom_percent(typed: &str) -> Option { + let percent = typed.trim().trim_end_matches('%').trim_end().parse::().ok()?; + (percent.is_finite() && percent > 0.0).then_some(percent / 100.0) + } + /// Standard zoom steps (as in Acrobat's zoom menu). pub fn zoom_step(&mut self, up: bool) { const STEPS: [f32; 17] = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0, 16.0, 32.0]; @@ -1517,6 +1535,7 @@ impl DocView { /// Start collecting demand from every consumer before scheduling or uploading results. pub(crate) fn begin_render_frame(&mut self) { + self.frame_geometry_changed = false; self.pages_panel = None; self.panel_gap = None; self.thumb_demand.clear(); @@ -1732,6 +1751,15 @@ impl DocView { } pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) { + if self.frame_geometry_changed { + // Dialogs and edits can replace pages after the canvas collected demand. Its + // scales and indexes belong to the old pages, not to the new renderer (#3149). + self.begin_render_frame(); + self.last_queue.clear(); + pool.set_queue(Vec::new()); + ctx.request_repaint(); + return; + } let queue = self.prepare_render_queue(info, ctx.pixels_per_point()); if queue != self.last_queue { pool.set_queue(queue.clone()); @@ -1798,6 +1826,12 @@ impl DocView { self.errors.insert(r.request.page, e); return 0; } + // Even current demand may have been sized with obsolete geometry or a different + // device limit. Check before cache eviction, conversion and every upload path. + let max_side = ctx.input(|i| i.max_texture_side); + if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side { + return 0; + } // Remember heavy pages: only these get the zoom/pan settle holds, so fast pages // never pay added latency. Stale entries die with the tiles on edits. if r.millis >= SLOW_MILLIS { @@ -2558,6 +2592,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if !std::mem::replace(&mut view.shown, true) { scroll = scroll.scroll_offset(Vec2::ZERO); } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if app.quick_tool == QuickTool::Hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } if let Some(v) = view.sync_scroll.take() { scroll = scroll.scroll_offset(v); } @@ -2703,14 +2756,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { view.selection_drag = false; } - // The Hand tool pans: the content widget takes every drag, so scroll by its delta. + // The Hand tool pans by this widget's drag, before the pages are laid out (above). if hand { if resp.dragged() { - ui.scroll_with_delta(resp.drag_delta()); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); } else if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); @@ -3828,6 +3881,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((p, i)) = app.views[index].comments.default_request.take() { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() { + let doc = app.views[index].id; + app.comment_attachment_action(doc, p, i, open); + } if let Some((name, w)) = field_props { app.open_field_props(&name, w); } @@ -5106,6 +5163,16 @@ mod tests { use super::*; + #[test] + fn typed_zoom_percentages_parse() { + for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] { + assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}"); + } + for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] { + assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}"); + } + } + /// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture /// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still /// fills the area's height; neither covers the checkbox corner or the label below. @@ -5525,6 +5592,81 @@ trailer << /Root 1 0 R >> } } + /// #3149: the replacement dialog commits after the canvas collected this frame's demand. + #[test] + fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 2048); + let mut app = PdfCraftApp::new(); + let original = app.session.create_blank(200.0, 300.0, 1).unwrap(); + let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap(); + app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap(); + let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() }; + app.views[0].frame_queue = vec![request]; + app.views[0].frame_visible.insert(0); + assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] })); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0)); + let edited_bytes = doc.bytes.clone(); + let pool = RenderPool::new_inline(doc.display.clone(), Default::default()); + app.views[0].finish_render_frame(&ctx, &doc.info, &pool); + assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer"); + + // The next layout can render a bounded backdrop and sharp tiles at the same zoom. + let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request }; + let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request }; + for _ in 0..2 { + let view = &mut app.views[0]; + view.begin_render_frame(); + view.frame_visible.insert(0); + view.frame_queue = vec![backdrop, tile]; + view.finish_render_frame(&ctx, &doc.info, &pool); + } + let view = &app.views[0]; + assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]); + assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]); + assert!(view.errors.is_empty()); + assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit"); + assert!(doc.dirty); + assert_eq!(doc.can_undo(), Some("Replace page")); + } + + #[test] + fn oversized_raster_results_do_not_replace_cached_textures() { + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 64); + for request in [ + RenderRequest::default(), + RenderRequest { tag: THUMB_TAG, ..Default::default() }, + RenderRequest { tag: GRID_TAG, ..Default::default() }, + RenderRequest { tag: PRINT_TAG, ..Default::default() }, + RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() }, + ] { + let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default()); + view.last_queue = vec![request]; + let texture = |v: &DocView| { + v.pages + .values() + .map(|p| &p.tex) + .chain(v.thumbs.values().map(|p| &p.tex)) + .chain(v.grid_pages.values().map(|(_, t)| t)) + .chain(v.print_pages.values().map(|(_, t)| t)) + .chain(v.tiles.values()) + .map(|t| (t.id(), t.size())) + .collect::>() + }; + assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4); + let cached = texture(&view); + assert_eq!(cached.len(), 1); + for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] { + assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}"); + assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster"); + } + assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4); + assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress"); + } + } + #[test] fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 84e62622fb..fb9e0a7c0d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -694,10 +694,36 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } - let zoom = format!("{:.0}%", view.zoom * 100.0); - ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { - ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); - }); + let id = egui::Id::new("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .horizontal_align(Align::Center) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); + } + } ui.add_space(6.0); if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { view.step_page(true); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index e3609fcc9c..832ce7cfbd 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -629,6 +629,12 @@ impl PdfCraftApp { "page.duplicate" => { self.apply_edit(Edit::DuplicatePages { pages: targets }); } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); + } "ocr.enhance_camera" => { // The corners start on the document's edges when they can be found, else // inset on the current page (camera_ui::page_input). diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b49a0dce25..68b1a72a0c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -504,6 +504,9 @@ pub struct CommentView { pub props_request: Option<(usize, usize)>, /// Make Current Properties Default was asked for: (page, index). pub default_request: Option<(usize, usize)>, + /// A file attachment comment's file was asked for: (page, index, open it in a new tab + /// rather than save it). + pub attachment_request: Option<(usize, usize, bool)>, /// Replace Text: the struck-out text's quads while the replacement is typed. pub replace: Option>, /// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file. @@ -1595,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's +/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading +/// the file changes nothing, so a read-only document offers them too. +pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) { + let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index }; + let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return }; + if ui.button(tl!("Save attachment…")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, false)); + ui.close(); + } + if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() { + view.comments.attachment_request = Some((a.page, a.index, true)); + ui.close(); + } + ui.separator(); +} + /// Items of the canvas context menu for the selected comment (or the page). pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { let mut action = None; @@ -1602,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo match selected { Some(a) => { let (page, index) = (a.page, a.index); + attachment_items(ui, view, info, a); if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index c5fbbd0ce3..2ac1853f4e 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -255,7 +255,7 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) { + if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) { edit = Some(e); } } @@ -268,6 +268,7 @@ fn card( t: &Tokens, a: &Annotation, thread: &[&Annotation], + info: &DocInfo, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, @@ -447,6 +448,7 @@ fn card( .layout(Layout::top_down_justified(Align::Min)) .show(|ui| { ui.set_min_width(160.0); + crate::comments::attachment_items(ui, view, info, a); if ui.button(tl_ctx!("comment menu", "Edit")).clicked() { view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default())); ui.close(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 742641330c..630d66190c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -793,14 +793,27 @@ struct Limits { max_unauthenticated: usize, /// How long a write may block on a client that never reads its replies. write_timeout: std::time::Duration, + /// How long a connection refused after a reply is kept half-open, discarding input, so the + /// reply is not lost to a reset (see [`close_after_reply`]). + linger: std::time::Duration, + /// How many refused connections may linger at once. A lingering connection has given back + /// its place among [`Self::max_unauthenticated`], so this is what bounds their threads; + /// beyond it a refused connection is closed at once. + max_lingering: usize, } #[cfg(not(target_arch = "wasm32"))] -const LIMITS: Limits = - Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) }; +const LIMITS: Limits = Limits { + auth_deadline: std::time::Duration::from_secs(10), + max_unauthenticated: 8, + write_timeout: std::time::Duration::from_secs(30), + linger: std::time::Duration::from_secs(1), + max_lingering: 8, +}; -/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the -/// connection authenticates, and on every way its thread can end. +/// One of a limited number of places, given back when dropped: a place among +/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused, +/// and on every way its thread can end), or among [`Limits::max_lingering`]. #[cfg(not(target_arch = "wasm32"))] struct Slot(std::sync::Arc); @@ -850,6 +863,38 @@ impl std::io::Read for Reading { } } +/// Close a connection whose last reply has just been written without losing that reply. +/// +/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and +/// the reset can discard the reply before the client reads it (Linux does): a client still +/// sending when it is refused would never learn why. So send FIN first, then read and discard +/// whatever arrives until the client closes its side or `linger` passes, and only then close. +/// +/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places, +/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one +/// the connection is closed at once after the FIN, accepting the risk of a reset rather than +/// holding another thread. +#[cfg(not(target_arch = "wasm32"))] +fn close_after_reply(reading: &mut Reading, place: Option, linger: std::time::Duration) { + use std::io::Read; + let _ = reading.stream.shutdown(std::net::Shutdown::Write); + let Some(_place) = place else { return }; + reading.until = Some(std::time::Instant::now() + linger); + let mut sink = [0u8; 1024]; + // Each read is bounded by the time left, and none is made once `linger` has passed. + while matches!(reading.read(&mut sink), Ok(n) if n > 0) {} +} + +/// A connection is being refused before `auth`: give back its place among those waiting to +/// authenticate, so it does not keep a real client out while it lingers, and take a place to +/// linger in if one is free. Done before the reply is written, so a client that has read the +/// reply sees the places as they will be. +#[cfg(not(target_arch = "wasm32"))] +fn refuse(slot: &mut Option, lingering: &std::sync::Arc, limits: Limits) -> Option { + drop(slot.take()); + Slot::take(lingering, limits.max_lingering) +} + /// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`. /// /// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be @@ -868,6 +913,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result std::io::Result { let secs = limits.auth_deadline.as_secs_f64(); let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } // End of stream or a failed read ends the connection, as before. Ok(LineRead::End) | Err(_) => break, Ok(LineRead::TooLong) => { let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth")); - let _ = writeln!(write, "{e}").and_then(|_| write.flush()); + let place = refuse(&mut slot, &lingering, limits); + if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() { + close_after_reply(reader.get_mut(), place, limits.linger); + } break; } } @@ -945,7 +998,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect(); + for s in &others { + // A refused one has already been closed, so its write may fail; its reply says why. + let _ = writeln!(s.try_clone().unwrap(), "{auth}"); + let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply"); + assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}"); + } + assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed()); + drop(refused); + } + + /// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still + /// gets its reply, and is then closed at once instead of lingering. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn only_so_many_refused_connections_linger() { + use std::io::{BufReader, Write}; + // A linger far longer than the checks below take, even on a loaded machine. + let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; + let (_control, client) = super::attach(&egui::Context::default()); + let ep = super::serve_with(client, limits).unwrap(); + let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; + // The first refused connection takes the only lingering place and keeps it. + let first = connect(ep.port); + writeln!(first.try_clone().unwrap(), "{bad}").unwrap(); + let mut r1 = BufReader::new(first.try_clone().unwrap()); + assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001); + // The second still gets its reason (it has nothing unread, so no reset)... + let second = connect(ep.port); + let mut w2 = second.try_clone().unwrap(); + writeln!(w2, "{bad}").unwrap(); + let mut r2 = BufReader::new(second.try_clone().unwrap()); + assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001); + // ...and is closed outright, not lingering: writing to it soon fails, where a lingering + // connection would read and discard for the whole of `linger`. + let started = std::time::Instant::now(); + let closed = loop { + if w2.write_all(b"x").is_err() { + break true; + } + if started.elapsed() > limits.linger { + break false; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed()); + drop(first); + } + /// #1806: only so many connections may wait to authenticate at once. One more is refused /// on the spot; once a waiting one authenticates or times out, its place is free again. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47d..66ad98ffe6 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1329,7 +1329,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - ed.max_width = ed.rect.width(); let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01); ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0); - let font = editor_font(&ed.look, ed.size); + // egui draws the caret at least one row of this font tall. Each row already has its source + // row's height, so the font stays below every row (half the region's smallest size): on a + // 12 pt row under a 24 pt title the caret is as tall as the row, not the title. + let smallest = + ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min)); + let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size))); let text_color = color32(ed.look.color); let mut done = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( @@ -1475,6 +1480,21 @@ mod tests { (app, ed) } + #[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 + // face until the font was found again in the document-font menu (#2591). + let (mut app, ed) = preview_app(); + app.views[0].line_editor = Some(ed); + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec(); + app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]); + let doc = app.session.get(app.views[0].id).unwrap(); + let lines = doc.text_lines(0); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text"); + assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 8b63c49fe9..0e5307f0e9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -24,6 +24,7 @@ pub enum CloseRequest { pub struct PendingOverwrite { pub id: pdfcraft_engine::DocId, pub dest: String, + target: SaveTarget, /// What was to run once the document is written (close its tab, quit). after: Box, } @@ -621,12 +622,12 @@ impl PdfCraftApp { let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { - app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after)); + app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; } }; - self.save_doc_to_then(id, &destination, Box::new(after)) + self.save_doc_to_then(id, &destination, target, Box::new(after)) } #[cfg(target_arch = "wasm32")] { @@ -681,14 +682,14 @@ impl PdfCraftApp { /// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged). /// Returns `true` if written now. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box) -> bool { + fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool { let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest)); if own && self.watch.disk_differs(id, dest) { - self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after }); + self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after }); self.dialog = Some(crate::Dialog::OverwriteChanged); return false; } - let saved = self.save_doc_to(id, dest); + let saved = self.save_doc_to(id, dest, target); if saved { after(self); } @@ -699,7 +700,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn overwrite_changed_file(&mut self) { let Some(p) = self.pending_overwrite.take() else { return }; - if self.save_doc_to(p.id, &p.dest) { + if self.save_doc_to(p.id, &p.dest, p.target) { (p.after)(self); } } @@ -715,17 +716,27 @@ impl PdfCraftApp { /// Write document `id` to `dest` and make it the document's file. Returns `true` if written. #[cfg(not(target_arch = "wasm32"))] - fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool { + fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool { // Save As answers on a later frame: commit what was typed meanwhile, too (#166), even // if another tab is active by then. if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } - let Some(name) = self.session.get(id).map(|d| d.name.clone()) else { + let Some((name, full)) = self.session.get(id).map(|d| { + // Save As to another file is a clean copy, without the earlier revisions that still + // hold deleted pages or replaced text (#2165). A signed document stays incremental so + // its signatures keep covering the file. + let full = target == SaveTarget::As + && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) + && d.read_only_reason.is_none() + && !d.is_signed(); + (d.name.clone(), full) + }) else { self.notify_tr("The document was closed before it could be saved."); return false; }; - let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) { + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }; + let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) { Ok(b) => b, Err(e) => { self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index f5e9e0319b..03c773807c 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -476,13 +476,26 @@ impl PdfCraftApp { match purpose { FilePurpose::TextFont { page } => { let Some((_, bytes)) = files.into_iter().next() else { return }; - if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) - && let Some((index, id)) = self.active_ids() - && let Some(doc) = self.session.get(id) - && let Some(ed) = self.views[index].line_editor.as_mut() - && let Some(ctx) = self.ctx.as_ref() + let bytes = Arc::new(bytes); + if !self.apply_edit(Edit::RegisterTextFont { page, bytes: bytes.clone() }) { + return; + } + let Some((index, id)) = self.active_ids() else { return }; + let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return }; + // The font just loaded is the one the user wants: apply it to the paragraph, as + // choosing it from the document-font menu does, instead of only registering it (#2591). + let resource = self.session.get(id).and_then(|doc| { + doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource) + }); + let ctx = self.ctx.clone(); + if resource.is_some() { + ed.font_resource = resource; + self.apply_line_format(index, ed, ctx.as_ref()); + } else if let Some(doc) = self.session.get(id) + && let Some(ctx) = ctx { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page); + self.views[index].line_editor = Some(ed); } } FilePurpose::Combine => self.stage_combine(files), diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 7ee5a83922..14b97fc08f 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -162,6 +162,7 @@ Next page الصفحة التالية Previous page الصفحة السابقة Current page — type a page number or label (such as iv) and press Enter الصفحة الحالية — اكتب رقم صفحة أو تسميتها (مثل iv) ثم اضغط Enter + Zoom — type a percentage and press Enter التكبير — اكتب نسبة مئوية واضغط Enter Welcome to PdfCraft مرحبًا بك في PdfCraft An open-source PDF workbench — local, private, and scriptable. منضدة عمل مفتوحة المصدر لملفات PDF — محلية وخاصة وقابلة للبرمجة. Join the ArtCraft community انضم إلى مجتمع ArtCraft diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f586b9e70..c39e651cf3 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1129,6 +1129,7 @@ Next page Следваща страница Previous page Предишна страница Current page — type a page number or label (such as iv) and press Enter Текуща страница — въведете номер или етикет на страница (например iv) и натиснете Enter + Zoom — type a percentage and press Enter Мащаб — въведете процент и натиснете Enter Open a PDF ({key}) Отвори PDF файл ({key}) Dark gray theme Тъмносива тема Light theme Светла тема diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a6aa7a55b4..cbe2677ee7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -279,6 +279,7 @@ key Space Mezerník Next page Další stránka Previous page Předchozí stránka Current page — type a page number or label (such as iv) and press Enter Aktuální stránka — zadejte číslo nebo označení stránky (třeba iv) a stiskněte Enter + Zoom — type a percentage and press Enter Lupa — zadejte procenta a stiskněte Enter Welcome to PdfCraft Vítá vás PdfCraft An open-source PDF workbench — local, private, and scriptable. Open-source dílna pro PDF — lokální, soukromá a skriptovatelná. Join the ArtCraft community Připojte se ke komunitě ArtCraft diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cc86a0bfe0..ed19af960e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Next page Nächste Seite Previous page Vorherige Seite Current page — type a page number or label (such as iv) and press Enter Aktuelle Seite – gib eine Seitenzahl oder -beschriftung ein (z. B. iv) und drücke die Eingabetaste + Zoom — type a percentage and press Enter Zoom – gib einen Prozentwert ein und drücke die Eingabetaste Welcome to PdfCraft Willkommen bei PdfCraft An open-source PDF workbench — local, private, and scriptable. Eine Open-Source-PDF-Werkbank – lokal, privat und skriptfähig. Join the ArtCraft community Tritt der ArtCraft-Community bei diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index df2fffc5bc..7953a42aa8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -282,6 +282,7 @@ Flatten Fill & Sign when saving Ενσωμάτωση συμπληρώσεων και υπογραφών κατά την αποθήκευση Text, marks and signatures become part of the page. Other comments stay editable. Το κείμενο, τα σημάδια και οι υπογραφές γίνονται μέρος της σελίδας. Τα υπόλοιπα σχόλια παραμένουν επεξεργάσιμα. Current page — type a page number or label (such as iv) and press Enter Τρέχουσα σελίδα — πληκτρολογήστε αριθμό ή ετικέτα σελίδας (π.χ. iv) και πατήστε Enter + Zoom — type a percentage and press Enter Ζουμ — πληκτρολογήστε ένα ποσοστό και πατήστε Enter Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Τα αρχεία που ανοίγετε στο PdfCraft εμφανίζονται εδώ. Σύρετε ένα PDF οπουδήποτε για να το ανοίξετε. Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Τα σενάρια εκτελούνται σε απομονωμένο περιβάλλον χωρίς πρόσβαση σε αρχεία ή δίκτυο. Με απενεργοποιημένη τη JavaScript, οι τυπικές λειτουργίες μορφοποίησης, επικύρωσης και υπολογισμού του Acrobat εξακολουθούν να λειτουργούν. Move up Μετακίνηση πάνω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5333de3883..3ff1e107b0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -158,6 +158,7 @@ Next page Página siguiente Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página actual: escribe un número o una etiqueta de página (por ejemplo, iv) y pulsa Intro + Zoom — type a percentage and press Enter Zoom: escribe un porcentaje y pulsa Intro Welcome to PdfCraft Te damos la bienvenida a PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco de trabajo PDF de código abierto: local, privado y automatizable. Join the ArtCraft community Únete a la comunidad de ArtCraft diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba5c90a7b1..582c80fa12 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -135,6 +135,7 @@ Next page Page suivante Previous page Page précédente Current page — type a page number or label (such as iv) and press Enter Page actuelle — saisissez un numéro ou une étiquette de page (comme iv) et appuyez sur Entrée + Zoom — type a percentage and press Enter Zoom — saisissez un pourcentage et appuyez sur Entrée Welcome to PdfCraft Bienvenue dans PdfCraft An open-source PDF workbench — local, private, and scriptable. Un atelier PDF open source — local, privé et automatisable. Join the ArtCraft community Rejoindre la communauté ArtCraft diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cdf194d1bb..b14d13fd1f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -133,6 +133,7 @@ Next page Következő oldal Previous page Előző oldal Current page — type a page number or label (such as iv) and press Enter Aktuális oldal — írj be egy oldalszámot vagy címkét (például iv), majd nyomd meg az Entert + Zoom — type a percentage and press Enter Nagyítás – írjon be egy százalékértéket, és nyomja meg az Entert Welcome to PdfCraft Üdvözöljük a PdfCraftban An open-source PDF workbench — local, private, and scriptable. Nyílt forráskódú PDF-munkapad — helyi, privát és parancsfájlokkal vezérelhető. Join the ArtCraft community Csatlakozz az ArtCraft közösséghez diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f962e36c8b..2fb364c154 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -159,6 +159,7 @@ Next page Pagina successiva Previous page Pagina precedente Current page — type a page number or label (such as iv) and press Enter Pagina corrente: digita un numero o un'etichetta di pagina (ad esempio iv) e premi Invio + Zoom — type a percentage and press Enter Zoom: digita una percentuale e premi Invio Welcome to PdfCraft Benvenuto in PdfCraft An open-source PDF workbench — local, private, and scriptable. Un banco di lavoro PDF open source: locale, riservato e programmabile. Join the ArtCraft community Unisciti alla community di ArtCraft diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2f0cfc81fa..3e0ea82914 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -162,6 +162,7 @@ Next page 次のページ Previous page 前のページ Current page — type a page number or label (such as iv) and press Enter 現在のページ:ページ番号またはラベル(例:iv)を入力して Enter + Zoom — type a percentage and press Enter ズーム — パーセンテージを入力して Enter キーを押します Welcome to PdfCraft PdfCraft へようこそ An open-source PDF workbench — local, private, and scriptable. ローカルで処理し、プライバシーを守る、スクリプト対応のオープンソース PDF ツール。 Join the ArtCraft community ArtCraft コミュニティに参加 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index a56de41f8d..96bfb9fbbd 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -123,6 +123,7 @@ Next page Келесі бет Previous page Алдыңғы бет Current page — type a page number or label (such as iv) and press Enter Ағымдағы бет — бет нөмірін немесе белгісін (мысалы, iv) теріңіз де, Enter пернесін басыңыз + Zoom — type a percentage and press Enter Масштаб — пайызды енгізіп, Enter пернесін басыңыз Welcome to PdfCraft PdfCraft-ге қош келдіңіз An open-source PDF workbench — local, private, and scriptable. Ашық бастапқы кодты PDF жұмыс орны — жергілікті, жеке әрі скрипттерді қолдайтын. Join the ArtCraft community ArtCraft қауымдастығына қосылыңыз diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 6e5541c011..96569cdeb0 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -431,6 +431,7 @@ Current page Dabartinis puslapis Current page ({n}) Dabartinis puslapis ({n}) Current page — type a page number or label (such as iv) and press Enter Dabartinis puslapis — įveskite puslapio numerį arba žymą (pvz., iv) ir paspauskite „Enter“ + Zoom — type a percentage and press Enter Mastelis – įveskite procentus ir paspauskite Enter Custom Pasirinktinis Custom scale: Pasirinktinis mastelis: Custom text: Pasirinktinis tekstas: diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 5bb2889c9d..20c0fd0467 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -390,9 +390,23 @@ fn first_supported(list: &str) -> Option { list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag) } +/// The first supported language among BCP 47 tags in preference order, as the browser lists them +/// in `navigator.languages` (#2954). Only the first few tags are looked at: the list is short in +/// practice, and a page can't make Auto walk an unbounded one. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn first_supported_tag>(tags: impl IntoIterator) -> Option { + tags.into_iter().take(32).find_map(|tag| lang_from_tag(tag.as_ref().trim())) +} + +/// The browser's preferred languages, as the desktop builds use the system's: `navigator.languages` +/// in order, then `navigator.language` (kept within the 32 tags `first_supported_tag` reads). +/// English when the browser offers none we have a catalog for. #[cfg(target_arch = "wasm32")] fn detect_system_lang() -> Lang { - Lang::EN + let Some(navigator) = web_sys::window().map(|w| w.navigator()) else { return Lang::EN }; + let mut tags: Vec = navigator.languages().iter().take(31).filter_map(|v| v.as_string()).collect(); + tags.extend(navigator.language()); + first_supported_tag(&tags).unwrap_or(Lang::EN) } thread_local! { @@ -411,7 +425,38 @@ pub fn set_current(lang: Lang) { /// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages /// that capitalise nouns (German), where lowercasing would misspell it. pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> { - if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) } + match current().code() { + "de" => std::borrow::Cow::Borrowed(label), + "tr" => std::borrow::Cow::Owned(label.chars().flat_map(lower_turkish).collect()), + _ => std::borrow::Cow::Owned(label.to_lowercase()), + } +} + +/// The translation of `english` in upper case, for section titles. Rust's case mapping knows no +/// language, and Turkish has a dotted and a dotless i (`i` ↔ `İ`, `ı` ↔ `I`): "Gelişmiş" must +/// become "GELİŞMİŞ", not "GELIŞMIŞ". A label without a Turkish row is still English and keeps +/// the English rule. +pub fn upper_label(english: &str) -> String { + let lang = current(); + let shown = tr(lang, english); + if lang.code() == "tr" && has(lang, english) { shown.chars().flat_map(upper_turkish).collect() } else { shown.to_uppercase() } +} + +fn upper_turkish(c: char) -> std::char::ToUppercase { + match c { + 'i' => 'İ'.to_uppercase(), + 'ı' => 'I'.to_uppercase(), + c => c.to_uppercase(), + } +} + +fn lower_turkish(c: char) -> std::char::ToLowercase { + match c { + // `İ`.to_lowercase() is "i" plus a combining dot above. + 'İ' => 'i'.to_lowercase(), + 'I' => 'ı'.to_lowercase(), + c => c.to_lowercase(), + } } pub fn current() -> Lang { @@ -619,6 +664,21 @@ mod tests { assert_eq!(first_supported("("), None); } + /// #2954: the web build's Auto language follows the browser's preferred languages in order, + /// skipping ones without a catalog, and is English only when none is supported. + #[test] + fn browser_language_list_is_followed_in_order() { + assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it")); + assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA())); + assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant")); + assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN)); + assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br")); + assert_eq!(first_supported_tag(["", "xx"]), None); + assert_eq!(first_supported_tag(Vec::::new()), None); + let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]); + assert_eq!(first_supported_tag(long), None, "only the first tags are read"); + } + #[test] fn windows_display_language_resolves_chinese_scripts() { for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { @@ -1961,6 +2021,20 @@ mod tests { set_current(Lang::EN); } + #[test] + fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() { + set_current(Lang::from_code("tr").expect("tr registered")); + assert_eq!(upper_label("Advanced"), "GELİŞMİŞ"); + assert_eq!(upper_label("Signatures"), "İMZALAR"); + // A label with no Turkish row is still English and keeps the English rule. + assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE"); + assert_eq!(in_sentence("İmza Alanı"), "imza alanı"); + assert_eq!(in_sentence("Işık"), "ışık"); + set_current(Lang::EN); + assert_eq!(upper_label("Advanced"), "ADVANCED"); + assert_eq!(in_sentence("Image"), "image"); + } + #[test] fn german_certificate_subject_has_a_distinct_context() { let de = Lang::from_code("de").expect("de registered"); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6c9b6c2585..c1b3759c7f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -158,6 +158,7 @@ Next page Następna strona Previous page Poprzednia strona Current page — type a page number or label (such as iv) and press Enter Bieżąca strona — wpisz numer lub etykietę strony (np. iv) i naciśnij Enter + Zoom — type a percentage and press Enter Powiększenie — wpisz wartość procentową i naciśnij Enter Welcome to PdfCraft Witamy w PdfCraft An open-source PDF workbench — local, private, and scriptable. Otwarty zestaw narzędzi do PDF — lokalny, prywatny i obsługujący skrypty. Join the ArtCraft community Dołącz do społeczności ArtCraft diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1499adf9c9..4eb752e17a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1073,6 +1073,7 @@ Next page Próxima página Previous page Página anterior Current page — type a page number or label (such as iv) and press Enter Página atual — digite o número da página ou rótulo (como iv) e pressione Enter + Zoom — type a percentage and press Enter Zoom — digite uma porcentagem e pressione Enter Open a PDF ({key}) Abrir um PDF ({key}) Dark gray theme Tema cinza escuro Light theme Tema claro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 44d9150558..caefe5fd1d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -155,6 +155,7 @@ Next page Следующая страница Previous page Предыдущая страница Current page — type a page number or label (such as iv) and press Enter Текущая страница — введите номер или метку страницы (например, iv) и нажмите Enter + Zoom — type a percentage and press Enter Масштаб — введите процент и нажмите Enter Welcome to PdfCraft Добро пожаловать в PdfCraft An open-source PDF workbench — local, private, and scriptable. Открытый PDF-редактор — локальный, приватный и программируемый. Join the ArtCraft community Присоединяйтесь к сообществу ArtCraft diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 5ed15c71ca..99b7ce7389 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -519,6 +519,7 @@ Current page Trenutna stran Current page ({n}) Trenutna stran ({n}) Current page — type a page number or label (such as iv) and press Enter Trenutna stran — vnesite številko ali oznako strani (npr. iv) in pritisnite Enter + Zoom — type a percentage and press Enter Povečava – vnesite odstotek in pritisnite Enter Custom Po meri Custom scale: Merilo po meri: Custom stamps arrive on the web with file pickers for images Lastni žigi bodo v spletni različici na voljo z izbirniki slikovnih datotek diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index be5764bd13..f42b797042 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -139,6 +139,7 @@ comment menu Edit సవరించు Next page తదుపరి పేజీ Previous page మునుపటి పేజీ Current page — type a page number or label (such as iv) and press Enter ప్రస్తుత పేజీ — పేజీ సంఖ్యను లేదా లేబుల్‌ను (iv వంటిది) టైప్ చేసి Enter నొక్కండి + Zoom — type a percentage and press Enter జూమ్ — శాతాన్ని టైప్ చేసి Enter నొక్కండి Welcome to PdfCraft PdfCraftకు స్వాగతం An open-source PDF workbench — local, private, and scriptable. ఓపెన్-సోర్స్ PDF వర్క్‌బెంచ్ — మీ కంప్యూటర్‌లోనే నడుస్తుంది, గోప్యంగా ఉంటుంది, స్క్రిప్ట్‌లతో ఆటోమేట్ చేయవచ్చు. Join the ArtCraft community ArtCraft కమ్యూనిటీలో చేరండి diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7feb0cd18b..11fd5dda40 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -120,6 +120,7 @@ Next page Sonraki sayfa Previous page Önceki sayfa Current page — type a page number or label (such as iv) and press Enter Geçerli sayfa — bir sayfa numarası veya etiketi (örneğin iv) yazıp Enter'a basın + Zoom — type a percentage and press Enter Yakınlaştırma — bir yüzde yazıp Enter tuşuna basın Welcome to PdfCraft PdfCraft'a Hoş Geldiniz An open-source PDF workbench — local, private, and scriptable. Açık kaynaklı bir PDF çalışma alanı — yerel, gizli ve komut dosyalarıyla yönetilebilir. Join the ArtCraft community ArtCraft topluluğuna katılın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 00114adf94..42763353fc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -160,6 +160,7 @@ Next page Наступна сторінка Previous page Попередня сторінка Current page — type a page number or label (such as iv) and press Enter Поточна сторінка — введіть номер або позначку сторінки (наприклад iv) і натисніть Enter + Zoom — type a percentage and press Enter Масштаб — введіть відсоток і натисніть Enter Welcome to PdfCraft Вітаємо в PdfCraft An open-source PDF workbench — local, private, and scriptable. Відкритий інструментарій для PDF — локальний, приватний і з підтримкою сценаріїв. Join the ArtCraft community Приєднатися до спільноти ArtCraft diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0f08139d47..8555b43581 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -15,6 +15,7 @@ Zoom Thu phóng Actual size Kích thước thực Zoom to page level Thu phóng vừa trang + Zoom — type a percentage and press Enter Thu phóng — nhập tỷ lệ phần trăm rồi nhấn Enter Fit to width Vừa chiều rộng Display theme Chủ đề hiển thị Side panels Bảng bên diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eedf99d651..093ec5d04e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1136,6 +1136,7 @@ Next page 下一页 Previous page 上一页 Current page — type a page number or label (such as iv) and press Enter 当前页 — 输入页码或标签(如 iv)后按 Enter + Zoom — type a percentage and press Enter 缩放 — 输入百分比并按 Enter Open a PDF ({key}) 打开 PDF ({key}) Dark gray theme 深灰色主题 Light theme 浅色主题 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e71244983c..ff3d03ce3d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -109,7 +109,7 @@ Sanitize 清理 Mark all 全部標記 Save changes to “{name}” before closing? 在關閉前儲存對「{name}」的更改? - Your changes will be lost if you don't save them. 如果不儲存,更改將會丟失。 + Your changes will be lost if you don't save them. 如果不儲存,更改將會遺失。 Password required 需要密碼 “{name}” is protected. Enter a password to open it. 「{name}」受保護。請輸入密碼以開啟。 Password 密碼 @@ -190,7 +190,7 @@ Discard User Data 捨棄使用者資料 Clean Up 清理 Color Images 彩色圖片 - Grayscale Images 灰度圖片 + Grayscale Images 灰階圖片 Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 @@ -303,8 +303,8 @@ Negative number style: 負數樣式: Format: 格式: Custom: 自訂: - Zip Code 郵政編碼 - Zip Code + 4 郵政編碼 + 4 + Zip Code 郵遞區號 + Zip Code + 4 郵遞區號 + 4 Phone Number 電話號碼 Social Security Number 社會安全號碼 Arbitrary Mask 任意掩碼 @@ -509,7 +509,7 @@ Continue 繼續 Configure a Digital ID for Signing 設定簽署用的數位 ID Use a Digital ID from a file 使用檔案中的數位 ID - Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自簽名,儲存到受密碼保護的檔案) + Create a new Digital ID (self-signed, saved to a password-protected file) 建立新數位 ID(自我簽署,儲存到受密碼保護的檔案) Name 名稱 Organizational Unit 組織單位 Organization Name 組織名稱 @@ -517,7 +517,7 @@ Country/Region 國家/地區 Key Algorithm 金鑰演算法 Confirm Password 確認密碼 - Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自簽名 ID 通常不受他人信任。 + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. 有效期 5 年,用於數位簽章。自我簽署 ID 通常不受他人信任。 File (.p12, .pfx) 檔案 (.p12, .pfx) Digital ID 數位 ID Browse… 瀏覽… @@ -572,7 +572,7 @@ Public key 公鑰 Key usage 金鑰用途 Not present 不存在 - Self-signed 自簽名 + Self-signed 自我簽署 Edit Action 編輯操作 New Action 新建操作 Action name: 操作名稱: @@ -778,9 +778,9 @@ Social Security Numbers 社會安全號碼 Dates 日期 Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。 - No matches. 無匹配。 + No matches. 無相符。 Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此處未顯示部分書籤:面板列出的書籤數量和巢狀深度有限。 - {n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。 + {n} match(es) marked for redaction. 已標記 {n} 個相符以待遮蔽內容。 Multiple words or phrases 多個單字或片語 One word or phrase per line 每行一個單字或片語 Import list… 匯入清單… @@ -821,7 +821,7 @@ {name} (default) {name}(預設) Copies: 份數: Collate 逐份列印 - Print in grayscale 灰度列印 + Print in grayscale 灰階列印 Two-sided: 雙面: Off 關 Flip on long edge 沿長邊翻轉 @@ -1080,6 +1080,7 @@ Next page 下一頁 Previous page 上一頁 Current page — type a page number or label (such as iv) and press Enter 目前頁面:輸入頁碼或頁面標籤(例如 iv),再按 Enter + Zoom — type a percentage and press Enter 縮放 — 輸入百分比並按 Enter Open a PDF ({key}) 開啟 PDF({key}) Dark gray theme 深灰色主題 Light theme 淺色主題 @@ -1180,7 +1181,7 @@ Add a comment 新增註解 No comments yet. 尚無註解。 Pick a tool in the quick bar, or type above. 在快捷欄中選擇工具,或在上方輸入。 - No comments match. 沒有匹配的註解。 + No comments match. 沒有相符的註解。 Unknown author 未知作者 No colour 無顏色 Remove checkmark 刪除勾選標記 @@ -1237,13 +1238,13 @@ Open in a new tab 在新索引標籤中開啟 This document has no pages. 本文件沒有頁面。 Find text 尋找文字 - No matches 無匹配 + No matches 無相符 Searching… {s}/{p} 正在搜尋… {s}/{p} {c} of {n}{more} {c} / {n}{more} Previous ({key}) 上一個 ({key}) Next ({key}) 下一個 ({key}) Find options 尋找選項 - Whole words only 僅全字匹配 + Whole words only 僅全字相符 Case-sensitive 區分大小寫 Close (Esc) 關閉 (Esc) Align 對齊 @@ -1308,7 +1309,7 @@ · password-protected · 受密碼保護 Recover 恢復 Discard 放棄 - Next / previous match 下一個/上一個符合項目 + Next / previous match 下一個/上一個相符項目 Copy selected text 複製所選文字 Select a word 選取一個詞 Clear selection / close find 清除選取/關閉尋找 @@ -1624,7 +1625,7 @@ the name is not a plain file name 名稱不是單純的檔案名稱 Wrote 1 file to {dir} 已將 1 個檔案寫入{dir} Wrote {n} files to {dir} 已將{n}個檔案寫入{dir} - No pages match those choices 所選條件沒有匹配的頁面 + No pages match those choices 所選條件沒有相符的頁面 Couldn't split: {e} 無法拆分:{e} Couldn't summarize comments: {e} 無法彙總註解:{e} Couldn't open the result: {e} 無法開啟結果:{e} @@ -1699,8 +1700,8 @@ What word or phrase would you like to search for? 您想搜尋哪個詞或短語? Searching… {s} of {p} pages 正在搜尋… {s}/{p} 頁 (searching… {s} of {p} pages) (正在搜尋… {s}/{p} 頁) - 1 instance 1 個匹配 - {n} instances {n} 個匹配 + 1 instance 1 個相符 + {n} instances {n} 個相符 Go to this result 轉到此結果 Highlight 突出顯示 Draw 繪製 @@ -1749,7 +1750,7 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… - No matching tools 找不到符合的工具 + No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 Convert 轉換 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7df5d6706..725bcad4b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1541,6 +1541,15 @@ impl PdfCraftApp { } } + /// [`Self::attachment_action`] for the file of the attachment comment at (page, index). + pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) { + let source = pdfcraft_render::AttachmentSource::Annotation { page, index }; + // The menus offer the items only for a listed file, so a miss is a comment gone since. + if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) { + self.attachment_action(doc, i, open); + } + } + /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves /// the frame to the app and Use system title bar is off. pub fn draws_window_frame(&self) -> bool { @@ -2556,7 +2565,7 @@ impl PdfCraftApp { self.palette_query = value.to_string(); } ("page", Some(v)) => v.go_to_page(value.parse::().map_err(|e| e.to_string())?.saturating_sub(1)), - ("zoom", Some(v)) => v.set_zoom(value.trim_end_matches('%').parse::().map_err(|e| e.to_string())? / 100.0), + ("zoom", Some(v)) => v.set_zoom(DocView::parse_zoom_percent(value).ok_or("zoom must be a percentage above 0")?), ("layout", Some(v)) => { let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?; v.set_display(layout, continuous); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 2007dc0afa..0f2c601497 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -418,17 +418,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { - let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; - if app.apply_edit(edit) { - ed.applied(); - if let Some(doc) = app.session.get(app.views[i].id) - && let Some(block) = doc.text_blocks(ed.page).get(ed.block) - { - ed.refresh_source(block, doc.text_lines(ed.page)); - ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); - } - } - app.views[i].line_editor = Some(ed); + app.apply_line_format(i, ed, Some(ui.ctx())); } ui.add_space(6.0); ui.separator(); @@ -482,6 +472,25 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.separator(); } +impl PdfCraftApp { + /// Apply the open paragraph's formatting as one change; the paragraph stays open. + pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { + let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if self.apply_edit(edit) { + ed.applied(); + if let Some(doc) = self.session.get(self.views[index].id) + && let Some(block) = doc.text_blocks(ed.page).get(ed.block) + { + ed.refresh_source(block, doc.text_lines(ed.page)); + if let Some(ctx) = ctx { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page); + } + } + } + self.views[index].line_editor = Some(ed); + } +} + // ───────────────────────────────────────────────────────────────────────────── right panels pub(crate) enum Nav { @@ -791,6 +800,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) { app.make_comment_default(p, i); } + if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) { + app.comment_attachment_action(id, p, i, open); + } if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) { app.open_comment_props(p, i); } @@ -1052,6 +1064,11 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie { delete_pages(view, info.pages.len()); } + // A new current page scrolls its thumbnail into view, as little as it takes (Acrobat), but + // not while thumbnails are dragged: then the change counts as seen. A wheel turned or a + // scroll bar dragged by the user already wins over a scroll to a place in egui. + let follow = view.panel_followed != Some(view.current) && view.panel_drag.is_none(); + view.panel_followed = Some(view.current); let w = (ui.available_width() - 40.0).min(150.0); let count = info.pages.len(); let mut rows: Vec = Vec::with_capacity(info.pages.len()); @@ -1096,7 +1113,14 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { t.accent_text } else { t.text_muted })); + let label = ui.label(egui::RichText::new(&p.label).font(theme::medium(12.0)).color(if selected || picked { + t.accent_text + } else { + t.text_muted + })); + if follow && selected { + ui.scroll_to_rect(rect.union(label.rect), None); + } let resp = theme::hand(resp); if resp.clicked() { let m = ui.input(|i| i.modifiers); @@ -1104,6 +1128,8 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.click_page(i, m, true); } else { view.clear_page_selection(Some(i)); + // A page clicked here is in view already: the panel stays put. + view.panel_followed = Some(i); *nav = Some(Nav::Page(i)); } } diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index 4e0f065885..f4e039d512 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -1103,7 +1103,7 @@ pub(crate) fn body( ); // Advanced (Acrobat's Advanced Print Setup): print as image. Folded unless it is on. ui.add_space(6.0); - egui::CollapsingHeader::new(egui::RichText::new(tl!("Advanced").to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint)) + egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::upper_label("Advanced")).font(theme::semibold(10.5)).color(t.text_faint)) .id_salt("print-advanced") .default_open(d.as_image) .show(ui, |ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..c39f4cf05e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -272,7 +272,7 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); ui.add_space(2.0); } diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index f4eb84359b..9a3716c3ee 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -1213,6 +1213,91 @@ fn attaching_a_file_as_a_comment() { assert_eq!(s.quick_tool, QuickTool::Select); } +/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying +/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference +/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones. +fn attachment_comment_fixture() -> Vec { + let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens"); + app.set_option("left", "closed").unwrap(); + setup(&mut app); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect() +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_page_menu() { + let mut h = attachment_harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + assert_eq!(comments(&h)[0].subtype, "FileAttachment"); + click_pt(&mut h, (110.0, 112.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected"); + let p = at(&h, 110.0, 112.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); + // The Attachments panel is closed, so these items can only come from the comment's menu. + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + +#[test] +fn an_attachment_comment_opens_its_file_from_the_card_menu() { + let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap()); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("More").click(); + h.run_steps(2); + h.get_by_label("Copy text"); + h.get_by_label("Save attachment…"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(4); + assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]); +} + /// Drag with the middle button (the mouse wheel) in small steps. fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 6957d47f06..0c7f7196c8 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() { assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]); } +#[test] +fn reverse_pages_reverses_the_selection_or_else_the_whole_document() { + let mut h = organize(4); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]); + h.get_by_label("Page 1").click(); + h.run_steps(1); + h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND); + h.run_steps(2); + assert!(h.state_mut().execute("page.reverse")); + h.run_steps(3); + assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]); +} + #[test] fn command_click_toggles_and_rotation_applies_to_selection() { let mut h = organize(3); @@ -520,6 +535,27 @@ fn save_writes_an_incremental_update_and_clears_dirty() { let _ = std::fs::remove_file(out); } +#[test] +fn save_as_to_a_new_file_drops_earlier_revisions() { + // #2165: Save As appended a revision, so a deleted page stayed in the new file's first one. + let out = temp_path("save-as-clean.pdf"); + let mut h = organize(3); + h.get_by_label("Page 2").hover(); + h.run_steps(2); + h.get_by_label("Delete page 2").click(); + h.run_steps(3); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As)); + let saved = std::fs::read(&out).expect("file written"); + let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count(); + assert_eq!(count(b"%%EOF"), 1, "one revision only"); + assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone"); + assert!(!dirty(&h)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice()); + assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]); + let _ = std::fs::remove_file(out); +} + #[test] fn closing_a_dirty_tab_asks_and_cancel_keeps_it() { let mut h = organize(2); @@ -3796,6 +3832,58 @@ fn one_editor_contains_mixed_styles_and_visual_rows() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rows.len(), 3); } +#[test] +fn the_caret_on_a_small_row_is_as_tall_as_that_row() { + // It was one row of the region's first (18 pt) size tall, reaching into the row below. + let bytes = source_font_fixture_with_text( + "BT 0 0.2 0.5 rg /F1 1 Tf 18 0 0 18 20 240 Tm (Heading) Tj ET \ + BT 0 g /F2 12 Tf 20 215 Td (Plain ) Tj /F1 12 Tf 0 0.2 0.5 rg (bold) Tj /F2 12 Tf 0 g ( words) Tj ET \ + BT /F2 10 Tf 20 195 Td (Last row) Tj ET", + ); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("mixed.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 300.0; + let screen = |x: f32, y: f32| egui::pos2(page.left() + x * k, page.top() + (300.0 - y) * k); + let click = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + }; + click(&mut h, screen(30.0, 245.0)); + assert!(h.state().views[0].line_editor.is_some(), "the region opens"); + let row = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text == "Last row").unwrap().rect; + click(&mut h, screen(40.0, ((row[1] + row[3]) / 2.0) as f32)); + let row_height = (row[3] - row[1]) as f32 * k; + let (left, right) = (screen(row[0] as f32, 0.0).x - 2.0, screen(row[2] as f32, 0.0).x + 2.0); + let middle = screen(0.0, ((row[1] + row[3]) / 2.0) as f32).y; + // The caret: a vertical segment within the row's span, through its middle. + let caret = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::LineSegment { points: [a, b], .. } + if (a.x - b.x).abs() < 0.01 && (left..right).contains(&a.x) && a.y.min(b.y) < middle && a.y.max(b.y) > middle => + { + Some((b.y - a.y).abs()) + } + _ => None, + }) + .expect("a caret on the last row"); + assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] diff --git a/crates/ui-egui/tests/signing.rs b/crates/ui-egui/tests/signing.rs index ccb6dece7b..c7fd4c42fa 100644 --- a/crates/ui-egui/tests/signing.rs +++ b/crates/ui-egui/tests/signing.rs @@ -285,6 +285,38 @@ fn certifying_without_a_visible_signature() { h.get_by_label_contains("Certified by Test Signer EC"); } +#[test] +fn save_as_keeps_a_signed_document_incremental() { + // #2165: Save As to a new file rewrites unsigned documents in full; a signed one is appended + // to, or its signature would no longer cover the file. + let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12"); + let digital_id = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap(); + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("contract.pdf", None, std::sync::Arc::new(FIXTURE.to_vec()), None).unwrap(); + let opts = pdfcraft_engine::SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..Default::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + let out = dir().join("copy.pdf"); + let (bytes, cert) = (signed.to_vec(), digital_id.certificate.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("contract.pdf", None, bytes).expect("opens"); + app.session.set_trusted_certificates(vec![cert]); + app + }); + h.run_steps(4); + let status = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).map(|x| x.status) + }; + assert_eq!(status(&h), Some(SignatureStatus::Valid)); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As), "a signed document can be saved as a copy"); + let saved = std::fs::read(&out).expect("file written"); + assert!(saved.starts_with(&signed), "the signed revision is kept as it was"); + assert_eq!(status(&h), Some(SignatureStatus::Valid)); +} + /// The fixture with an empty signature field across the bottom of the page. const WITH_FIELD: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 3f4420d3b4..c30f081e4a 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -617,6 +617,35 @@ fn default_zoom_is_used_for_new_documents() { assert_eq!(fit_zoom(&fresh, 0), (Fit::None, 1.5)); } +#[test] +fn the_rail_zoom_field_sets_a_typed_zoom() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + use pdfcraft_ui_egui::canvas::Fit; + let mut h = harness(&[]); + let type_zoom = |h: &mut Harness<'static, PdfCraftApp>, typed: &str, key: Key| { + h.get_by_role_and_label(Role::TextInput, "Zoom").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Zoom").type_text(typed); + h.run_steps(1); + h.key_press(key); + h.run_steps(3); + }; + type_zoom(&mut h, "150", Key::Enter); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 1.5)); + type_zoom(&mut h, "62.5 %", Key::Enter); + assert_eq!(h.state().views[0].zoom, 0.625); + for (typed, key) in [("abc", Key::Enter), ("0", Key::Enter), ("300", Key::Escape)] { + type_zoom(&mut h, typed, key); + assert_eq!(h.state().views[0].zoom, 0.625, "{typed}"); + } + type_zoom(&mut h, "9000", Key::Enter); + assert_eq!(h.state().views[0].zoom, 64.0); + h.state_mut().views[0].set_zoom(2.0); + h.run_steps(2); + assert_eq!(h.get_by_role_and_label(Role::TextInput, "Zoom").value().as_deref(), Some("200%")); +} + #[test] fn preferences_set_the_default_zoom() { use egui_kittest::kittest::Queryable; @@ -1046,6 +1075,47 @@ fn the_hand_tool_pans_by_dragging() { assert!(h.state().views[0].selected_text().is_none(), "no text selection with the hand"); } +#[test] +fn the_hand_tool_moves_the_page_with_the_pointer() { + // With the Hand tool the page stays under the pointer, point for point, in the frame the + // pointer moves (#924, #1992). The drag used to reach the scroll offset only after the frame + // was drawn (and, before the page view stopped animating its scrolls, eased in over several + // frames), so the page trailed behind the pointer; the points moved before egui tells a drag + // from a click were lost on top. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "continuous"), ("zoom", "600"), ("quick", "hand")]); + let start = h.state().views[0].viewport_rect().center(); + h.hover_at(start); + h.run_steps(2); + let page = |h: &Harness<'static, PdfCraftApp>| rect(h, 0).expect("page 1 on screen").min; + let grabbed = page(&h); + let click_dist = h.ctx.options(|o| o.input_options.max_click_dist); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.event(button(start, true)); + h.step(); + // A slow start, two points a frame, then quick strokes both ways. Once the pointer is further + // than a click may wander, the page has moved exactly as far as it, in every frame. + let mut pointer = start; + let strokes = [egui::vec2(-2.0, -2.0); 5].into_iter().chain([egui::vec2(-40.0, -150.0), egui::vec2(25.0, 60.0)]); + for stroke in strokes { + pointer += stroke; + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + if (pointer - start).length() > click_dist { + assert_eq!(page(&h) - grabbed, pointer - start, "the page keeps up with the pointer in the same frame"); + } + } + // Held still, it stays: nothing is left to ease in. + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "nothing moves the page once the pointer stops"); + // Let go mid-stroke, it stops with the pointer: the Hand tool doesn't fling the page on. + pointer += egui::vec2(0.0, -30.0); + h.event(egui::Event::PointerMoved(pointer)); + h.step(); + h.event(button(pointer, false)); + h.run_steps(30); + assert_eq!(page(&h) - grabbed, pointer - start, "the page stays where it was let go"); +} + #[test] fn required_fields_get_a_red_border_when_highlighting() { let _gpu = gpu(); diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 474360865b..31530bf235 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -1304,6 +1304,70 @@ fn pages_panel_thumbnails_have_a_context_menu() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2])); } +/// Where the Pages panel draws page `n`'s thumbnail. +fn thumbnail_rect(h: &Harness<'static, PdfCraftApp>, n: usize) -> egui::Rect { + h.get_all_by_label(&format!("Page {n}")).find(|t| t.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect() +} + +/// Whether `r` lies wholly in the part of the Pages panel in view. +fn in_panel_view(h: &Harness<'static, PdfCraftApp>, r: egui::Rect) -> bool { + h.state().views[0].pages_panel.expect("the Pages panel is shown").contains_rect(r) +} + +/// #2781: as in Acrobat, going to a page whose thumbnail is out of sight scrolls the Pages +/// panel to show it. +#[test] +fn going_to_a_far_page_scrolls_its_thumbnail_into_view() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + assert!(!in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25 starts out of sight"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert!(in_panel_view(&h, thumbnail_rect(&h, 25)), "page 25's thumbnail is shown"); +} + +/// #2781: a page whose thumbnail is already in view leaves the panel where it is, rather than +/// centring the thumbnail on every page change. +#[test] +fn going_to_a_page_in_view_leaves_the_pages_panel_still() { + // Landscape pages, so several thumbnails share the panel. + let mut h = pages_panel_harness(&[(300, 200); 30]); + h.state_mut().views[0].go_to_page(14); + h.run_steps(4); + let clip = h.state().views[0].pages_panel.expect("the Pages panel is shown"); + // The topmost thumbnail wholly in view, other than the current page's. + let (n, before) = + (1..=30).map(|n| (n, thumbnail_rect(&h, n))).find(|(n, r)| *n != 15 && in_panel_view(&h, *r)).expect("another thumbnail in view"); + assert!( + (before.center().y - clip.center().y).abs() > 50.0, + "page {n} at {before:?} is away from the middle of {clip:?}, where centring would move it" + ); + h.state_mut().views[0].go_to_page(n - 1); + h.run_steps(4); + assert_eq!(h.state().views[0].current, n - 1); + assert_eq!(thumbnail_rect(&h, n), before, "page {n}'s thumbnail stayed put"); +} + +/// #2781: while the user drags thumbnails, a page change does not pull the panel away from +/// where they are being dropped. +#[test] +fn a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel() { + let mut h = pages_panel_harness(&[(200, 300); 30]); + let first = thumbnail_rect(&h, 1); + let at = first.center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.hover_at(at + egui::vec2(0.0, 30.0)); + h.run_steps(2); + assert_eq!(h.state().views[0].panel_drag, Some(vec![0]), "page 1 is being dragged"); + h.state_mut().views[0].go_to_page(24); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 24); + assert_eq!(thumbnail_rect(&h, 1), first, "the panel stays put under the drag"); +} + /// #444, #186: a mouse-wheel notch scrolls as far as the scroll speed preference says; /// trackpad (point) scrolling keeps its own speed. The choice is saved. #[test] diff --git a/docs/architecture.md b/docs/architecture.md index aa91c36c4e..6b44817e15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,7 +49,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod 3. After every edit an incremental write produces the working file (original bytes plus one revision); the view is refreshed from those bytes, so what you see is what Save writes. 4. Saves are incremental and atomic. A full rewrite happens only where it must: redaction, PDF/A - conversion, print imposition, a changed password, Save As with garbage collection. + conversion, print imposition, a changed password, Save As to another file (garbage-collected, + unless the document is signed). 5. Autosave and crash recovery keep encrypted documents encrypted. Signed documents are protected from rewrites. diff --git a/docs/editing-images.md b/docs/editing-images.md index eba918ef39..67d3613bf1 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -8,7 +8,8 @@ together with its pixels, including nested graphics states, intervening state op and paths split across content streams. A clip shared with other content stays where it is. When the figure already sits alone in a `q … cm … Q` (its own placement, or the one an earlier edit added), moving or resizing it again changes that `cm`, so repeated edits don't -nest one more graphics state each time. +nest one more graphics state each time. Moving a privately cropped figure again likewise +changes the `cm` before its clip instead of adding another. PDF figures are not always bitmap images. Imported diagrams often use a **Form XObject**, which packages text, vector paths, and nested images into one object. PdfCraft selects diff --git a/docs/roadmap.md b/docs/roadmap.md index 55e898b283..91742b2c47 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,8 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; 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 (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **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) @@ -105,7 +108,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | @@ -135,9 +138,9 @@ total matches the parity estimate. | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | @@ -182,8 +185,12 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e7698baeef..9119063e20 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ full parity in that area. | C Content editing | 61 | 29 | 7 | 53.3% | 30% | 100–180 | Edit existing paragraphs (font reused or Helvetica substituted, rewrapped), added text and images stay editable, header/footer, watermarks, backgrounds, Bates, links. Missing: robust editing of real-world text (subset fonts, CJK, RTL #766, rotated), lists, vector/object editing, arrange/align, spell check, multi-select move across pages (same-page shipped, #844) | | D Organize pages | 76 | 45 | 3 | 61.2% | 70% | 12–25 | Organize grid, insert/extract/replace/split/combine with links, fields, layers and bookmarks carried over; page labels, boxes, bookmarks from structure. Missing: transitions, attachments editing, portfolios, struct-tree merge, field-name conflicts (destination and annotation fixes #817, #818, #821 shipped) | | E Comments and review | 65 | 46 | 9 | 77.7% | 70% | 12–25 | Every markup type with appearances, replies, status, filters, stamps (custom, dynamic), XFDF/FDF, summaries, flatten, 2D measuring. Missing: rich-text runs, popup windows, summary layouts with pages, per-type hiding (XFDF fidelity fixes #807, #809, #819 shipped) | -| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | +| F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a197628329..66ff1a7273 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -950,8 +950,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation"] -notes = "PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" +evidence = ["crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/view.rs::the_rail_zoom_field_sets_a_typed_zoom"] +notes = "Typed into the right rail's zoom field (150, 150% or 62.5 %, Enter applies, Escape discards, clamped to 8–6400%) or set through the control channel's zoom option; no preset dropdown in the field yet. PdfCraft draws a percentage on its fixed 96-dpi basis: 96/72 logical pixels per PDF point at 100%. Acrobat draws it at its Page Display resolution, with logical pixels per PDF point = ppi / 72 × zoom (checked black-box with Custom resolutions of 96, 110 and 120 ppi on one display), so the same percentage can differ in size. Acrobat's Use System Setting resolution is not reproduced. Tracked with misc.prefs-page-display" [[feature]] id = "view.zoom-actual-size" @@ -1070,8 +1070,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] -notes = "drag to pan; Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily" +evidence = ["crates/ui-egui/tests/view.rs::the_hand_tool_pans_by_dragging", "crates/ui-egui/tests/view.rs::the_hand_tool_moves_the_page_with_the_pointer", "crates/ui-egui/tests/viewer.rs::v_h_and_space_pick_the_quick_tools"] +notes = "drag to pan: the page follows the pointer point for point in the frame it moves, and stops where it is let go (no fling); Grab/Grabbing cursors; H picks it, V goes back to Select, Space held pans temporarily. Checked with kittest pointer events, not yet on a desktop" [[feature]] id = "view.rotate-view" @@ -1262,8 +1262,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] +notes = "The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" @@ -1866,7 +1866,7 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row"] notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] @@ -2812,8 +2812,8 @@ milestone = "M4" status = "shipped" commands = ["page.replace"] tools = ["page_replace"] -evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools"] -notes = "content, resources and boxes replaced; links, comments, fields and bookmarks kept" +evidence = ["crates/organize/src/tests.rs::replace_pages_swaps_content_but_keeps_annotations", "crates/ui-egui/tests/editing.rs::replace_pages_dialog_swaps_page_content", "crates/ui-egui/tests/editing.rs::replace_button_replaces_selected_pages_in_organize", "crates/automation/tests/automation.rs::replacing_pages_through_tools", "crates/ui-egui/src/canvas.rs::replacing_a_page_after_layout_never_uploads_an_oversized_texture", "crates/ui-egui/src/canvas.rs::oversized_raster_results_do_not_replace_cached_textures"] +notes = "Content, resources and boxes replaced; links, comments, fields and bookmarks kept. Page-list edits discard demand laid out against the previous geometry; the next frame plans bounded backdrops and tiles. Raster uploads reject empty images and dimensions beyond the current GPU texture limit before replacing cached textures (#3149). This covers the viewer's page, tile, thumbnail, organize and print-preview result paths; it does not change the renderer's output resolution or add a global GPU memory budget." [[feature]] id = "organize.duplicate-pages" @@ -2833,11 +2833,11 @@ title = "Reverse page order" area = "D" tier = "P2" milestone = "M4" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["page.reverse"] +tools = ["page_reverse"] +evidence = ["crates/organize/src/tests.rs::reverse_pages_reorders", "crates/organize/src/tests.rs::cannot_reverse_missing_or_fewer_than_two_pages", "crates/automation/tests/automation.rs::reversing_pages_through_tools", "crates/ui-egui/tests/editing.rs::reverse_pages_reverses_the_selection_or_else_the_whole_document"] +notes = "Organize Pages ▸ More ▸ Reverse pages, or the command palette: two or more selected pages swap among their own positions, otherwise the whole document reverses. One undo step. Not yet: an entry in the page thumbnails' right-click menu" [[feature]] id = "organize.cut-copy-paste-pages" @@ -3812,8 +3812,8 @@ milestone = "M5" status = "shipped" commands = ["comment.attach"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] -notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel" +evidence = ["crates/annot/src/tests.rs::files_attach_as_comments", "crates/ui-egui/tests/comments.rs::attaching_a_file_as_a_comment", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_page_menu", "crates/ui-egui/tests/comments.rs::an_attachment_comment_opens_its_file_from_the_card_menu", "crates/automation/tests/automation.rs::drawing_comments_through_tools"] +notes = "push pin, paperclip, graph or tag icon; the file is embedded and listed in the Attachments panel. The comment's menu, on the page and in the Comments panel, saves the file or opens a PDF in a new tab (#2565)" [[feature]] id = "comment.record-audio" @@ -4208,8 +4208,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] +notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" @@ -5088,8 +5088,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/automation/tests/automation.rs::detecting_comb_boxes_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields" @@ -5220,8 +5220,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them"] -notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/automation/tests/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/ui_group/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/ui_group/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/ui_group/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "apps/pdfcraft-cli/tests/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions"] +notes = "laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters, breaks, repeated header rows, captions, rich text, JPEG/PNG/GIF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. Missing: loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" @@ -5728,8 +5728,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] -notes = "with pop-ups; fields lose all widgets" +evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/redact/src/tests.rs::replies_to_a_redacted_comment_go_with_it", "crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing"] +notes = "with pop-ups and replies (/IRT, transitively; replies on other pages are not followed); fields lose all widgets" [[feature]] id = "protect.redact-struct-tree"