diff --git a/README.md b/README.md index bd33ad23be..2a65b66457 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

PdfCraft

- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web

@@ -417,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development ## What's next -PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro. +PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows. -**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout. +**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout. - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. -- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. +- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side. -**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). @@ -581,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 3f628ab694..7cca8d5585 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,17 +1,19 @@ # PdfCraft roadmap -> **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-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-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #2658: reject partial extended-attribute copies after a size change; estimates unchanged) - **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-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **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 (M4, #1919: repeated page indices accumulate rotations; 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-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; 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-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-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) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -36,6 +38,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -167,22 +170,29 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged. - **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-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-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-18 (M1, #2658):** Atomic macOS saves recheck attribute sizes after reading, so a partial resource-fork read cannot publish truncated metadata. A deterministic growth regression preserves the original PDF and grown fork. Estimates unchanged. -- **2026-10-17 (M1, #2658):** macOS saves retain accessible destination extended attributes before atomic publication, including Finder tags and empty/binary values. Metadata errors keep the original and unsaved edit; source symlinks are not followed. Permissions/ACLs, hard links and creation dates remain separate work. Estimates unchanged. +- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. +- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. 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-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. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. +- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. - **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-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged. +- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. +- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. 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. +- **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-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. 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. @@ -191,9 +201,9 @@ 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 (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-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 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. 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. - - **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. - **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. - **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. @@ -524,11 +534,15 @@ Newest first: the date, what moved, and the new overall estimate. | 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 | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. 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. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | +| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 119aaf4acb..be2df77355 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -329,6 +329,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index a46a4674b9..73d14f4a6c 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -70,6 +70,10 @@ fn main() { app.host_save = Some(bridge.save_fn()); app.host_dirty = Some(bridge.dirty_fn()); bridge.post_ready(); + } else if query_param("host").as_deref() == Some("parent") { + eframe::web_sys::console::warn_1( + &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), + ); } Ok(Box::new(app)) }), @@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. /// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. /// -/// Only the parent window on the app's own origin is heard or answered. +/// Only the parent window on the app's own origin is heard or answered. With a parent on +/// another origin there is no bridge: the app runs as if unframed and saves download. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; @@ -143,7 +148,9 @@ mod host { } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to). + /// `None` when the page isn't framed (there is no parent to talk to), or when the parent + /// is on another origin: messages only go to this origin, so the browser would drop + /// every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; @@ -151,6 +158,10 @@ mod host { return None; } let origin = window.location().origin().ok()?; + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } Some(Self { window, parent, origin }) } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index b274dc53a8..721b7da1b3 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -380,6 +380,7 @@ fn app_creator<'a>( if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { app.restore(&json); } + app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage); app.integrated_titlebar = integrated; app.window_controls = window_controls; app.system_text_scale = text_scale::system(); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f4973aa021..b7f9d62697 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -2282,6 +2282,11 @@ fn line_endings_of(d: &Dict) -> Option> { /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the /// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they /// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. #[allow(clippy::too_many_arguments)] pub fn add_text_replacement( doc: &mut Document, @@ -2292,6 +2297,7 @@ pub fn add_text_replacement( strike: &Style, caret: &Style, meta: &Meta, + caret_meta: &Meta, ) -> Result { let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( @@ -2317,7 +2323,7 @@ pub fn add_text_replacement( let c = add_annotation( doc, &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, - meta, + caret_meta, )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 2e45b4e4d4..4d3eef632b 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) { d.set(b"H".to_vec(), Object::name(s.highlight.code())); } +/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8). +fn uri_bytes(u: &str) -> Vec { + let mut out = Vec::with_capacity(u.len()); + for &b in u.as_bytes() { + if (0x21..=0x7e).contains(&b) { + out.push(b); + } else { + out.extend_from_slice(format!("%{b:02X}").as_bytes()); + } + } + out +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { LinkAction::Page(p) => { @@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno let mut act = Dict::new(); act.set(b"S".to_vec(), Object::name("URI")); // URIs are 7-bit ASCII strings (§12.6.4.8). - act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u))); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409abb..d3d448e520 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); } +#[test] +fn link_uri_is_percent_encoded_to_7_bit_ascii() { + use crate::links::{self, LinkAction, LinkStyle}; + let mut doc = fixture(); + for url in ["https://example.com/ação", "https://example.com/a%20b"] { + links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap(); + } + let doc = reopen(&doc); + let urls: Vec = links::list(&doc) + .into_iter() + .filter_map(|link| match link.action { + LinkAction::Uri(url) => Some(url), + _ => None, + }) + .collect(); + assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]); + assert!(urls.iter().all(|url| url.is_ascii())); +} + #[test] fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() { use crate::links::{self, LinkAction, LinkStyle}; @@ -784,7 +803,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap(); + let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -792,13 +811,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); assert!(caret.reference(b"IRT").is_some()); + // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it + // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the + // pair does not survive an export and import. + assert_eq!(text(caret, b"NM").as_str(), "r-caret"); + assert_eq!(text(strike, b"NM").as_str(), "r"); + assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); + let irt = caret.reference(b"IRT").unwrap(); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); // Deleting the strikeout takes the caret with it (the group). let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err()); + assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); } #[test] diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8a4dcec16c..c011873069 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1753,16 +1753,21 @@ impl Automation { let mut used = std::collections::HashSet::new(); for (i, (first, last, bytes)) in parts.iter().enumerate() { let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let file = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}.pdf", safe(t)), - None => format!("{stem}-part{}.pdf", i + 1), + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file.clone(); + let mut unique = file; let mut n = 1; while !used.insert(unique.to_lowercase()) { n += 1; - unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf")); + unique = format!("{base}-{n}.pdf"); } let path = child(&dir, &unique); write_atomic(&path, bytes)?; @@ -2401,6 +2406,9 @@ fn info(d: &Document) -> Value { "pdf_version": i.pdf_version, "file_size": i.file_size, "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 6b7b8a306a..69d1eda217 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -156,7 +156,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .ro() diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index d7e6f658df..001ca43a1e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -273,6 +273,98 @@ fn page_transitions_are_set_and_removed_through_the_tools() { ); } +/// #1937 through the tools: a damaged file is repaired on open (`doc_info` lists the repair) and +/// `doc_save` over it writes the rebuilt file, so opening it again needs no repair. +#[test] +fn saving_a_repaired_file_puts_the_repair_on_disk() { + let dir = workdir("repaired-save"); + let good = fixture(2); + let mut broken = b"%PDF-1.7\n%shifted\n".to_vec(); + broken.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + std::fs::write(dir.join("broken.pdf"), &broken).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("rebuilt")), "{}", info["repairs"]); + let saved = ok(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], false, "{saved}"); + let bytes = std::fs::read(dir.join("broken.pdf")).unwrap(); + assert_ne!(bytes, broken, "the damaged bytes were written back"); + let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["repairs"].as_array().unwrap().len(), 0, "{}", info["repairs"]); + assert_eq!(info["pages"].as_array().unwrap().len(), 2); +} + +/// Custom document properties through the tools: set and cleared with doc_set_info under any +/// name the spec does not use, listed by doc_info apart from the standard entries, bad names +/// refused without a change. +#[test] +fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() { + let dir = workdir("custom-info"); + std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["title"], "Report"); + let mut custom: Vec<(String, String)> = + info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect(); + custom.sort(); + assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused"); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err()); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]); + assert_eq!(info["custom"][0]["name"], "Revision"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut b, "doc_info", json!({ "doc": re })); + assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]); +} + +/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated +/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the +/// open-time reader and by the engine's refresh. +#[test] +fn custom_document_properties_do_not_change_after_an_unrelated_edit() { + let dir = workdir("custom-info-stable"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>", + ]; + 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 /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("custom.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}"); + assert_eq!(opened[1]["value"], "Lab", "{opened}"); + assert_eq!(opened[2]["value"], "Lab", "{opened}"); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + assert_eq!(edited, opened); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); @@ -3898,6 +3990,35 @@ fn bookmark_split_with_equal_titles_keeps_every_part() { } } +#[test] +fn bookmark_split_with_long_titles_shortens_the_file_names() { + let dir = workdir("organize_long_titles"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // Two equal titles far over the 255-byte name limit, then a multibyte one (3 bytes a character). + let long = format!("Chapter {}", "x".repeat(300)); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "章".repeat(100), "page": 3 })); + let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" })); + let files: Vec = s["files"] + .as_array() + .unwrap() + .iter() + .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files.len(), 3, "{files:?}"); + let distinct: std::collections::HashSet<_> = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(distinct.len(), 3, "{files:?}"); + for f in &files { + assert!(f.len() <= 255, "{f} is {} bytes", f.len()); + assert!(f.ends_with(".pdf"), "{f}"); + assert!(dir.join("parts").join(f).exists(), "{f} missing"); + } + assert!(files[0].starts_with("a-Chapter x"), "{files:?}"); + assert!(files[2].starts_with("a-章"), "{files:?}"); +} + #[test] fn organizing_with_filters_bookmark_splits_and_extract_options() { let dir = workdir("organize2"); diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e943555905..639edf582c 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -82,6 +82,8 @@ struct XrefSection { row_count: usize, max_num: u32, has_entries: bool, + /// A table subsection was read one number lower than it says ([`subsection_start`]). + renumbered: bool, } #[derive(Clone, Debug)] @@ -307,6 +309,10 @@ pub struct Revision { pub xref_offset: u64, /// `true` if the section is a cross-reference stream. pub is_stream: bool, + /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the + /// file. The two differ only in a file with bytes before its header, and an update appended + /// to such a file must count the same way as the section it points back to. + pub header_relative: bool, } #[derive(Clone, Debug)] @@ -857,6 +863,19 @@ impl Document { &self.revisions } + /// Position of the `%PDF-` header in the file: 0 unless bytes come before it. + pub fn header_offset(&self) -> usize { + self.header_offset + } + + /// There is no cross-reference chain to append to: the index was rebuilt from object + /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document + /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even + /// when nothing was edited: for a repaired file that is what puts the repair on disk. + pub fn reconstructed(&self) -> bool { + self.revisions.is_empty() + } + /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it /// was saved. A linearized file's first-page section belongs to the revision it precedes, so @@ -891,9 +910,10 @@ impl Document { self.repair_log = Arc::new(log); } - /// `true` when there are unsaved edits. + /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in + /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same. pub fn is_modified(&self) -> bool { - !self.overlay.is_empty() + !self.overlay.is_empty() || self.encryption_changed } /// Object numbers changed since the document was opened (or last saved). @@ -1397,13 +1417,13 @@ impl Document { log.push(format!("cross-reference chain loops at offset {off}; stopped")); break; } - let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) { - Ok(Some(v)) => v, + let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => (v, false), Ok(None) => return Ok(None), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); match self.read_compact_section(off as usize + self.header_offset)? { - Some(v) => v, + Some(v) => (v, true), None => return Ok(None), } } @@ -1411,7 +1431,10 @@ impl Document { // materialized reader logs it and the file is rebuilt from object headers (#1656). Err(e) => return Err(e), }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + if primary.renumbered { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { return Ok(None); @@ -1486,6 +1509,7 @@ impl Document { let mut has_entries = false; let mut previous_end = None; let mut row_count = 0usize; + let mut renumbered = false; loop { lx.skip_ws(); if lx.eat_keyword(b"trailer") { @@ -1494,7 +1518,7 @@ impl Document { if !layout_ok { return Ok(None); } - return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false))); } let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); @@ -1502,6 +1526,9 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + renumbered |= shifted != start; + let start = shifted; let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; if next_rows > MAX_COMPACT_XREF_ROWS { return Ok(None); @@ -1654,7 +1681,14 @@ impl Document { } let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); let row_count = row_start; - let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false }; + let section = XrefSection { + data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, + ranges, + row_count, + max_num, + has_entries: false, + renumbered: false, + }; let has_entries = section.has_any_entry(); let section = XrefSection { has_entries, ..section }; Ok(Some((section, stream.dict.clone(), true))) @@ -1688,13 +1722,13 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { - Ok(v) => v, + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { + Ok(v) => (v, false), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -1705,12 +1739,12 @@ impl Document { return Err(e); } }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd) { + match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { for (num, e) in stm { match sect.get(&num) { @@ -1745,7 +1779,13 @@ impl Document { /// Read one section at `off`, adding entries not already known (newer sections win). `odd` /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). - fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { + fn read_section( + &self, + off: usize, + entries: &mut BTreeMap, + odd: &mut Vec, + log: &mut Vec, + ) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1762,6 +1802,11 @@ impl Document { if !valid_xref_range(start, count) || count > 10_000_000 { return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); } + let shifted = subsection_start(data, lx.pos, start, count); + if shifted != start { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } + let start = shifted; for i in 0..count { lx.skip_ws(); let o = lx.token(); @@ -1972,6 +2017,34 @@ fn generated_id(seed: &[u8; 32]) -> Vec { out } +/// The object number a classic xref subsection really starts at. A common producer bug heads the +/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so +/// every row would be read one number too high and object 1 as free. Such a subsection is read +/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows` +/// is the position just after the subsection header. +fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { + if start != 1 || count < 2 { + return start; + } + let mut lx = Lexer::new(data, rows); + let mut row = || { + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + (offset, generation, lx.token() == b"f") + }; + let (Some(0), Some(65535), true) = row() else { return start }; + let (Some(second), _, false) = row() else { return start }; + let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start }; + let mut obj = Lexer::new(data, at); + match (obj.object(), obj.object()) { + (Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0, + _ => start, + } +} + /// Object numbers must fit the representation used by references and the xref map. /// Validate the whole subsection before adding entries, so neither arithmetic overflow nor /// truncation can turn a damaged range into entries for unrelated objects. @@ -2159,6 +2232,71 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered + /// `1 n` instead of `0 n` (#2676): every row is one number off. + fn misnumbered_table(start: u32) -> Vec { + let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{body} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out + } + + #[test] + fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { + let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); + let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); + assert!(first.contains(b"Marker")); + assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); + // A correct table reads the same and logs nothing. + let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); + assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); + assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + + #[test] + fn a_table_that_really_starts_at_1_keeps_its_numbers() { + // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its + // second row locates object 2, so nothing is shifted. + let mut bytes = b"%PDF-1.7\n".to_vec(); + let pages = bytes.len(); + bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let catalog = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); + assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + #[test] fn opening_an_exhausted_classic_xref_repairs_and_allocates() { // The extra in-use row need not be loaded to exhaust the next object number. diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d80..331f97d443 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -183,7 +183,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, } // Reconstructed files have no chain to append to; added or removed encryption must // rewrite every object; redaction must not leave the old revision behind. All need a full save. - if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() { + if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() { return write_full(doc, opts); } let mut doc = doc.clone(); @@ -193,7 +193,11 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, // allocated once at its exact size: growing a buffer that holds a large original past a // guessed capacity would copy the whole file again and keep up to as much again unused. let newline = !matches!(original.last(), Some(b'\n' | b'\r')); - let base = (original.len() + usize::from(newline)) as u64; + // A file with bytes before its `%PDF-` header may count its offsets from the header. The new + // section then has to as well: readers take `/Prev` and `startxref` in one convention, and a + // mix sends them into reconstruction (#2364). + let shift = if doc.revisions().last().is_some_and(|r| r.header_relative) { doc.header_offset() } else { 0 }; + let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64; let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) for (num, generation, obj) in doc.overlay_entries() { diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index af92cf67c7..e05161558d 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -336,3 +336,41 @@ fn an_earlier_revision_opens_with_the_documents_own_credentials() { assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); } } + +/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it, +/// instead of as an indirect object. +fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: alg, + user_password: user, + owner_password: owner, + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still +/// an unsaved change, and saving must write a file that opens without a password (#2661). +#[test] +fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() { + for alg in ALL { + let bytes = Arc::new(protect_inline(alg, "user", "owner")); + assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}"); + let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap(); + assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline"); + assert!(!doc.is_modified(), "{alg:?}"); + doc.remove_encryption(); + assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change"); + let plain = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}")); + assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}"); + assert_eq!(content(&again), SECRET, "{alg:?}"); + assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}"); + } +} diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index ec18b13f36..ac04be2e94 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -392,3 +392,67 @@ fn full_save_preserves_unknown_trailer_data() { assert_eq!(trailer_note(&back), trailer_note(&doc)); assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); } + +/// The `startxref` value at the end of `bytes`. +fn last_startxref(bytes: &[u8]) -> usize { + let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap() +} + +#[test] +fn incremental_saves_of_a_file_with_bytes_before_its_header_count_from_the_header() { + // Offsets written before the prefix was added count from `%PDF-`, as in the KillerPDF file + // of #2364; the appended section must count the same way as the `/Prev` it points back to. + let prefix = vec![b'#'; 128]; + let as_stream = write_full(&Document::open(Arc::new(fixture())).unwrap(), &SaveOptions::default()).unwrap(); + for (name, body, marker) in [("table", classic_table(), &b"xref"[..]), ("stream", as_stream, &b" 0 obj"[..])] { + let input = [prefix.as_slice(), &body].concat(); + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.revisions().last().unwrap().header_relative, "{name}"); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(&input), "{name}"); + + let xref = last_startxref(&saved) + prefix.len(); + let section = saved.get(xref..).unwrap_or_default(); + assert!(section.windows(marker.len()).position(|w| w == marker).is_some_and(|p| p < 16), "{name}: startxref misses its section"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert_eq!(back.revisions().len(), doc.revisions().len() + 1, "{name}"); + assert!(back.revisions().iter().all(|r| r.header_relative), "{name}: one convention across the chain"); + let edited = back.get(ObjRef::new(1, 0)); + assert_eq!(edited.as_dict().unwrap().get(b"Lang").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"en".as_slice())); + assert!(back.xref_entry_is_exact(1), "{name}"); + assert_qpdf_clean(&saved); + } +} + +#[test] +fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so() { + // The other convention: the same file, but its offsets include the bytes before the header. + let prefix = vec![b'#'; 128]; + let mut input = [prefix.as_slice(), &classic_table()].concat(); + let xref = input.windows(6).rposition(|w| w == b"\nxref\n").unwrap() + 1; + let table: String = String::from_utf8_lossy(&input[xref..]) + .lines() + .map(|line| match line.strip_suffix(" 00000 n ").and_then(|o| o.parse::().ok()) { + Some(o) => format!("{:010} 00000 n \n", o + prefix.len()), + None if line.parse::().is_ok() => format!("{xref}\n"), + None => format!("{line}\n"), + }) + .collect(); + input.truncate(xref); + input.extend_from_slice(table.as_bytes()); + + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert!(!doc.revisions().last().unwrap().header_relative); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.get(last_startxref(&saved)..).unwrap_or_default().starts_with(b"xref")); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.revisions().len(), 2); + assert!(back.xref_entry_is_exact(1)); + // No qpdf check here: qpdf counts every offset from the header in a file with bytes before + // it, so it already reconstructs this input before anything is appended. +} diff --git a/crates/create/README.md b/crates/create/README.md index 88c6da2ea5..499081059a 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. + +Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. +Missing, null or malformed Decode arrays keep the default mapping. +The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. +ICC transforms and calibrated color conversion remain outside this export path. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 1034489458..768da4d1f1 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. - let inverted = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) - .unwrap_or(false); + // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let inverted = (jpeg || mask) + && s.dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as // a negative. @@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); + let decode = if jpeg || mask { None } else { decode(doc, s, n) }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } @@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> return as_is(); } let invert = inverted ^ mask; // a stencil mask paints its 0 samples + // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too. let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { + let decoded = |i: usize| { + decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| { + let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max); + // Avoid overflowing hi - lo for finite endpoints with opposite signs. + (1.0 - t) * lo + t * hi + }) + }; let v = |i: usize| { + if let Some(value) = decoded(i) { + return (value.clamp(0.0, 1.0) * 255.0).round() as u8; + } let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8; if invert { 255 - x } else { x } }; @@ -180,8 +192,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), Space::Indexed(base, hival, table) => { - // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3). - let i = px.first().copied().unwrap_or(0).min(*hival) as usize; + // Explicit Decode maps into index units, rounded then clipped (8.6.6.3). + // Without Decode, retain raw indices rather than normalizing to hival. + let i = match decoded(0) { + Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize, + None => px.first().copied().unwrap_or(0).min(*hival) as usize, + }; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { @@ -198,6 +214,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) } +/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90). +/// +/// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives +/// the default mapping, so the image still exports. +fn decode(doc: &Document, s: &Stream, n: usize) -> Option> { + let value = doc.resolve(s.dict.get(b"Decode")?); + let values = value.as_array()?; + if Some(values.len()) != n.checked_mul(2) { + return None; + } + let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite()); + let mut pairs = Vec::with_capacity(n); + for [lo, hi] in values.as_chunks::<2>().0 { + pairs.push([endpoint(lo)?, endpoint(hi)?]); + } + Some(pairs) +} + /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. fn size(w: i64, h: i64) -> Option<(usize, usize)> { let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index a996b703f3..6356105699 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_keeps_leading_spaces_and_tabs() { + let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); + let p = pages(&doc); + let c = doc.resolve(p[0].get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(content.contains("( indented) Tj"), "{content}"); + assert!(content.contains("( tabbed) Tj"), "{content}"); + assert!(content.contains("(plain) Tj"), "{content}"); +} + /// Text Helvetica can't draw is refused with its line and character, never set as `?`; text /// PdfCraft generates writes such characters as [U+XXXX] and says so. #[test] @@ -875,6 +887,193 @@ fn indexed_hival_keeps_existing_short_palette_fallback() { assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); } +/// Save and reopen a synthetic image before checking both export entry points. +fn decode_fixture(dict: Dict, samples: Vec) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| Stream::from_raw(dict, samples)); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap(); + (doc, image) +} + +fn assert_decode_pixels(dict: Dict, samples: Vec, expected: &[u8]) { + let (doc, image) = decode_fixture(dict, samples); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (ext, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +fn decode_array(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +#[test] +fn lossless_decode_maps_each_component_before_colour_conversion() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]), + ("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]), + ("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]), + ("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]), + ("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode_array(&decode)); + assert_decode_pixels(dict, samples, &expected); + } +} + +#[test] +fn lossless_decode_scales_packed_samples_by_their_bit_depth() { + let mut dict = image_dict(4, 1, "DeviceGray", 2); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]); +} + +#[test] +fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() { + for (bpc, samples, expected) in [ + (1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]), + (2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]), + (4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]), + (8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]), + (16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]), + ] { + for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] { + let mut dict = image_dict(1, 2, "DeviceGray", bpc); + if let Some(decode) = decode { + dict.set(b"Decode".to_vec(), decode); + } + assert_decode_pixels(dict, samples.clone(), &expected); + } + } +} + +#[test] +fn lossless_indexed_decode_rounds_in_index_units_then_clamps() { + let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]; + for (bpc, samples, decode, expected) in [ + (2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]), + (2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]), + (2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]), + (8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]), + // Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5. + (16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]), + ] { + let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples); + if let Some(decode) = decode { + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&decode)); + doc.set(image, Object::Stream(stream)); + } + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() { + let mut dict = image_dict(2, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)])); + assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]); + + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.])); + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255]; + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_resolves_indirect_arrays_and_endpoints() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let lo = doc.add(Object::Real(0.2)); + let hi = doc.add(Object::Real(0.8)); + let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)])); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![0]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let decode = doc.add(Object::Null); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![64]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); +} + +#[test] +fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() { + let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX])); + doc.set(image, Object::Stream(stream)); + let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255]; + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], decode_array(&[1., 0.]), [0, 64, 255]), + ("DeviceGray", vec![64], Object::name("bad"), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[0.]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[1., 0., 0., 1.]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::name("bad")]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::Null]), [64, 64, 64]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode); + assert_decode_pixels(dict, samples, &expected); + } + // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input. + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[1., value])); + let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![64]); + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![64]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); + } +} + /// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an /// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index fd600cb9af..82b598688a 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -203,8 +203,22 @@ impl SecurityHandler { self.recovered_user.clone() } + /// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key + /// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an + /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { - Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner } + let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); + Permissions { bits, owner: self.auth == Auth::Owner } + } + + /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. + fn perms_p(&self) -> Option { + if !matches!(self.dict.r, 5 | 6) { + return None; + } + let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); + (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } pub fn dict(&self) -> &EncryptDict { diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 7272af9ea1..5110e79998 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() { assert!(p.modify(), "owners ignore /P"); } +#[test] +fn perms_limits_what_an_edited_p_grants() { + let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only + let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions(); + let mut d = made.dict().clone(); + assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree"); + d.p = -4; // /P edited to allow everything, /Perms as written + let p = open(&d, "user pw"); + assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble()); + assert!(open(&d, "owner pw").modify(), "owners ignore /P"); + d.p = -3904; // a stricter /P still applies + assert!(!open(&d, "user pw").print()); + // No /Perms, or one that does not decrypt to an `adb` block: /P as before. + for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] { + let d = EncryptDict { p: -4, perms, ..made.dict().clone() }; + assert!(open(&d, "user pw").modify()); + } + // R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open. + let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() }; + assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword); +} + proptest::proptest! { #[test] fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60), diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 299356b293..99fae205eb 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2246,6 +2246,72 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + // #1756: a rotated watermark's bounding box covers the middle of the page, and a body line + // whose baseline happens to sit near the watermark's starting y is still unrelated text. + for rotation in [35.0, 45.0, 0.0] { + let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET"); + add_watermark( + &mut base, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline(); + let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET")); + add_watermark( + &mut doc, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap(); + let mut line_edited = doc.clone(); + text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap(); + assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}"); + + let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap(); + let mut preview = doc.clone(); + text::clear_block(&mut preview, 0, block_idx).unwrap(); + assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}"); + + text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}"); + } + + // #1368: dragging one paragraph onto another paragraph's baseline and moving it on again + // leaves the covered paragraph intact. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph"); + assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc)); + let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap(); + text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]); + + // Even when a large horizontal watermark shows the same word as a body line at the same y, + // or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin, + // editing one keeps the other. + let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET"); + text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap(); + let after = text::text_lines(&reopen(&same_word), 0).unwrap(); + assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::>(), [("FINAL", 12), ("DRAFT", 48)]); + + let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET"); + text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap(); + assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]); + + // An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is + // still removed when the visible line is edited, even if OCR misread a letter. + let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET"); + text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap(); + assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]); +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; @@ -3932,6 +3998,31 @@ fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() { assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err()); } +/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the +/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider +/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row. +#[test] +fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() { + let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."]; + let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \ + before it ends on the third row here."; + let rewrapped = |degrees: f64| { + let (sin, cos) = degrees.to_radians().sin_cos(); + let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::>().join(" T* "); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin)); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::>() + }; + let upright = rewrapped(0.0); + // The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt. + assert_eq!(upright[2], "now carries a few extra words", "{upright:?}"); + for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] { + assert_eq!(rewrapped(degrees), upright, "{degrees}°"); + } +} + /// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width. /// Adding a word keeps every row as it was and shows the word in the page's own font (#1443). #[test] @@ -3986,6 +4077,76 @@ fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() { } } +/// #2578: a single turned row grows along its baseline only up to the page's 36 pt margin and wraps +/// onto further rows from there, instead of running off the page. +#[test] +fn turned_single_row_wraps_at_the_page_margin() { + let draft = "A single turned row that keeps growing with many more words added until it is far longer than the page is tall, so it has to stop or wrap somewhere"; + // Reading upwards (90° CCW) from y = 600 on the 600 × 800 test page: top margin is at y = 764. + let before = text_page("BT /F1 12 Tf 0 1 -1 0 300 600 Tm (A single turned row) Tj ET"); + let layout = text::text_edit_layout(&before, 0, 0, draft, &text::BlockStyle::default()).unwrap(); + let mut doc = before; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + assert!(lines.len() > 1, "expected the turned row to wrap at the top margin: {lines:?}"); + for line in &lines { + assert!((line.rect[1] - 600.0).abs() < 0.5, "row starts at y = 600: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "row stays within the top margin: {:?}", line.rect); + } + let blocks = text::text_blocks(&reopened, 0).unwrap(); + assert_eq!(blocks.len(), 1, "wrapped turned rows stay one paragraph: {blocks:?}"); + assert_eq!(blocks[0].text, draft); + assert_eq!(layout.draft_rect, Some(blocks[0].rect)); + + // Reading downwards (90° CW) from y = 200: bottom margin is at y = 36. + let mut doc = text_page("BT /F1 12 Tf 0 -1 1 0 300 200 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 90° CW row to wrap at the bottom margin: {lines:?}"); + for line in &lines { + assert!((line.rect[3] - 200.0).abs() < 0.5, "row starts at y = 200: {:?}", line.rect); + assert!(line.rect[1] >= 36.0 - 0.5, "row stays within the bottom margin: {:?}", line.rect); + } + + // Reading upside down (180°) from x = 200: left margin is at x = 36. + let mut doc = text_page("BT /F1 12 Tf -1 0 0 -1 200 600 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 180° row to wrap at the left margin: {lines:?}"); + for line in &lines { + assert!((line.rect[2] - 200.0).abs() < 0.5, "row starts at x = 200: {:?}", line.rect); + assert!(line.rect[0] >= 36.0 - 0.5, "row stays within the left margin: {:?}", line.rect); + } + + // Slanted (45°) from (400, 600): baseline stops where it first reaches a 36 pt margin. + let (sin, cos) = 45.0_f64.to_radians().sin_cos(); + let mut doc = text_page(&format!("BT /F1 12 Tf {cos:.6} {sin:.6} {:.6} {cos:.6} 400 600 Tm (A single turned row) Tj ET", -sin)); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 45° slanted row to wrap at the margin: {lines:?}"); + for line in &lines { + assert!(line.rect[2] <= 600.0 && line.rect[3] <= 800.0, "slanted row stays on the page: {:?}", line.rect); + } + + // Moving a turned single row with `offset` reduces the room left before the margin. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 500 Tm (A single turned row) Tj ET"); + let moved = text::BlockStyle { offset: Some([0.0, 150.0]), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &moved).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "{lines:?}"); + for line in &lines { + assert!((line.rect[1] - 650.0).abs() < 0.5, "moved row starts at y = 650: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "moved row stops at the top margin: {:?}", line.rect); + } + + // A turned row that already starts inside the 36 pt margin keeps its own width instead of collapsing. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 770 Tm (Edge label) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some("Edge label updated"), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() >= 2, "{lines:?}"); +} + /// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold` /// …), so it has to be *measured* with that same face. Measuring the upright face while writing /// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722. @@ -4276,6 +4437,106 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } +#[test] +fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() { + // Each row has its own background box drawn immediately before its BT..ET, as cards and + // highlighted headings exported from LibreOffice or browsers do (#2377). + let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \ + 0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \ + 0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \ + 0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET"; + let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| { + ops.iter() + .position(|op| { + matches!(op.op.as_slice(), b"Tj" | b"TJ") + && op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle))) + }) + .unwrap() + }; + + // 1. Editing the title rows emits all rewritten rows after all four background fills. + let mut doc = text_page(card); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo"; + text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert_eq!(fills.len(), 4); + assert!( + shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3], + "rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}" + ); + + // 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2] + // and still before kept row 3, preserving reading order. + let mut doc = text_page(card); + let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert!( + shown_at(&ops, "WORKSHOP") > fills[2] + && shown_at(&ops, "pratica:") > fills[2] + && shown_at(&ops, "criterio") > fills[2] + && shown_at(&ops, "criterio") < shown_at(&ops, "complesso") + && shown_at(&ops, "complesso") > fills[3], + "rewritten prefix rows must follow their fills and precede the kept final row" + ); + + // 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3]. + let mut doc = text_page(card); + let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill, + "wrapped rows and pushed-down suffix row must follow all row background fills" + ); + + // 4. Single-style paragraph path across a CTM change between rows, with movement, underline, + // and a trailing unrelated line sharing the second row's BT..ET. + let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \ + q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \ + BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q"; + let mut doc = text_page(cross_ctm); + let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2); + text::rewrite_block( + &mut doc, + 0, + para, + Some("Updated first highlighted row and updated second highlighted row"), + &text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() }, + ) + .unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"), + "cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line" + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap(); + let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap(); + assert!( + (updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01, + "offset baseline in user space across CTM: {:?}", + updated_first.baseline() + ); + assert!( + (side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01, + "trailing line in shared BT keeps its tlm across CTM: {:?}", + side_footer.baseline() + ); +} + /// Small graphics-state oracle for synthetic streams: every tracked state change must /// have been restored when a probe XObject would draw. No external assets are involved. fn probe_states(doc: &Document) -> Vec, Vec>> { diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 916aaeadd9..e4eaaa641e 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -50,6 +50,10 @@ pub struct TextLine { pub visual_order: bool, /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, + /// Its box along and across its baseline, `[along0, across0, along1, across1]` (see + /// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is + /// narrower than `rect` turned into the same frame. + frame: [f64; 4], /// The content stream piece the line starts in, and its operators (indices into the page's /// streams parsed joined). stream: usize, @@ -470,12 +474,7 @@ impl TextLine { /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). fn frame_rect(&self) -> [f64; 4] { - let dir = self.direction(); - let r = self.rect; - [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])] - .into_iter() - .map(|p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) + self.frame } } @@ -493,6 +492,11 @@ fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) } +/// The box covering both `[x0, y0, x1, y1]` boxes. +fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] pub struct LineEdit { @@ -639,6 +643,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// Its box along and across its baseline (see [`TextLine::frame`]). + frame: [f64; 4], /// The boxes of its words (see [`TextLine::ink`]). ink: Vec<[f64; 4]>, baseline: f64, @@ -773,6 +779,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.frame = union(first.frame, shown.frame); first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); @@ -936,6 +943,13 @@ fn interpret( let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); let ink = words .iter() .map(|&(a, b)| { @@ -963,6 +977,7 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + frame, ink, baseline: start.1, start_x: start.0, @@ -1040,6 +1055,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1051,6 +1067,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1128,6 +1145,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1138,6 +1156,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1784,7 +1803,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the // line drawn on the same baseline go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, &[&target]); + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1816,7 +1836,10 @@ fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); for line in lines { - let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + let copy = targets.iter().any(|target| { + let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); + same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) + }); if copy { drop.extend(line.ops.iter().copied()); } @@ -1834,13 +1857,16 @@ fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bo /// Perpendicular distance between baselines, in user space. Horizontal text compares height; /// turned text compares the distance across the writing direction. fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let (dir, target_dir) = (line.direction(), target.direction()); + let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; + let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; let angle = target.rotation().to_radians(); let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; let dx = line.origin.x - target.origin.x; let dy = line.origin.baseline - target.origin.baseline; let offset = dx.mul_add(-sin, dy * cos).abs(); let size = line.size.min(target.size).max(1.0); - offset.is_finite() && offset < size * 0.3 + same_dir && same_size && offset.is_finite() && offset < size * 0.3 } /// The paragraphs on a page (0-based). @@ -2072,6 +2098,8 @@ fn rewrite_block_from( // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; + let last = members.last().copied().unwrap_or(first); + let emit = &last.origin; let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); // The size in text space: points ÷ the text-to-user scale. @@ -2132,7 +2160,7 @@ fn rewrite_block_from( // paragraph leaves the document as it was. The move is in page space, so it is taken back through // the CTM's linear part into the space the paragraph is drawn in. let movement = if dx != 0.0 || dy != 0.0 { - let c = o.ctm; + let c = emit.ctm; let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; @@ -2140,6 +2168,12 @@ fn rewrite_block_from( } else { None }; + let tm = if o.ctm == emit.ctm { + o.tm + } else { + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 + }; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { @@ -2161,9 +2195,27 @@ fn rewrite_block_from( }; t * o_state.scale * k }; - // A turned single line grows to fit, as far as the largest page PDF allows. + // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0) + let crop = p.crop(doc); + let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); + let (ux, uy) = first.direction(); + let tx = if ux > 1e-6 { + (crop[2] - 36.0 - x0) / ux + } else if ux < -1e-6 { + (crop[0] + 36.0 - x0) / ux + } else { + f64::INFINITY + }; + let ty = if uy > 1e-6 { + (crop[3] - 36.0 - y0) / uy + } else if uy < -1e-6 { + (crop[1] + 36.0 - y0) / uy + } else { + f64::INFINITY + }; + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(&advance).fold(width, f64::max).min(cap) } else { width }; @@ -2217,7 +2269,7 @@ fn rewrite_block_from( let n = pdfcraft_content::num; // In its own graphics state, so a new colour (or anything else) stops at the paragraph. let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, o.ctm)?); + block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); // Moved: a translation inside that state. if let Some((ax, ay)) = movement { block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); @@ -2232,7 +2284,7 @@ fn rewrite_block_from( state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; } block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); // Alignment: each line's offset from the left edge, in text space. let offset = |line: &str| -> f64 { let free = (width - advance(line)).max(0.0) / k; @@ -2315,7 +2367,7 @@ fn rewrite_block_from( }) .collect(); block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); block_ops.extend(colour); block_ops.push(Op::new("w", vec![n(size * 0.06)])); for (x0, x1, y) in underlines { @@ -2330,24 +2382,24 @@ fn rewrite_block_from( // second copy, an invisible text layer): the new text is all that may show. let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - // Text shown earlier in the same text object stays first: the paragraph then goes where it - // was, between two halves of the text object, so it keeps its place in reading order. - let start = first.ops.first().copied().unwrap_or(o.bt_op); + // Text shown earlier in the same text object stays first: the paragraph then goes where its + // last row was, after any backgrounds between the rows, or between two halves of the text object. + let start = last.ops.first().copied().unwrap_or(emit.bt_op); let split = ops - .get(o.bt_op..start) + .get(emit.bt_op..start) .unwrap_or_default() .iter() .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); // What the text after the paragraph expects: the state at its BT (or where it was split). - let after = if split { &o.state } else { &o.bt_state }; + let after = if split { &emit.state } else { &emit.bt_state }; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; + let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !drop.contains(&i)) diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519f..8e34e0bdf4 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -17,10 +17,6 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool { }) } -fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] -} - fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) } @@ -620,19 +616,20 @@ pub(super) fn rewrite_styled_block( let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's - // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = rewritten.first().copied().filter(|lead| { - lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9) - && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))) - }); + // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted + // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = + rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); let (members, lead) = match lead { Some(lead) if !kept_lines.is_empty() => (rewritten, lead), _ => { kept.iter_mut().for_each(|k| *k = false); - (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first) + let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) } }; + let o = &lead.origin; for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { @@ -668,8 +665,8 @@ pub(super) fn rewrite_styled_block( } let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?); - let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0]) + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); @@ -741,7 +738,7 @@ pub(super) fn rewrite_styled_block( if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; } - let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); tm[4] += ax; @@ -790,10 +787,10 @@ pub(super) fn rewrite_styled_block( if underlines.get(&item.1).copied().or(style.underline) == Some(true) { let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); @@ -810,7 +807,6 @@ pub(super) fn rewrite_styled_block( block_ops.push(Op::new("Q", vec![])); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &lead.origin; let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 147fc634ec..bf689af541 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -831,6 +831,11 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) } + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + /// The name shown on the tab and window: the document title when the document asks for it /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { @@ -2402,7 +2407,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } Edit::ReplaceText { page, quads, text, author, strike, caret } => { - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; @@ -3902,6 +3910,7 @@ impl Session { doc.info.keywords = info("Keywords"); doc.info.creator = info("Creator"); doc.info.producer = info("Producer"); + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); doc.info.file_size = bytes.len(); if !doc.signatures.is_empty() { let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); @@ -4078,11 +4087,16 @@ impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved { + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { return Ok(editor.saved.clone()); } let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1c473b7807..d5ffac5001 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -519,6 +519,63 @@ fn save_is_incremental_stamps_mod_date_and_rebases() { assert_eq!(reopened.revisions().len(), 3); } +/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when +/// nothing was edited, so the damage Document Properties reported as repaired was still in the +/// file. Save writes the rebuilt file in full; reopened, it needs no repair. +#[test] +fn a_repaired_file_is_rewritten_by_save_even_without_edits() { + let good = fixture(2); + // Every offset in the table is off by the inserted line: the chain reads but leads nowhere. + let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec(); + shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + // No cross-reference table at all. + let mut lost = good.clone(); + lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap()); + lost.extend_from_slice(b"startxref\n999999\n%%EOF\n"); + for (name, damaged) in [("shifted", shifted), ("lost", lost)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + assert!(!doc.dirty, "{name}: a repair is not an edit"); + let saved = s.save_bytes(id).unwrap(); + assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log()); + assert_eq!(reopened.revisions().len(), 1, "{name}"); + s.mark_saved(id, saved.clone(), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + // Sound now: saving again without edits writes the same bytes. + assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}"); + } +} + +/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full +/// to put the repair on disk would invalidate its signatures without a word. +#[test] +fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but + // the file's cross-reference data has to be rebuilt. + let mut damaged = signed.to_vec(); + damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n"); + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = t.get(tid).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log()); + assert!(doc.is_signed()); + assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits"); +} + #[test] fn undo_still_works_after_save() { let (mut s, id) = session_with(2); @@ -1186,6 +1243,22 @@ fn the_permissions_password_unlocks_an_open_document() { assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); } +#[test] +fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() { + // MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661). + let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap(); + let r = doc.trailer().reference(b"Encrypt").unwrap(); + let dict = doc.get(r).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict)); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted); + let plain = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); +} + #[test] fn unlocking_needs_the_permissions_password_not_the_open_password() { let mut s = Session::new(); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 16dd2b06d2..30299b2d76 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -114,6 +114,34 @@ const COL_TOL: f64 = 4.0; /// its columns, so a hostile page laid out as a staircase of blocks would otherwise make /// (blocks / 2)² cells. const MAX_COLS: usize = 63; +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) + .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + }) + .collect() +} /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -264,13 +292,20 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); es }; + let cont = continuations(blocks); let qual: Vec = rows .iter() .map(|row| { - let mut cs: Vec = row.iter().map(|(c, _)| *c).collect(); + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); cs.sort_unstable(); cs.dedup(); - cs.len() >= 2 + apart && cs.len() >= 2 }) .collect(); let mut i = 0; @@ -1186,6 +1221,75 @@ mod tests { assert_eq!(grid_cols(&grid(64)), 0); } + fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + } + + /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks + /// on one line. They are not table columns, even when several such lines share left edges. + #[test] + fn a_link_inside_a_sentence_is_not_a_table_column() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), + blue("http", 273.5, 710.3, 294.1, 724.9), + blue("s", 294.1, 709.8, 298.8, 725.8), + blue("://women", 298.8, 710.3, 348.0, 724.9), + blue("g", 348.1, 709.8, 353.7, 725.8), + blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), + block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), + block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), + blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), + block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), + block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), + block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), + ], + images: Vec::new(), + }; + let h = html(std::slice::from_ref(&p), "Regulations"); + assert!(!h.contains("
"), "running text became a table: {h}"); + for b in &p.blocks { + assert!(h.contains(&b.text), "{} was lost: {h}", b.text); + } + } + + /// #2806: lines that each run on into a link starting at the same place are sentences, not a + /// two-column table, even three in a row. + #[test] + fn sentences_that_run_on_into_links_are_not_a_table() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let mut blocks = Vec::new(); + for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { + let y = 700.0 - 16.0 * n as f64; + blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); + blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let h = html(std::slice::from_ref(&p), "Event"); + assert!(!h.contains("
"), "sentences became a table: {h}"); + assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); + } + + /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table + /// around it stays a table. + #[test] + fn a_superscript_beside_a_number_keeps_its_table() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let h = html(std::slice::from_ref(&p), "Prizes"); + assert!(h.contains("
"), "the prize table was lost: {h}"); + assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); + } + #[test] fn two_column_body_text_is_not_mistaken_for_a_table() { let para = |t: &str, x: f64, y: f64| Block { diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index 84801d5fd2..a5bb011e00 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -195,7 +195,7 @@ pub fn embed_mapping( // page; source subset programs stay untouched. let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), + Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), _ => None, }) }); @@ -206,7 +206,7 @@ pub fn embed_mapping( d.set(b"Subtype".to_vec(), Object::name("OpenType")); } d.set(b"PCFullFont".to_vec(), Object::Bool(true)); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, bytes.to_vec())))) + Object::Ref(doc.add(Object::Stream(Stream::flate(d, bytes)))) }); descriptor.set(if cff { b"FontFile3".to_vec() } else { b"FontFile2".to_vec() }, program); let desc = Object::Ref(doc.add(Object::Dict(descriptor))); @@ -264,15 +264,20 @@ struct Written { } impl Written { - /// Whether its program is `bytes`. The length it was written with is compared before decoding. + /// Whether its program is `bytes`. fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false }; - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) + matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) } } +/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was +/// written with is compared before decoding. +fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { + s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() + && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) +} + /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -379,4 +384,31 @@ mod tests { assert!(!fonts.contains(medium.as_bytes()), "{medium} is new"); assert_eq!(programs(&doc), 2); } + + #[test] + fn the_full_program_is_written_compressed_and_another_page_reuses_it() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = Document::new_empty(); + let mut first = Dict::new(); + embed(&mut doc, &mut first, bytes, "Inter").unwrap(); + let program = || { + doc.scan_objects() + .find_map(|(_, o)| match o.as_ref() { + Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() => Some(s.clone()), + _ => None, + }) + .unwrap() + }; + let stream = program(); + assert_eq!(stream.dict.name(b"Filter"), Some(b"FlateDecode".as_slice())); + assert!(stream.raw.len() < bytes.len(), "{} of {} bytes", stream.raw.len(), bytes.len()); + assert_eq!(stream.decoded().unwrap(), bytes); + // Another page's resources don't have it, so it is found among the document's objects. + let objects = doc.scan_objects().count(); + let mut second = Dict::new(); + embed(&mut doc, &mut second, bytes, "Inter").unwrap(); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one program for both pages"); + assert!(doc.scan_objects().count() > objects, "the second page gets its own resource"); + } } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index cd0d8358e5..a1f2ff2779 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -80,13 +80,19 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) let mut lines = Vec::new(); for para in text.split(['\n', '\r']) { let mut line = String::new(); + // No word is on the line yet; an empty word (a leading space) still starts it. + let mut fresh = true; for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width { + let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; + if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { line = candidate; + fresh = false; continue; } - if !line.is_empty() { + // Indentation alone is not a line: drop it before an over-long word. + if line.trim().is_empty() { + line.clear(); + } else { lines.push(std::mem::take(&mut line)); } for ch in word.chars() { @@ -95,6 +101,7 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } line.push(ch); } + fresh = line.is_empty(); } lines.push(line); } @@ -256,6 +263,20 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn wrap_keeps_leading_spaces() { + assert_eq!(wrap(" indented", 11.0, 500.0), [" indented"]); + assert_eq!(wrap(" a b\n c", 11.0, 500.0), [" a b", " c"]); + assert_eq!(wrap("", 11.0, 500.0), [""]); + // A single leading space before a word that must be broken leaves no blank first line. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.is_empty()), "{broken:?}"); + // So does indentation of two or more spaces: no whitespace-only line, same as one space. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.trim().is_empty()), "{broken:?}"); + assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); + } + #[test] fn win_ansi_encodability_matches_the_encoder() { for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index b50ed4c1ed..129f7fa751 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -587,7 +587,9 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { return None; } let lower = t.to_lowercase(); - let pm = lower.contains("pm") || lower.ends_with('p'); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let pm = lower.contains("pm") || bare_p; let am = lower.contains("am"); // Tokens: numbers and month names. let mut nums: Vec = Vec::new(); @@ -1285,3 +1287,25 @@ mod never_crash_tests { let _ = format_date(dt, "dddd ddd"); } } + +#[cfg(test)] +mod pm_marker_tests { + use super::*; + + /// A month name ending in "p" ("Sep") is not a PM marker; only a "p" attached to a time is. + #[test] + fn month_name_ending_in_p_is_not_pm() { + let dt = parse_date("2026 05 Sep", "yyyy dd mmm HH:MM").expect("valid date"); + assert_eq!((dt.m, dt.d, dt.hh, dt.mm), (9, 5, 0, 0)); + let f = Format::Date("yyyy dd mmm HH:MM".into()); + assert_eq!(format_value(&f, "2026 05 Sep"), "2026 05 Sep 00:00"); + assert!(keystroke(&f, "date", "2026 05 Sep").is_ok()); + } + + #[test] + fn bare_p_after_a_time_is_still_pm() { + assert_eq!(parse_date("05/09/2026 5:30p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + } +} diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 059276e936..cf82eff466 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -712,6 +712,13 @@ fn num_text(n: f64) -> String { if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } } +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + /// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. fn parse_num(s: &str) -> Option { let t = s.trim(); @@ -2166,7 +2173,7 @@ impl Interp<'_> { let v = self.arg_num(args, 0)?; let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = format!("{v:.prec$}"); + let text = fixed(v, prec); Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) } "substr" => { @@ -2255,7 +2262,7 @@ impl Interp<'_> { let num = self.to_num(&v); let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); let grouped = pic.contains(','); - let mut text = format!("{:.decimals$}", num.abs()); + let mut text = fixed(num.abs(), decimals); if grouped { let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); let mut g = String::new(); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 55fa45760d..4fe5fd2906 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -636,6 +636,19 @@ mod formcalc { assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6"); } + #[test] + fn str_and_format_round_exact_ties_away_from_zero() { + assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); + assert_eq!(calc("Str(0.5, 3, 0)"), " 1"); + assert_eq!(calc("Str(-2.5, 4, 0)"), " -3"); + assert_eq!(calc("Str(1.125, 6, 2)"), " 1.13"); + assert_eq!(calc(r#"Format("9", 2.5)"#), "3"); + assert_eq!(calc(r#"Format("9.99", 0.125)"#), "0.13"); + assert_eq!(calc(r#"Format("9.9", 0.25)"#), "0.3"); + assert_eq!(calc(r#"Format("9.9", -0.25)"#), "-0.3"); + assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 133bc03ecf..e120d486b1 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -113,13 +113,17 @@ pub fn pages(doc: &Document) -> Result, OrganizeError> { pub fn page_rotation(doc: &Document, index: usize) -> Result { let pages = walk(doc)?; let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?; - Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360)) + Ok(resolved_rotation(doc, attrs).rem_euclid(360)) } pub fn page_count(doc: &Document) -> Result { Ok(walk(doc)?.len()) } +fn resolved_rotation(doc: &Document, attrs: &Dict) -> i64 { + attrs.get(b"Rotate").and_then(|value| doc.resolve(value).as_int()).unwrap_or(0) +} + /// Rebuild the page tree as one flat node with `order` as its kids (attributes pushed down). fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> { let root = pages_root(doc)?; @@ -201,7 +205,10 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; - let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0); + // Repeated indices must see earlier edits instead of the page walk's snapshot. + let current_page = doc.get(*page); + let current_attrs = current_page.as_dict().filter(|attrs| attrs.get(b"Rotate").is_some()).unwrap_or(inherited); + let current = resolved_rotation(doc, current_attrs); let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } @@ -556,6 +563,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) /// Document information keys editable in Document Properties ▸ Description. pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in +/// `/Info` is a custom property (Document Properties ▸ Custom). +pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"]; + +/// Longest custom property name accepted, in bytes: the name length every reader is asked to +/// accept (§7.3.5, Annex C). +pub const MAX_CUSTOM_KEY_LEN: usize = 127; + +/// The custom document properties: every `/Info` entry whose key the spec does not name and whose +/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace +/// padding and entries that are only padding left out. At most 256 are listed. +pub fn custom_info(doc: &Document) -> Vec<(String, String)> { + let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() }; + let Some(d) = info.as_dict() else { return Vec::new() }; + d.iter() + .filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice())) + .filter_map(|(k, v)| { + // Padding (\0s and whitespace, in any mix) is not part of the value, and a value that + // is only padding is no property. Filtered before the cap, so blank entries can't + // crowd real ones out. + let text = doc.resolve(v).as_string()?.to_text(); + let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + (!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned())) + }) + .take(256) + .collect() +} + +/// Whether `name` may be a custom property's key: not one the spec names (those are the +/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the +/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`), +/// so every reader shows the same name. Acrobat refuses the standard and empty names too. +pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { + if name.is_empty() { + return Err(OrganizeError::Invalid("a custom property needs a name".into())); + } + if STANDARD_INFO_KEYS.contains(&name) { + return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one"))); + } + if name.len() > MAX_CUSTOM_KEY_LEN { + return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes"))); + } + if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) { + return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}"))); + } + Ok(()) +} + /// Read a document-information entry as text. pub fn info(doc: &Document, key: &str) -> Option { let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; @@ -568,6 +623,9 @@ pub fn info(doc: &Document, key: &str) -> Option { /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is /// changed (or removed) to match (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + if !STANDARD_INFO_KEYS.contains(&key) { + check_custom_key(key)?; + } let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); set_info_entry(doc, key.as_bytes(), entry)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index c857cd4da7..32a386d221 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -16,6 +16,53 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() { assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3))); } +#[test] +fn indirect_page_rotation_is_read_and_updated_from_its_effective_value() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(270, 90, 0), (-90, 90, 0), (large, 90, 90), (90, -180, 270)] { + for level in 0..3 { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(current)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = open(bytes); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let initial = page_rotation(&doc, 0).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(*doc.get(rotation), Object::Int(current), "shared rotation object unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + let pdf = hayro_syntax::Pdf::new(saved).unwrap(); + let dimensions = if expected % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) }; + assert_eq!(pdf.pages()[0].render_dimensions(), dimensions, "written PDF has the correct orientation"); + assert_eq!(page_rotation(&reopened, 0).unwrap(), expected); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if level == 0 { 90 } else { current.rem_euclid(360) }, "sibling unchanged"); + assert_eq!(page_rotation(&reopened, 2).unwrap(), if level == 2 { current.rem_euclid(360) } else { 90 }, "other subtree unchanged"); + } + assert_eq!(initial, current.rem_euclid(360), "effective indirect rotation at level {level}"); + } + } +} + +#[test] +fn malformed_indirect_rotations_keep_the_zero_fallback() { + for value in [Object::Null, Object::name("invalid"), Object::Ref(ObjRef::new(99_999, 0))] { + let mut doc = open(fixture()); + let rotation = doc.add(value); + let page = pages(&doc).unwrap()[0].obj; + doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 0); + rotate_pages(&mut doc, &[0], 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 90); + } +} + #[test] fn rotate_rejects_angles_that_are_not_multiples_of_90() { // The page tree only turns in quarter steps, so anything else used to be silently truncated @@ -336,6 +383,59 @@ fn page_transitions_are_set_removed_and_checked() { assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0)); } +/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not +/// name, set and cleared like the standard ones, listed apart from them, and with the names +/// Acrobat refuses refused here too. +#[test] +fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { + let mut doc = open(fixture()); + assert!(custom_info(&doc).is_empty()); + set_info(&mut doc, "Title", "Report").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + set_info(&mut doc, "Revision", "7").unwrap(); + let mut listed = custom_info(&doc); + listed.sort(); + assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom"); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + set_info(&mut doc, "Project", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save"); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report")); + + for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] { + assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused"); + } + let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1); + assert!(check_custom_key(&long).is_err()); + for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] { + assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name"); + } + let mut doc = open(fixture()); + assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names"); + assert!(custom_info(&doc).is_empty()); +} + +/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only +/// padding is not listed, and such entries don't count towards the 256 listed: a real property +/// after 300 blank ones is still there. +#[test] +fn custom_document_properties_drop_padding_before_the_cap() { + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "Report").unwrap(); + let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") }; + let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false }); + doc.update_dict(info_ref, |d| { + d.set(b"Mixed".to_vec(), text(" \0Lab\0 ")); + d.set(b"Pad".to_vec(), text(" \0 \0")); + for i in 0..300 { + d.set(format!("Blank{i}").into_bytes(), text(" ")); + } + d.set(b"Keep".to_vec(), text("yes")); + }) + .unwrap(); + assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]); +} + /// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the /// same title and author in `/Info`, as LibreOffice writes them. fn doc_with_xmp(xmp: &[u8]) -> Document { @@ -2676,3 +2776,35 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo .join(); assert!(run.is_ok(), "the copy must not overflow the stack"); } + +#[test] +fn rotate_repeated_indices_accumulate_from_the_current_page_value() { + for level in 0..3 { + for indices in [vec![0, 0], vec![0, 1, 0]] { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(90)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let mut doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let original_labels = labels(&doc); + rotate_pages(&mut doc, &indices, 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 270, "level {level}, indices {indices:?}"); + assert_eq!(*doc.get(rotation), Object::Int(90), "shared source angle is unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + assert_eq!(page_rotation(&reopened, 0).unwrap(), 270); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if indices.len() == 3 { 180 } else { 90 }); + assert_eq!(page_rotation(&reopened, 2).unwrap(), 90); + assert_eq!(labels(&reopened), original_labels); + let independent = hayro_syntax::Pdf::new(saved).unwrap(); + assert_eq!(independent.pages()[0].render_dimensions(), (400.0, 300.0)); + } + } + } +} diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index 71bc19f59c..fdf088c0fa 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -27,6 +27,20 @@ pub fn create_staging( name: &str, form: StagingName, suffixes: impl IntoIterator, +) -> std::io::Result<(PathBuf, std::fs::File)> { + create_staging_as(dir, name, form, suffixes, None) +} + +/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable +/// and writable by its owner only and then given `keep`, so its mode is never more open than that +/// of the file it will replace, not even while it is still empty (a handle opened then would keep +/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +fn create_staging_as( + dir: &Path, + name: &str, + form: StagingName, + suffixes: impl IntoIterator, + keep: Option<&std::fs::Permissions>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -42,8 +56,22 @@ pub fn create_staging( StagingName::SuffixThenTag => format!(".{stem}.{suffix:016x}.pdfcraft-tmp"), StagingName::TagThenSuffix => format!(".{stem}.pdfcraft-{suffix:016x}.tmp"), }); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { - Ok(file) => return Ok((tmp, file)), + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if keep.is_some() { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + match options.open(&tmp) { + Ok(file) => { + if let Some(keep) = keep { + // A volume that can't change modes (FAT, some network shares) leaves the file + // owner-only: stricter than the file it replaces, never looser. + let _ = file.set_permissions(keep.clone()); + } + return Ok((tmp, file)); + } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} // Windows reports a folder at the name as "access denied"; it is taken all the same. Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {} @@ -69,7 +97,9 @@ pub fn staging_suffixes() -> impl Iterator { /// crash, a full disk or a refused flush never leaves a half-written file where a good one was, /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own -/// (`sync_all`); parent-directory durability after a power loss is not promised. +/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file +/// that is replaced keeps its permissions, so a private file stays private; a new file gets the +/// usual mode for a new file. /// On macOS, accessible extended attributes of an existing regular destination are copied /// before flushing; a copy failure refuses the save rather than losing metadata (#2658). pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> { @@ -78,7 +108,7 @@ pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io /// [`write_atomically`] with the staging-name suffixes and the flush given, so tests can plant /// names and refuse flushes. A failure at any step removes the staging file and leaves the -/// target as it was. +/// target as it was. A read-only target is refused before anything is written. pub fn write_atomically_with( path: &Path, bytes: &[u8], @@ -87,9 +117,20 @@ pub fn write_atomically_with( sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, ) -> std::io::Result<()> { use std::io::Write as _; + // A read-only file is not replaced. Windows refuses the rename anyway, but on Unix a rename + // ignores the target's permissions: the file would be overwritten without a word, and come + // out writable (#2658). + if std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As")); + } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - let (tmp, file) = create_staging(dir, name, form, suffixes)?; + // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + #[cfg(unix)] + let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + #[cfg(not(unix))] + let keep = None; + let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?; let result = (|| { // Closed at the end of the block, before the rename (required on Windows). { @@ -235,6 +276,33 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would + /// replace it) as on Windows (where the rename fails). + #[test] + fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn running_out_of_suffixes_refuses_and_creates_nothing() { let dir = test_dir("empty"); @@ -456,4 +524,34 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); } + + /// #1790: a replaced file keeps its permissions, and its staging file already has them while + /// it is written; a new file gets the same mode as any other new file. + #[cfg(unix)] + #[test] + fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 4dd098d58f..0c9228ac02 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -271,6 +271,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) { content_pass(doc, text, layers, true)?; } + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + let listed = |doc: &Document| -> std::collections::HashSet { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() + }; + let listed_before = listed(doc); if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -291,16 +297,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc) + .iter() + .flat_map(|p| annots_of(doc, &p.dict)) + .filter_map(|a| a.as_ref()) + .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) + .collect(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); - let mut removed: Vec = Vec::new(); - for a in &list { - if let Some(r) = a.as_ref() - && doc.get(r).as_dict().is_some_and(drop_annot) - { - removed.push(r); - } - } let mut kept = Vec::new(); for a in list { let Some(r) = a.as_ref() else { @@ -340,6 +346,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); + crate::tags::drop_object_refs(doc, &gone)?; let off = hidden_layers(doc); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = sub(doc, &cat, b"Names"); diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 02aae12aa3..b70cf23ab9 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -202,3 +202,111 @@ pub(crate) fn clean( } Ok(n) } + +/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`. +fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { + doc.resolve(o) + .as_dict() + .is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r))) +} + +/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their +/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the +/// reference keeps the annotation, its URL or text included, in a full rewrite after the page +/// no longer lists it. Returns how many references were dropped. +pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result { + drop_object_refs_within(doc, gone, MAX_STRUCT_REFS) +} + +/// The most structure-tree references [`drop_object_refs`] visits. +const MAX_STRUCT_REFS: usize = 2_000_000; + +/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full +/// could keep a reference the walk never reached, so then the whole structure tree goes instead +/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of +/// the document's tags. +pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result { + if gone.is_empty() { + return Ok(0); + } + let Some(root) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned())) + else { + return Ok(0); + }; + let mut todo: Vec = root.get(b"K").cloned().into_iter().collect(); + let mut seen = HashSet::new(); + let mut hits: Vec = Vec::new(); + while let Some(k) = todo.pop() { + match k { + Object::Array(a) => todo.extend(a), + Object::Ref(r) => { + if !seen.insert(r) { + continue; + } + if seen.len() > limit { + let Some(catalog) = doc.root() else { return Ok(0) }; + doc.update_dict(catalog, |c| { + c.remove(b"StructTreeRoot"); + c.remove(b"MarkInfo"); + })?; + return Ok(0); + } + let object = doc.get(r); + if let Object::Array(a) = &*object { + todo.extend(a.iter().cloned()); + continue; + } + let Some(d) = object.as_dict() else { continue }; + if !d.contains(b"S") { + continue; + } + let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) { + Some(o) => match &*o { + Object::Array(a) => a.clone(), + _ => d.get(b"K").cloned().into_iter().collect(), + }, + None => Vec::new(), + }; + if kids.iter().any(|x| refers_to(doc, x, gone)) { + hits.push(r); + } + todo.extend(kids); + } + _ => {} + } + } + let mut n = 0; + for r in hits { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let kids = d.get(b"K").map(|k| doc.resolve(k)); + let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); + let list: Vec = match kids.as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => d.get(b"K").cloned().into_iter().collect(), + None => Vec::new(), + }; + let before = list.len(); + let keep: Vec = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect(); + n += before - keep.len(); + doc.update_dict(r, |e| { + if array { + e.set(b"K".to_vec(), Object::Array(keep.clone())); + } else if let Some(k) = keep.first().cloned() { + e.set(b"K".to_vec(), k); + } else { + e.remove(b"K"); + } + // An element left with nothing to tag keeps no text describing what it tagged: + // a link's alternate text often repeats the URL, an annotation's its comment. + if keep.is_empty() { + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); + } + } + })?; + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a4e3530aba..a7e11bcaf1 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -708,6 +708,140 @@ fn hidden_information_is_counted_and_removed() { assert!(shown, "the field's value stays visible as page content"); } +/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags +/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the +/// annotations, the URL and the comment text included, while the page no longer lists them. +#[test] +fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog, with a structure tree + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "13 0 R").into_bytes(), + // 6 link, 7 comment + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR), + // 12 annotation element (OBJR by reference, as K's only kid) + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), + // the link element's alternate text repeats the URL + b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), + widths(), + b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); + // Without object streams, whose compression would hide the strings from the search. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(See the site)"), "the search sees the saved objects"); + assert!(!has(b"secret.example"), "the removed link's URL survived the save"); + assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); + // The tagged paragraph and its content stay; the elements lose only the references. + let saved = Document::open(Arc::new(bytes)).unwrap(); + assert!(content(&saved, 0).contains("(See the site)")); + let objrs = saved.object_numbers().into_iter().filter(|&n| { + let o = saved.get(ObjRef::new(n, saved.generation(n))); + let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR")); + let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { + Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))), + _ => false, + }); + direct || nested + }); + assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left"); + let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); +} + +/// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the +/// structure tree too (their field value with them), and a pop-up listed on another page than +/// its removed comment goes with it. +#[test] +fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog with a form and a structure tree + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(), + // 3 page 1: a text field's widget and a comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + // 6 the field and its widget, with an appearance + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(), + // 7 comment, its pop-up (15) listed on page 2 + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(), + // 8 struct tree root, 9 document, 10 form element tagging the widget + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"), + widths(), + // 13 page 2, listing the pop-up of page 1's comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(), + // 16 an unrelated link on page 2 stays + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]; + let mut doc = pdf(objs); + remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap(); + let page2 = pdfcraft_model::pages(&doc)[1].dict.clone(); + let left: Vec = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default(); + assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment"); + let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1)); + assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let field_left = saved.object_numbers().into_iter().any(|n| { + saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") || d.contains(b"FT")) + }); + assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); +} + +/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a +/// reference the walk never reached could keep a removed annotation. Then the whole tree goes +/// (with `/MarkInfo`), rather than reporting success with references left behind; under the +/// limit only the reference goes. +#[test] +fn a_structure_tree_too_large_to_walk_is_removed_whole() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 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] >>".to_vec(), + // 4: the removed link + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5 root, 6 document, 7 section, 8 link element with the OBJR + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into(); + let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1); + assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays"); + + let mut doc = pdf(objs); + crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap(); + assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole"); + assert!(!catalog(&doc).contains(b"MarkInfo")); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more"); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3c3988b7b5..0979f3d38a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -56,6 +56,9 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// Custom document properties: `/Info` entries with a key the spec does not name and a text + /// value, as (name, text), in the dictionary's order (at most 256). + pub custom: Vec<(String, String)>, pub pdf_version: String, pub file_size: usize, pub encrypted: bool, @@ -616,6 +619,23 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + const STANDARD: [&[u8]; 9] = + [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; + // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, + // trimmed and without empty values): text strings only, so a name value is not a + // custom property, and the cap counts listed entries. + info.custom = d + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) + .filter_map(|(k, v)| match self.resolve(v) { + Object::String(bytes, _) => { + let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); + (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) + } + _ => None, + }) + .take(256) + .collect(); } let Ok(catalog) = self.doc.catalog() else { return }; info.tagged = catalog @@ -678,6 +698,12 @@ impl<'a> Inspector<'a> { } info.has_javascript |= names.get(b"JavaScript").is_ok(); } + if let Ok(action) = catalog.get(b"OpenAction") + && let Some(dict) = self.dict(action) + && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) + { + info.has_javascript = true; + } } // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -2302,6 +2328,27 @@ trailer << /Root 1 0 R >> ); } + const CUSTOM_INFO: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF"; + + #[test] + fn custom_document_properties_are_listed_apart_from_the_standard_ones() { + let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); + assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); + let mut custom = info.custom.clone(); + custom.sort(); + // Revision is a number, not text, so it is not listed; Trapped is a standard entry. + assert_eq!( + custom, + [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] + ); + } + #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); @@ -2982,3 +3029,49 @@ trailer << /Size 7 /Root 1 0 R >> assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown"); } } + +#[cfg(test)] +mod open_action_javascript_tests { + use std::sync::Arc; + + use super::inspect; + + /// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller). + fn pdf_with_open_action(action: &[u8]) -> Vec { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>", + action, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + 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{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + #[test] + fn open_action_javascript_sets_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript"); + } + + #[test] + fn open_action_goto_does_not_set_has_javascript() { + let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>"); + let info = inspect(Arc::new(bytes), None).expect("inspects"); + assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 855361f6f3..00ff0eb3a5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -446,6 +446,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// The selection whose action bar was closed by its Copy button: the bar stays hidden + /// until the selection changes, as it does once a markup button consumes the selection. + actions_dismissed: Option, /// The drag in progress started a text selection: the wheel scrolls the page during it /// (#721), which egui's scroll area refuses while anything is dragged. selection_drag: bool, @@ -735,6 +738,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + actions_dismissed: None, selection_drag: false, clicks: 0, last_press_time: 0.0, @@ -1048,6 +1052,7 @@ impl DocView { pub(crate) fn clear_selection(&mut self) { self.selection = None; + self.actions_dismissed = None; } /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). @@ -2190,11 +2195,20 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { view.open_find(); } + let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); + let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); + // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview + // (#2669): ahead of the text-input check, but only while there is a search to step. + if view.find.is_some() { + if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { + view.find_step(false); + } else if pressed(cmd(Key::G)) { + view.find_step(true); + } + } if ctx.egui_wants_keyboard_input() { return; } - let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); - let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s)); // Rotation first: egui matches ⌘+ loosely with respect to Shift. if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus)) || pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals)) @@ -2235,11 +2249,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.fit = Fit::Width; view.goto = Some((view.current, 0.0)); } - if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) { - view.find_step(false); - } else if pressed(cmd(Key::G)) { - view.find_step(true); - } if pressed(cmd(Key::OpenBracket)) { view.view_history(false); } @@ -2446,9 +2455,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // When not scrolling continuously, only one row is shown at a time: the current page, or the // current two-page spread (with the cover rule). These indices drive paging, which pages are // drawn, and the content height (#752). Empty for a document with no pages. - let row: Vec = if rects.is_empty() { - Vec::new() - } else { + let shown_row = |view: &DocView| -> Vec { + if rects.is_empty() { + return Vec::new(); + } let last = rects.len() - 1; let cur = view.current.min(last); match view.layout { @@ -2460,6 +2470,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } }; + let row = shown_row(view); let middle_gesture = view.auto_scroll.blocks_input(); // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on @@ -2560,6 +2571,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view); let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; let (y_shift, content_h) = if view.continuous { (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) @@ -2618,13 +2632,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let r = rects[page.min(rects.len() - 1)]; let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0))); - } else if edge_turned { - // The row above was laid out for the page before the turn: place the new one next frame. - ui.ctx().request_repaint(); } else if let Some((page, frac)) = view.goto.take() { let page = page.min(rects.len() - 1); let r = rects[page]; - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0)); + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - GAP + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0).min(max_y)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. + let (x, w) = (view.last_scroll.x, avail.width()); + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } } let ppp = ui.ctx().pixels_per_point(); let scale = view.render_scale(ppp); @@ -3250,6 +3277,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if resp.drag_started() && press_here && !over_link { + // A drag replaces the selection without passing through none, so a + // re-drag over text whose bar Copy closed would keep the bar hidden. + view.actions_dismissed = None; view.selection_drag = true; let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); @@ -3334,6 +3364,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if tool == QuickTool::Select && !consumed { crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); } + crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); comments::page_after_text(&resp, &pcx, view); if tool == QuickTool::Redact && can_modify { @@ -3666,6 +3697,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.last_scroll = scroll_offset; let pan_held = now_settle < view.pan_hold_until; let (mut wanted, visible_now) = out.inner; + // Single-page and two-page views draw only the current row, so the next one was first asked + // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, + // as continuous scrolling does for the pages just off screen. + if !view.continuous + && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last()) + { + let n = if view.layout == PageLayout::Single { 1 } else { 2 }; + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) { + let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue }; + // As drawn: the whole page at this scale, or the backdrop of a tiled page. + let side = p.width.max(p.height).max(1.0); + wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) { + let bs = BASE_SIDE.min(max_side) / side; + (i, bs, scale_tag(bs), None) + } else { + (i, scale, tag, None) + }); + } + } let cur = view.current; wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some())); // Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page @@ -3956,7 +4007,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if copy_selected { app.execute("edit.copy"); } - quick_bar(app, avail, ui); + // Read mode hides the tools (#2646). + if app.mode != crate::Mode::Read { + quick_bar(app, avail, ui); + } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); } @@ -3983,7 +4037,13 @@ fn selection_actions( if ui.input(|input| input.pointer.is_decidedly_dragging()) { return false; } - let Some(selection) = view.selection else { return false }; + let Some(selection) = view.selection else { + view.actions_dismissed = None; + return false; + }; + if view.actions_dismissed == Some(selection) { + return false; + } let Some(text) = view.texts.get(&selection.page) else { return false }; let Some(xf) = view.page_xform(selection.page) else { return false }; let Some(last) = selection.rects(text).last().copied() else { return false }; @@ -4020,6 +4080,8 @@ fn selection_actions( }); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { + // Copying closes the bar but keeps the words selected, for ⌘C or a second action. + view.actions_dismissed = Some(selection); return true; } let Some((page, quads)) = view.selection_quads(info) else { return false }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0de1c6a7ea..981db214b2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -354,17 +354,20 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let ends = doc.revision_ends(); if ends.len() > 1 { ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + open_revision = Some(n); + } } - } + }); }); ui.end_row(); } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 0e5307f0e9..4d1f723c8a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -53,6 +53,12 @@ impl PdfCraftApp { let Some(doc) = self.session.get(id) else { return true }; let info = &doc.info; let view = &mut self.views[i]; + // A picture picked with the Select tool may be the one this edit removed or moved: + // drop its highlight now, not on the next frame (which only comes with the next input). + view.picked_image = None; + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } if matches!(edit, Edit::AddAnnotation(_)) { // A released drawing's raster can come now: it will have the annotation. view.comments.drawings_committed(); @@ -990,13 +996,15 @@ fn is_form_edit(edit: &Edit) -> bool { /// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the /// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails, /// because Outlook can still hold the file, or appears to work while the edits stay out of the -/// email and are deleted with the cache. Matched on either separator and in any case, so it can -/// be tested on every platform. +/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads//` in +/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on +/// either separator and in any case, so it can be tested on every platform. pub(crate) fn is_mail_temp_copy(path: &str) -> bool { let p = path.replace('\\', "/").to_ascii_lowercase(); p.contains("/inetcache/content.outlook/") || p.contains("/temporary internet files/content.outlook/") || p.contains("/microsoft.outlookforwindows") + || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } /// Write via a temporary file in the same directory and rename over the target, so a crash or @@ -1244,8 +1252,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Windows refuses to replace a read-only file, so the rename fails: the staging file must not - /// be left behind. + /// A read-only target is refused before anything is staged, so no staging file is left + /// behind. #[cfg(windows)] #[test] fn a_failed_save_removes_the_staging_file() { @@ -1256,7 +1264,7 @@ mod tests { perms.set_readonly(true); std::fs::set_permissions(&target, perms.clone()).unwrap(); let e = write_atomically_with(&target.to_string_lossy(), b"NEW", [7]).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "the rename failed: {e}"); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "a read-only target is refused: {e}"); assert_eq!(read(&target), "OLD"); assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); #[allow(clippy::permissions_set_readonly_false)] @@ -1314,6 +1322,16 @@ mod tests { } } + #[test] + fn apple_mail_attachment_copies_are_recognised() { + for p in [ + "/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf", + "/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf", + ] { + assert!(is_mail_temp_copy(p), "{p}"); + } + } + #[test] fn ordinary_paths_are_not_mail_copies() { for p in [ @@ -1321,6 +1339,8 @@ mod tests { r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf", r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf", "/home/me/Documents/Quote.pdf", + "/Users/me/Downloads/Mail Downloads/booking.pdf", + "/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf", "Quote.pdf", ] { assert!(!is_mail_temp_copy(p), "{p}"); diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index a05aefe2d2..53e4bdf158 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -9,12 +9,14 @@ use crate::PdfCraftApp; use crate::canvas::{DocView, PageXform}; use pdfcraft_render::DocInfo; -/// The selected picture: page, index in `page_images` and its box in user space. +/// The selected picture: page, index in `page_images` and its box in user space, and the +/// document's edit generation when it was picked (any later edit may have removed or renumbered it). #[derive(Clone, Copy, Debug, PartialEq)] pub struct PickedImage { pub page: usize, pub index: usize, pub rect: [f64; 4], + pub generation: u64, } const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); @@ -43,8 +45,18 @@ pub(crate) fn page_input( let (vx, vy) = xf.screen_to_view(p); let u = info_page.view_to_user(vx, vy); let at = [f64::from(u[0]), f64::from(u[1])]; + let generation = doc.edit_generation(); view.picked_image = - doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect })); + doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation })); +} + +/// Forget the selected picture once the document has been edited since it was picked: Edit text & +/// images (or Undo) may have deleted or moved it, and its highlight would stay behind on an empty +/// spot of the page. +pub(crate) fn drop_stale(doc: &pdfcraft_engine::Document, view: &mut DocView) { + if view.picked_image.is_some_and(|p| p.generation != doc.edit_generation()) { + view.picked_image = None; + } } /// The selected picture's highlight on page `page`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 725bcad4b0..268214b161 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ pub mod updates; mod wheel_pager; mod widgets; pub mod window_controls; +#[cfg(not(target_arch = "wasm32"))] +pub mod window_state; #[cfg(all(test, not(target_arch = "wasm32")))] mod open_worker_tests; @@ -744,6 +746,9 @@ pub struct PdfCraftApp { dialog_epoch: u64, /// The egui context, for commands that change window or theme state. ctx: Option, + /// The window's size and place kept across a minimized close (desktop). + #[cfg(not(target_arch = "wasm32"))] + pub window_state: window_state::WindowState, styled: bool, fonts_ready: bool, /// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`). @@ -1033,6 +1038,8 @@ impl PdfCraftApp { dialog_seen: None, dialog_epoch: 0, ctx: None, + #[cfg(not(target_arch = "wasm32"))] + window_state: Default::default(), styled: false, fonts_ready: false, fonts_cjk: theme::CjkPreference::Default, @@ -2162,6 +2169,11 @@ impl PdfCraftApp { } else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { self.put_down_edit_text(); } + // Read mode hides the quick toolbar, so no other tool may stay active there. Opening a + // document with Read as the default mode comes here without `switch_mode`. + if mode == Mode::Read { + self.quick_tool = QuickTool::Select; + } } /// Back to the Select tool from an editing tool. A paragraph still open in the editor is @@ -2179,6 +2191,7 @@ impl PdfCraftApp { /// Switches workspace because the user chose to (mode bar, Read mode command). Entering Read /// mode closes the right panel, as Acrobat's Read mode hides its panes, and leaving it reopens /// that panel. The rail stays in Read mode, and its buttons open panels there too (#932). + /// Read mode hides the quick toolbar, so entering it also puts the current tool down. pub(crate) fn switch_mode(&mut self, mode: Mode, select_tools: bool) { let was_read = self.mode == Mode::Read; if select_tools { @@ -2188,6 +2201,12 @@ impl PdfCraftApp { } if mode == Mode::Read && !was_read { self.right_before_read = self.right.take(); + // An editing tool is put down so its open paragraph is applied, as `select_mode` does. + if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { + self.put_down_edit_text(); + } else { + self.quick_tool = QuickTool::Select; + } } else if mode != Mode::Read && was_read { let before = self.right_before_read.take(); if self.right.is_none() { @@ -2920,11 +2939,19 @@ impl eframe::App for PdfCraftApp { } fn save(&mut self, storage: &mut dyn eframe::Storage) { + // eframe has just stored the window as it is now: not while it is minimized. + #[cfg(not(target_arch = "wasm32"))] + { + let minimized = self.ctx.as_ref().is_some_and(|c| c.input(|i| i.viewport().minimized == Some(true))); + self.window_state.keep(storage, minimized); + } storage.set_string("pdfcraft", self.persist()); } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); + #[cfg(not(target_arch = "wasm32"))] + self.window_state.repair(ctx); self.dialog_epoch(); // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 4a60c40a6e..d91a69f937 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -28,8 +28,10 @@ pub struct PageRange { } impl Default for PageRange { + /// From the first page to the last: `to` starts past any page count, and the range field + /// shows (and clamps it to) the document's last page. fn default() -> Self { - Self { all: true, from: 1, to: 1, subset: Subset::All } + Self { all: true, from: 1, to: usize::MAX, subset: Subset::All } } } @@ -53,9 +55,6 @@ impl PageRange { widgets::radio_value(ui, &mut self.all, false, tl!("Pages from")); ui.add_enabled(!self.all, egui::DragValue::new(&mut self.from).range(1..=count.max(1))); ui.label(tl!("to")); - if self.to == 1 && self.from == 1 { - self.to = count.max(1); - } ui.add_enabled(!self.all, egui::DragValue::new(&mut self.to).range(1..=count.max(1))); ui.label(tl!("Subset")); let label = match self.subset { @@ -519,6 +518,11 @@ mod tests { assert_eq!(r.pages(6), [1, 3, 5]); let r = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() }; assert_eq!(r.pages(5), [0, 2, 4]); + // Not all pages: by default up to the last one; a single page is a range too. + let r = PageRange { all: false, ..PageRange::default() }; + assert_eq!(r.pages(4), [0, 1, 2, 3]); + let r = PageRange { all: false, from: 1, to: 1, subset: Subset::All }; + assert_eq!(r.pages(38), [0]); } #[test] diff --git a/crates/ui-egui/src/tab_context.rs b/crates/ui-egui/src/tab_context.rs index aa16ef7105..7cc502722b 100644 --- a/crates/ui-egui/src/tab_context.rs +++ b/crates/ui-egui/src/tab_context.rs @@ -55,9 +55,8 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { return Err("The file no longer exists".into()); } let platform = std::env::consts::OS; - let run = |program: &str, args: &[std::ffi::OsString]| { - std::process::Command::new(program).args(args).output().map(|o| o.status.success()).map_err(|e| e.to_string()) - }; + let run = + |program: &str, args: &[std::ffi::OsString]| reveal_process(program, args).output().map(|o| o.status.success()).map_err(|e| e.to_string()); if matches!(platform, "linux" | "freebsd") { // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), // so it runs off the UI thread; a failure there is only logged. @@ -71,6 +70,24 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { reveal_via(platform, &path, run) } +/// The process that runs `program` with `args`. Windows: `reveal_command` already quotes what +/// Explorer needs quoted, and Rust's own quoting of an argument with a space breaks `/select,` +/// (#2579), so the arguments go on the command line as written. +#[cfg(not(target_arch = "wasm32"))] +fn reveal_process(program: &str, args: &[std::ffi::OsString]) -> std::process::Command { + let mut command = std::process::Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + for arg in args { + command.raw_arg(arg); + } + } + #[cfg(not(windows))] + command.args(args); + command +} + #[cfg(not(target_arch = "wasm32"))] fn reveal_via( platform: &str, @@ -95,9 +112,16 @@ fn reveal_via( fn reveal_command(platform: &str, path: &std::path::Path) -> Result<(&'static str, Vec), String> { match platform { "windows" => { - // Explorer uses /select,, rather than a separate option value. - let mut selection = std::ffi::OsString::from("/select,"); + // Explorer reads `/select,""`: the path quoted after the comma (#2579). Quoting + // the whole argument instead ("/select,C:\a b.pdf", what an ordinary argument with a + // space becomes) opens Documents. A Windows path can't contain `"`, so the quotes + // can't be closed early; one that does is refused. + if path.as_os_str().as_encoded_bytes().contains(&b'"') { + return Err("The file name can't be shown in File Explorer".into()); + } + let mut selection = std::ffi::OsString::from("/select,\""); selection.push(path); + selection.push("\""); Ok(("explorer.exe", vec![selection])) } "macos" => Ok(("open", vec!["-R".into(), path.as_os_str().into()])), @@ -221,6 +245,19 @@ mod tests { assert!(reveal_via("linux", path, |_, _| Err("command missing".into())).is_err()); } + /// #2579 (review of #2625): the `/select,"…"` argument reaches the process exactly as written. + /// `cmd /c echo` prints its command line back; with Rust's own quoting (`args`) it would come + /// back wrapped in quotes with the inner ones escaped, which Explorer reads as no selection. + #[cfg(windows)] + #[test] + fn the_reveal_argument_reaches_the_process_as_written() { + let (_, args) = reveal_command("windows", std::path::Path::new(r"C:\Reports\annual report.pdf")).unwrap(); + let mut echo: Vec = ["/d", "/c", "echo"].map(std::ffi::OsString::from).to_vec(); + echo.extend(args); + let out = reveal_process("cmd", &echo).output().unwrap(); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), r#"/select,"C:\Reports\annual report.pdf""#); + } + #[test] fn reveal_arguments_preserve_spaces_and_unicode() { let path = std::path::Path::new("/tmp/年度 report.pdf"); @@ -229,10 +266,13 @@ mod tests { assert_eq!(args, [std::ffi::OsString::from("-R"), path.as_os_str().into()]); let (exe, args) = reveal_command("windows", path).unwrap(); assert_eq!(exe, "explorer.exe"); - assert_eq!(args, [std::ffi::OsString::from("/select,/tmp/年度 report.pdf")]); + assert_eq!(args, [std::ffi::OsString::from("/select,\"/tmp/年度 report.pdf\"")]); + // #2579: the path is quoted after the comma, never the whole argument, which Explorer + // reads as no selection and answers by opening Documents. let special = std::path::Path::new(r"C:\Reports\年度 report, #1 & (draft).pdf"); let (_, windows) = reveal_command("windows", special).unwrap(); - assert_eq!(windows, [std::ffi::OsString::from(r"/select,C:\Reports\年度 report, #1 & (draft).pdf")]); + assert_eq!(windows, [std::ffi::OsString::from(r#"/select,"C:\Reports\年度 report, #1 & (draft).pdf""#)]); + assert!(reveal_command("windows", std::path::Path::new(r#"C:\a" b.pdf"#)).is_err(), "a quote can't end the path early"); let (exe, args) = reveal_command("linux", path).unwrap(); assert_eq!(exe, "dbus-send"); assert_eq!(args[6], "array:string:file:///tmp/%E5%B9%B4%E5%BA%A6%20report.pdf"); diff --git a/crates/ui-egui/src/window_state.rs b/crates/ui-egui/src/window_state.rs new file mode 100644 index 0000000000..0475604a48 --- /dev/null +++ b/crates/ui-egui/src/window_state.rs @@ -0,0 +1,137 @@ +//! The window's saved size and place across starts. eframe stores them just before +//! `App::save`, from the window as it is at that moment. A minimized window has neither: Windows +//! reports it as 0 × 0 at (-32000, -32000), and the next start then opened at egui's 64-point +//! floor in the screen's corner. So while the window is minimized the last settings it was shown +//! with are put back, and a window that opens at that floor is given a usable size. + +/// eframe's storage key for the window settings. +const WINDOW_KEY: &str = "window"; +/// The size egui opens a stored size below (its floor for "ridiculously small" windows), in points. +const EGUI_FLOOR: f32 = 64.0; +/// The size a window that opened at [`EGUI_FLOOR`] is given (apps/pdfcraft opens a first start at +/// this size too), at most nine tenths of the monitor. +const USABLE: egui::Vec2 = egui::vec2(1440.0, 920.0); + +#[derive(Debug, Default)] +pub struct WindowState { + /// The settings eframe last stored for a shown (not minimized) window. + shown: Option, + /// The size at start has been checked ([`Self::repair`]). + checked: bool, +} + +impl WindowState { + /// The settings the window started with: what to put back if it is minimized before eframe + /// stores a shown one. + pub fn started_with(storage: Option<&dyn eframe::Storage>) -> Self { + Self { shown: storage.and_then(|s| s.get_string(WINDOW_KEY)), checked: false } + } + + /// After eframe has stored the window: remember it while the window is shown, and while it + /// is minimized put the last shown settings back (or none, so the next start opens at the + /// default size). + pub fn keep(&mut self, storage: &mut dyn eframe::Storage, minimized: bool) { + if !minimized { + self.shown = storage.get_string(WINDOW_KEY); + } else if let Some(shown) = &self.shown { + storage.set_string(WINDOW_KEY, shown.clone()); + } else { + storage.remove_string(WINDOW_KEY); + } + } + + /// Once the window's size is known: a shown window at egui's floor in both directions came + /// from settings stored while it was minimized (before this fix), so give it a usable size. + pub fn repair(&mut self, ctx: &egui::Context) { + if self.checked { + return; + } + let (rect, minimized, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().minimized, i.viewport().monitor_size)); + let Some(rect) = rect else { return }; + if minimized == Some(true) { + return; + } + self.checked = true; + if rect.width() <= EGUI_FLOOR + 0.5 && rect.height() <= EGUI_FLOOR + 0.5 { + // The stored settings are the broken ones: never put them back. + self.shown = None; + let size = monitor.map_or(USABLE, |m| USABLE.min(m * 0.9)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use eframe::Storage as _; + + #[derive(Default)] + struct Store(std::collections::HashMap); + + impl eframe::Storage for Store { + fn get_string(&self, key: &str) -> Option { + self.0.get(key).cloned() + } + fn set_string(&mut self, key: &str, value: String) { + self.0.insert(key.to_string(), value); + } + fn remove_string(&mut self, key: &str) { + self.0.remove(key); + } + fn flush(&mut self) {} + } + + const SHOWN: &str = "(outer_position_pixels:Some((x:200.0,y:200.0)),inner_size_points:Some((x:1320.0,y:940.0)))"; + const MINIMIZED: &str = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),inner_size_points:Some((x:0.0,y:0.0)))"; + + /// Closing PdfCraft while it was minimized stored a 0 × 0 window off screen. + #[test] + fn a_minimized_window_keeps_the_last_shown_settings() { + let mut store = Store::default(); + store.set_string(WINDOW_KEY, SHOWN.into()); + let mut state = WindowState::started_with(Some(&store)); + // eframe stores the minimized window, then the app saves. + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY).as_deref(), Some(SHOWN)); + // Shown again and resized: that is what is kept from then on. + let moved = SHOWN.replace("200.0", "300.0"); + store.set_string(WINDOW_KEY, moved.clone()); + state.keep(&mut store, false); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), Some(moved)); + } + + #[test] + fn with_nothing_shown_yet_a_minimized_save_stores_no_window() { + let mut store = Store::default(); + let mut state = WindowState::started_with(Some(&store)); + store.set_string(WINDOW_KEY, MINIMIZED.into()); + state.keep(&mut store, true); + assert_eq!(store.get_string(WINDOW_KEY), None, "the next start opens at the default size"); + } + + fn repaired(size: egui::Vec2, minimized: Option) -> Vec { + let ctx = egui::Context::default(); + let mut input = egui::RawInput::default(); + let info = input.viewports.entry(egui::ViewportId::ROOT).or_default(); + info.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)); + info.minimized = minimized; + info.monitor_size = Some(egui::vec2(2674.0, 1504.0)); + let mut state = WindowState { shown: Some(MINIMIZED.into()), checked: false }; + let out = ctx.run_logic(&input, |ctx| state.repair(ctx)); + let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default(); + // Repaired: the settings it started with are not put back by a minimized close. + assert_eq!(state.shown.is_none(), !commands.is_empty()); + commands + } + + #[test] + fn a_window_at_eguis_floor_gets_a_usable_size() { + assert_eq!(repaired(egui::vec2(64.0, 64.0), Some(false)), [egui::ViewportCommand::InnerSize(USABLE)]); + assert!(repaired(egui::vec2(820.0, 520.0), Some(false)).is_empty(), "a small window someone chose stays"); + assert!(repaired(egui::vec2(64.0, 64.0), Some(true)).is_empty(), "not while minimized"); + } +} diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 9a3716c3ee..3d53f1e3bb 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -366,6 +366,40 @@ fn selected_text_shows_actions_and_underlines_without_changing_tools() { assert_eq!(h.state().quick_tool, QuickTool::Select); } +#[test] +fn selected_text_copy_action_closes_the_bar_and_keeps_the_selection() { + let mut h = harness(|_| {}); + h.state_mut().views[0].select_text(0, 4, 8); + h.run_steps(3); + h.get_by_label("Copy").click(); + h.run_steps(3); + assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy"); + assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "the words stay selected"); + // A new selection brings the bar back. + h.state_mut().views[0].select_text(0, 2, 6); + h.run_steps(3); + h.get_by_label("Add comment"); +} + +#[test] +fn dragging_over_copied_text_again_brings_the_action_bar_back() { + let mut h = harness(|_| {}); + let (a, b) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 8).expect("glyph")) + }; + drag(&mut h, a, b); + let copied = h.state().views[0].selected_text(); + assert!(copied.is_some(), "the drag selects text"); + h.get_by_label("Copy").click(); + h.run_steps(3); + assert!(h.query_by_label("Add comment").is_none(), "the action bar closes after Copy"); + // The same drag again selects the same words, which is a new selection: the bar is back. + drag(&mut h, a, b); + assert_eq!(h.state().views[0].selected_text(), copied, "the drag selects the same words"); + assert!(h.query_by_label("Add comment").is_some(), "the action bar is back after a new drag"); +} + #[test] fn selected_text_replace_action_opens_replacement_composer() { let mut h = harness(|_| {}); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 0c7f7196c8..680d49a777 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4777,3 +4777,32 @@ fn leaving_organize_goes_back_to_the_workspace_panel() { h.run_steps(3); assert_eq!(h.state().left, LeftPanel::AllTools); } + +/// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as +/// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. +#[test] +fn read_mode_shortcut_applies_the_open_paragraph() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + // The line "Page 1" sits at (20, 150) on a 200 × 300 page, 24 pt. + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some(), "the line opens for editing"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); + h.run_steps(1); + h.state_mut().run_command("view.read_mode"); + h.run_steps(3); + let s = h.state(); + assert_eq!(s.mode, Mode::Read); + assert_eq!(s.quick_tool, QuickTool::Select); + assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode"); + assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied"); +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 0082e62fd8..b86f7699f0 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -246,6 +246,39 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() { assert_eq!(h.state().right, Some(RightPanel::Pages)); } +/// Read mode hides the quick toolbar too, and puts down the tool that was in use (#2646). +#[test] +fn read_mode_hides_the_quick_toolbar() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Hand (H)").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Hand); + h.get_by_label("Read").click(); + h.run_steps(3); + assert!(h.query_by_label("Select (V)").is_none(), "no quick toolbar in Read mode"); + assert_eq!(h.state().quick_tool, QuickTool::Select); + h.state_mut().run_command("view.read_mode"); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::AllTools); + h.get_by_label("Select (V)"); +} + +/// Opening a document with Read as the default mode puts down the tool in use, as entering Read +/// mode from the mode bar does: the hidden toolbar would leave no way to see or change it. +#[test] +fn opening_into_read_mode_puts_the_tool_down() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().mode, Mode::AllTools); + h.state_mut().set_option("quick", "hand").unwrap(); + h.state_mut().set_option("default-mode", "read").unwrap(); + h.state_mut().open_bytes("again.pdf", None, FIXTURE.to_vec()).unwrap(); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::Read, "the new document opens in the default mode"); + assert_eq!(h.state().quick_tool, QuickTool::Select, "no tool stays active behind the hidden toolbar"); +} + /// A pane closed in Read mode stays closed on the way out: leaving Read mode doesn't bring back the /// pane saved on the way in, and closing Comments keeps its close preference. #[test] diff --git a/crates/ui-egui/tests/ui_group/marks.rs b/crates/ui-egui/tests/ui_group/marks.rs index 0a0e45b152..1f1515ad60 100644 --- a/crates/ui-egui/tests/ui_group/marks.rs +++ b/crates/ui-egui/tests/ui_group/marks.rs @@ -96,3 +96,37 @@ fn backgrounds_and_watermarks_from_a_file() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); } + +/// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on +/// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on +/// the last page. +#[test] +fn page_range_from_one_to_one_selects_only_the_first_page() { + let mut h = harness(); + h.state_mut().execute("edit.background"); + h.run_steps(2); + assert_eq!(h.state().marks_draft.range.pages(3), [0, 1, 2]); + h.get_by_label("Pages from").click(); + h.run_steps(2); + let range = h.state().marks_draft.range; + assert_eq!((range.from, range.to), (1, 3), "from the first page to the last"); + h.state_mut().marks_draft.range.to = 1; + h.run_steps(3); + let range = h.state().marks_draft.range; + assert_eq!((range.from, range.to), (1, 1), "the range stays as typed"); + h.get_by_label("Applies to 1 page"); + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.marks(), [MarkKind::Background]); + // Rendered: page 1 has the (default cream) background, page 2 is still blank. + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let corner = |r: &mut pdfcraft_render::PageRenderer, page: usize| { + let p = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p.rgba[..4].to_vec() + }; + assert_eq!(corner(&mut r, 0), vec![255, 255, 217, 255], "page 1 has the background"); + assert_eq!(corner(&mut r, 1), vec![255, 255, 255, 255], "page 2 does not"); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index c30f081e4a..60150c931c 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -185,6 +185,53 @@ fn single_page_layout_shows_one_page_at_a_time() { assert!(rect(&h, 0).is_none() && rect(&h, 1).is_some(), "only page 2"); } +#[test] +fn single_page_view_prefetches_the_next_page() { + // Only the shown page is drawn, but the next one renders ahead of the turn: otherwise + // turning forward shows blank paper until it arrives. + let mut h = harness(&[("layout", "single"), ("zoom", "50")]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().render_pending() { + assert!(std::time::Instant::now() < deadline, "renders settle"); + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(rect(&h, 1).is_none(), "page 2 is not shown"); + assert!(h.state().views[0].raster_memory().page_count >= 2, "page 2 is rendered ahead of the turn"); +} + +#[test] +fn single_page_wheel_turn_draws_the_new_page_at_once() { + // The frame that turns the page draws the new one. Its jump used to be measured from the + // old page's place, which scrolled both out of view: a blank frame on every turn forward. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single")]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + let vp = h.state().views[0].viewport_rect(); + h.hover_at(vp.center()); + h.run_steps(2); + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.step(); + assert_eq!(h.state().views[0].current, 1, "one notch turns one page"); + let r = rect(&h, 1).expect("page 2 drawn in the frame that turns to it"); + assert!(r.min.y >= vp.min.y && r.max.y <= vp.max.y, "page 2 fits on screen: {r:?} in {vp:?}"); +} + +#[test] +fn single_page_jump_draws_a_fitting_page_where_it_stays() { + // A page that fits has nothing to scroll, but the jump to it asked for the gap above it: + // drawn that much too high for a frame, it then hopped into place. + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single")]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); // fit page + h.run_steps(4); + h.state_mut().set_option("page", "2").unwrap(); + h.step(); + let first = rect(&h, 1).expect("page 2 drawn in the frame after the jump"); + h.run_steps(4); + let settled = rect(&h, 1).expect("page 2"); + assert!((first.min.y - settled.min.y).abs() < 0.5, "page 2 drawn where it settles: {first:?} then {settled:?}"); +} + #[test] fn single_page_wheel_pans_instead_when_zoomed_in() { // The zoomed-in guard: with room to pan, the wheel pans and must not turn pages. diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index 31530bf235..b154694854 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -175,6 +175,36 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + n - 1) % n)); } +/// #2669: ⌘G and ⇧⌘G step through the matches while the find field still has the focus. +#[test] +fn find_shortcuts_work_from_the_find_field() { + let mut h = harness(); + h.state_mut().active = Some(0); + h.run_steps(2); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = "page".into(); + h.state_mut().views[0].rerun_find(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 { + break; + } + } + let n = h.state().views[0].find.as_ref().unwrap().matches.len(); + assert_eq!(n, 15); + assert!(h.ctx.egui_wants_keyboard_input(), "the find field has the focus"); + let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + 1) % n)); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G); + h.run_steps(2); + assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some(start)); + assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); @@ -902,6 +932,70 @@ fn an_earlier_revision_opens_from_document_properties() { ); } +#[test] +fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { + use pdfcraft_engine::{Edit, Session}; + let mut bytes = std::sync::Arc::new(fixture(1)); + // Reopen each save so the next edit appends to the preceding saved revision. + for revision in 2..=13 { + let mut source = Session::new().with_clock(|| 1_700_000_000); + let id = source.open("revisions.pdf", None, bytes, None).unwrap(); + source.apply(id, Edit::SetInfo { key: "Title".into(), value: format!("Revision {revision}") }).unwrap(); + bytes = source.save_bytes(id).unwrap(); + } + for width in [1400.0, 720.0] { + let pdf = bytes.clone(); + let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("revisions.pdf", None, pdf.to_vec()).unwrap(); + app + }); + let s = h.state(); + let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap(); + assert_eq!(doc.revision_ends().len(), 13, "the fixture contains thirteen saved revisions"); + h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced)); + // Let the grid and the centered dialog settle before measuring or clicking. + h.run_steps(6); + let buttons: Vec<_> = (1..13).map(|n| (n, h.get_by_label(&format!("View revision {n}")).rect())).collect(); + let first_row = h.get_by_label("View revision 12").rect(); + let lower_revision = buttons + .iter() + .find(|(n, rect)| *n > 1 && rect.top() > first_row.bottom()) + .map(|(n, _)| *n) + .expect("revision buttons wrap to a lower row"); + let binding = h.get_by_label("Binding").rect(); + let left = h.get_by_label("Left Edge").rect(); + let right = h.get_by_label("Right Edge").rect(); + // Properties requests a fixed width. Measure its card in each viewport. + let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + for (n, rect) in &buttons { + for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { + assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); + } + } + for (n, rect) in &buttons { + assert!( + rect.left() >= card.left() && rect.right() <= card.right(), + "revision {n} escapes Properties horizontally at viewport width {width}: {rect:?}, measured card {card:?}" + ); + } + h.get_by_label(&format!("View revision {lower_revision}")).scroll_to_me(); + h.run_steps(3); + h.get_by_label(&format!("View revision {lower_revision}")).click(); + h.run_steps(3); + let s = h.state(); + assert!(s.dialog.is_none(), "opening a revision closes Properties"); + let view = &s.views[s.active.unwrap()]; + let doc = s.session.get(view.id).unwrap(); + assert_eq!(doc.name, format!("revisions (revision {lower_revision}).pdf")); + assert_eq!(doc.info_value("Title"), Some(format!("Revision {lower_revision}"))); + let snapshot = view.snapshot.as_ref().expect("the saved revision opens as a snapshot"); + assert_eq!(snapshot.kind, pdfcraft_ui_egui::canvas::SnapshotKind::Revision); + assert_eq!(snapshot.revision, lower_revision); + } +} + #[test] fn advanced_search_lists_results_with_context() { let mut h = harness(); @@ -1448,6 +1542,55 @@ fn a_clicked_picture_is_selected_and_copied_in_its_original_format() { assert_eq!(h.state().views[i].picked_image, None); } +/// A picture picked with the Select tool, then deleted in Edit text & images, leaves no highlight +/// behind on the empty spot where it was. +#[test] +fn a_picked_picture_that_is_deleted_in_edit_mode_loses_its_highlight() { + let (_, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + let at = h.state().views[i].page_screen_rect(0).expect("on screen").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.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool"); + // Edit text & images: click the picture, delete it with the Delete key. + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(2); + h.key_press(egui::Key::Delete); + // One frame, no more input: the highlight goes at once, it does not wait for a click. + h.run_steps(1); + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is gone"); + assert_eq!(h.state().views[i].picked_image, None, "and so is its highlight"); +} + +/// The highlight goes in the very edit that removes the picture, not on a later frame: the window +/// only redraws on the next input, so waiting for one leaves the highlight on screen until a click. +#[test] +fn an_edit_clears_the_picked_picture_at_once() { + let (_, mut h) = picture_harness(); + let i = h.state().active.unwrap(); + let at = h.state().views[i].page_screen_rect(0).expect("on screen").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.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().views[i].picked_image.is_some(), "picked with the Select tool"); + let delete = pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Delete }; + assert!(h.state_mut().apply_edit(delete)); + // No frame has run since the edit. + assert_eq!(h.state().views[i].picked_image, None); +} + #[test] fn right_click_on_a_picture_copies_it_at_full_size() { let (px, mut h) = picture_harness(); @@ -1845,3 +1988,27 @@ fn arrow_keys_follow_the_scroll_speed_preference() { let (slow, normal, fast) = (lines_to_page_2("slow"), lines_to_page_2("normal"), lines_to_page_2("fast")); assert!(fast * 3 < normal * 2 && normal * 3 < slow * 2, "presses to page 2: slow {slow}, normal {normal}, fast {fast}"); } + +/// #2665: next to a very wide page the normal pages sit far to its right, outside the window. +/// Going to one changed the page counter but kept the horizontal scroll, so it stayed out of +/// sight; as in Acrobat, it is brought into view. +#[test] +fn go_to_page_brings_the_page_into_view_horizontally() { + use pdfcraft_ui_egui::canvas::Fit; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("wide.pdf", None, sized_fixture(&[(595, 842), (595, 842), (595, 842), (6000, 2000)])).unwrap(); + app.views[0].fit = Fit::None; + app.views[0].zoom = 1.0; + app + }); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 starts off-screen, right of the window"); + h.state_mut().views[0].go_to_page(2); + h.run_steps(4); + let v = &h.state().views[0]; + assert_eq!(v.current, 2); + let (r, vp) = (v.page_screen_rect(2).expect("page 3 on screen"), v.viewport_rect()); + assert!(r.left() >= vp.left() && r.right() <= vp.right(), "page 3 at {r:?}, viewport {vp:?}"); +} diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index a491d3f493..62d7beedf1 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -232,6 +232,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result content: vec![Rect::new(36.0, 36.0, 540.0, 720.0)], items: Vec::new(), occur_max: None, + occur_min: 0, }], }; let mut values = HashMap::new(); @@ -261,6 +262,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result if tpl.root.common.presence.occupies() { l.flow_subform(&root_node, None, &ctx, 0, 0)?; } + l.satisfy_page_area_minimums()?; l.check_measures()?; let total = l.pages.len(); let count = total.to_string(); @@ -497,11 +499,28 @@ impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── pages fn new_page(&mut self) -> Result<(), XfaError> { + let idx = + (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); + self.new_page_of(idx) + } + + /// Once the body has been laid out, give every page area that has been used fewer times than + /// its `occur min` the pages it still needs, in template order, after the body's pages. + fn satisfy_page_area_minimums(&mut self) -> Result<(), XfaError> { + let areas = Rc::clone(&self.areas); + for (idx, area) in areas.iter().enumerate() { + while self.uses.get(idx).is_some_and(|u| *u < area.occur_min) { + self.new_page_of(idx)?; + } + } + Ok(()) + } + + /// Start a page from page area `idx`. + fn new_page_of(&mut self, idx: usize) -> Result<(), XfaError> { if self.pages.len() >= MAX_PAGES { return Err(XfaError::TooLarge(format!("the form lays out to more than {MAX_PAGES} pages"))); } - let idx = - (0..self.areas.len()).find(|&i| self.areas[i].occur_max.is_none_or(|m| self.uses[i] < m)).unwrap_or(self.areas.len().saturating_sub(1)); let areas = Rc::clone(&self.areas); let Some(area) = areas.get(idx) else { return Err(XfaError::Malformed("the template has no page area".into())); diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index f4ef3fabce..572675f958 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -680,6 +680,9 @@ pub struct PageArea { pub items: Vec, /// How many pages may use this master (`None`: unbounded). pub occur_max: Option, + /// How many pages must use this master: a layout that ends with fewer adds pages for it. + /// Zero without an ``. + pub occur_min: usize, } #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index f6c69789b4..4582d59b22 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -529,8 +529,11 @@ fn parse_page_set(n: XmlNode, warnings: &mut Vec, depth: usize) -> PageS content.push(Rect::new(0.0, 0.0, width, height)); } let items = pa.children().filter(|c| c.is_element()).filter_map(|c| parse_node(c, warnings, depth + 1)).collect(); - let occur = parse_occur(child(pa, "occur")); - areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max: occur.max }); + // A page area with no `` has no limit and no minimum (a subform's default of one + // would exhaust it, and demand a page for every master). + let occur = child(pa, "occur").map(|o| parse_occur(Some(o))); + let (occur_min, occur_max) = occur.map_or((0, None), |o| (o.min, o.max)); + areas.push(PageArea { name: pa.attribute("name").map(str::to_string), width, height, content, items, occur_max, occur_min }); } // Nested page sets: take their areas too. for ps in children(n, "pageSet") { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 1e8730586d..03eb2c67ad 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1191,6 +1191,75 @@ fn repeating_subforms_inside_positioned_content_get_their_instances() { assert!(((after.rect.y - rows[2].rect.y) - step).abs() < 0.01, "after: {:?}, last row: {:?}", after.rect, rows[2].rect); } +/// A page area without an `` repeats for as long as the body needs pages. Before, it was +/// treated like a subform (at most once), so once the first body page was out the layout fell +/// back to the last page area, here one limited to a single use, and drew its content over every +/// body page (and listed a copy of its fields per page). +#[test] +fn page_area_without_occur_serves_every_body_page() { + let section = |n: u32| { + format!( + r#""# + ) + }; + let xdp = format!( + r##" + + + +"##, + section(1), + section(2), + section(3), + section(4) + ); + let form = layout_xml(&xdp).unwrap(); + let soms: Vec> = form.pages.iter().map(|p| widgets(p).iter().map(|w| w.som.clone()).collect()).collect(); + let has_button = |page: &Vec| page.iter().any(|s| s.contains("PrintButton")); + // Four body pages, then the once-only page: its button is on that page alone, not over the body. + assert_eq!(form.pages.len(), 5, "{soms:?}"); + assert!(soms[..4].iter().all(|p| !has_button(p)), "the single-use page area is not drawn over body pages: {soms:?}"); + assert_eq!(soms[4].iter().filter(|s| s.contains("PrintButton")).count(), 1, "the required page is laid out once: {soms:?}"); +} + +/// A page area's `occur min` is a page it must have even when the body never reaches it, and a +/// body that already used it does not get another. An area with no `` needs none. +#[test] +fn page_area_minimum_adds_pages_after_the_body_only_when_unused() { + let area = |name: &str, occur: &str| { + format!( + r#"{occur}"# + ) + }; + let pages = |areas: String| { + let xdp = format!( + r##" +"## + ); + layout_xml(&xdp).unwrap().pages.len() + }; + // The body is one page; the required second master adds one, a third with min 2 adds two. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 2); + assert_eq!(pages(area("A", "") + &area("B", r#""#) + &area("C", r#""#)), 4); + // An area the body already used (it is first, so page 1 is its own) adds nothing. + assert_eq!(pages(area("A", r#""#)), 1); + assert_eq!(pages(area("A", r#""#) + &area("B", "")), 1); + // No `` and `min="0"` need no page. + assert_eq!(pages(area("A", "") + &area("B", r#""#)), 1); +} + /// Areas are named in SOM expressions but are not data scopes; unnamed subforms and areas have /// no segment at all. What the template doesn't have is kept as it is. #[test] diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md index 0605298e20..f1ca8ac001 100644 --- a/crates/xfdf/README.md +++ b/crates/xfdf/README.md @@ -18,11 +18,16 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o - **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and tab-delimited text (a row of names, a row of values). -Plain AcroForm XML imports retain all direct text around comments and processing instructions, -including CDATA and character references. Nested-element and XFA interpretation are unchanged. XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with character references, so XML attribute normalization cannot change their identities. +Plain AcroForm XML imports retain all direct text around comments and processing instructions, +including CDATA and character references. Nested-element and XFA interpretation are unchanged. + +CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV +delimiters without changing field identities. Automatic detection of externally authored, ambiguous +tables remains heuristic. + Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws appearances PdfCraft can draw, and sets values through the form's own checks (formats, validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 9d7b9839d7..44004796e8 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -615,7 +615,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { out } Format::Csv => { - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; format!( "{}\n{}\n", fields.iter().map(|f| q(&f.0)).collect::>().join(","), @@ -1044,7 +1044,7 @@ pub fn merge_csv(files: &[Vec<(String, Vec)>]) -> String { } } } - let q = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; + let q = |s: &str| if s.contains([',', '"', '\n', '\r', '\t']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }; let mut out = columns.iter().map(|c| q(c)).collect::>().join(","); out.push('\n'); for f in files { @@ -1104,6 +1104,31 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { Ok(report) } +// Inspect the first logical record: tabs within quoted CSV cells are data. A quote +// in the middle of an unquoted TSV name stays literal, as in the TSV reader. +fn has_tab_delimiter(text: &str) -> bool { + let mut quoted = false; + let mut cell_start = true; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' if quoted && chars.peek() == Some(&'"') => { + chars.next(); + } + '"' if quoted || cell_start => { + quoted = !quoted; + cell_start = false; + } + '\t' if !quoted => return true, + '\r' | '\n' if !quoted => return false, + ',' if !quoted => cell_start = true, + _ => cell_start = false, + } + } + // An unclosed quote can be a literal TSV field-name character. + quoted && text.lines().next().is_some_and(|line| line.contains('\t')) +} + /// Form data from XML, CSV or tab-delimited text. fn import_table(doc: &mut Document, text: &str) -> Result { let mut report = Report::default(); @@ -1121,7 +1146,7 @@ fn import_table(doc: &mut Document, text: &str) -> Result { (name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()]) }) .collect() - } else if t.lines().next().is_some_and(|l| l.contains('\t')) { + } else if has_tab_delimiter(t) { tab_delimited(t)? } else { // CSV with quotes: a quoted value may hold commas, doubled quotes and line breaks. diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 47952e642b..b2f4e4406c 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -142,6 +142,32 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } +/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by +/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its +/// own `name`: importing it found a comment replying to itself and the pair was lost. +#[test] +fn a_replace_text_pair_survives_an_xfdf_round_trip() { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; + let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; + add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); + assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); + assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); + + let mut dst = blank(); + let r = import(&mut dst, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 2, "both comments import: {r:?}"); + let found = summaries(&dst); + let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); + assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); + assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); +} + #[test] fn xml_exports_preserve_whitespace_in_field_names() { let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"]; @@ -1144,3 +1170,49 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() { assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]); } } + +#[test] +fn csv_headers_with_tabs_survive_export_import_and_save() { + for name in ["First\tName", "\tName", "Name\t", "Name\t\"quoted\"", "Name\nwith\ta tab"] { + let mut source = one_field(); + add_field(&mut source, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + set_value(&mut source, "Other", &FieldValue::Text("other field".into())).unwrap(); + let field = fields(&source).into_iter().find(|f| f.name == "Name").unwrap().obj; + source.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + set_value(&mut source, name, &FieldValue::Text("original, \"value\"".into())).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + let source = Document::open(Arc::new(bytes.clone())).unwrap(); + let row = data_values(&bytes).unwrap(); + for csv in [export_data(&source, Format::Csv), merge_csv(&[row])] { + let mut doc = Document::open(Arc::new(bytes.clone())).unwrap(); + set_value(&mut doc, name, &FieldValue::Text("replacement".into())).unwrap(); + set_value(&mut doc, "Other", &FieldValue::Text("other replacement".into())).unwrap(); + let report = import(&mut doc, csv.as_bytes()).unwrap(); + assert_eq!(report.fields, 2); + assert!(report.rejected.is_empty(), "{:?}", report.rejected); + for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(fields(&reopened).into_iter().find(|f| f.name == name).unwrap().value, vec!["original, \"value\""], "{csv:?}"); + assert_eq!(values(&reopened), values(&source), "{csv:?}"); + } + } + } +} + +#[test] +fn tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs() { + for name in ["Name", "Na\"me", "\"Name\"", "\"Name"] { + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + let field = fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().obj; + doc.update_dict(field, |dict| dict.set(b"T".to_vec(), PdfString::text(name))).unwrap(); + let text = format!("{name}\tOther\r\nAda\tother field\r\n"); + let report = import(&mut doc, text.as_bytes()).unwrap(); + assert_eq!(report.fields, 2, "{text:?}"); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == name).unwrap().value, vec!["Ada"]); + } + let mut doc = one_field(); + add_field(&mut doc, 0, [50.0, 650.0, 250.0, 670.0], &NewField::Text { multiline: false }, Some("Other")).unwrap(); + import(&mut doc, b"Name,Other\r\n\"Ada\tLovelace\",other field\r\n").unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["Ada\tLovelace"]); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 97416da7ed..9e97a95033 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,15 +2,15 @@ > **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 (M1, #2658: reject partial extended-attribute copies after a size change; estimates unchanged) -> **Last reviewed:** 2026-10-17 - **Last updated:** 2026-10-17 - **Change:** minor (M1: macOS atomic save retains destination xattrs; estimates unchanged) -> +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M4: indirect page rotations refreshed upstream; M13: CLI check visits each directory once through aliases and cycles; 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) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: sampled functions index a flat table, one image decode serves every zoom level, patch meshes copy less; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -43,6 +43,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (M13: reject malformed CLI --root options before dispatch; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; 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 (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged) Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md), @@ -107,11 +108,12 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). Done since: macOS atomic saves retain accessible destination xattrs, including Finder tags; a metadata failure keeps the original and the unsaved edit (#2658, metadata only). Done since: Post-read size checks refuse partial resource-fork copies when metadata changes during a save. Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. (#2658). Missing: ≥ 250 tests, own image codecs | | 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. #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). 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. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | 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. | @@ -119,7 +121,8 @@ 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. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | 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). | -| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Refreshed against upstream `99941daa` (2026-10-15). Repeated page indices accumulate rotations from the latest local value (#1919); saved/reopened regressions retain inherited angles and siblings. Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Rotation queries and edits resolve indirect values on pages and ancestors. Missing: page transitions, recovery on the web | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | 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). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | @@ -127,8 +130,10 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | @@ -137,11 +142,11 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | 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. Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). 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. 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). | @@ -193,11 +198,15 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 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 | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. 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. | | 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. | +| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | +| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 7fae1329f3..d96f2cd78d 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -111,6 +111,11 @@ Resource 1 is the app's lion; resource 2 is the PDF document (build.rs). --> + + diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c5be4de0c4..91e525e733 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -46,8 +46,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow"] -notes = "" +evidence = ["crates/cos/src/document.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] +notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." [[feature]] id = "core.malformed-input-safety" @@ -214,7 +214,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"] +evidence = ["crates/ui-egui/tests/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/crypt/src/tests.rs::permission_bits_follow_table_22", "crates/crypt/src/tests.rs::perms_limits_what_an_edited_p_grants", "crates/engine/src/tests.rs::combining_protected_files_is_refused_clearly"] notes = "" [[feature]] @@ -2130,8 +2130,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"] -evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png"] -notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG" +evidence = ["crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "edit.image-external-editor" @@ -2644,8 +2644,8 @@ milestone = "M4" status = "shipped" commands = ["page.rotate", "page.rotate_ccw"] tools = ["page_rotate"] -evidence = ["crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions"] -notes = "" +evidence = ["crates/organize/src/tests.rs::rotate_repeated_indices_accumulate_from_the_current_page_value", "crates/organize/src/tests.rs::rotate_uses_inherited_rotation_and_round_trips", "crates/ui-egui/tests/editing.rs::command_click_toggles_and_rotation_applies_to_selection", "crates/ui-egui/tests/editing.rs::organize_page_actions_rotate_only_the_named_page_and_undo", "crates/ui-egui/tests/editing.rs::organize_local_actions_respect_document_permissions", "crates/organize/src/tests.rs::indirect_page_rotation_is_read_and_updated_from_its_effective_value", "crates/organize/src/tests.rs::malformed_indirect_rotations_keep_the_zero_fallback"] +notes = "Rotation queries and edits resolve direct and inherited indirect integer values; full and incremental save regressions preserve sibling rotations. Existing quarter-turn restrictions and malformed-value zero fallback remain." [[feature]] id = "organize.rotate-filters" @@ -3481,11 +3481,11 @@ title = "Custom document properties" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "doc_set_info"] +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] +notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] id = "organize.xmp-editor" @@ -4704,8 +4704,8 @@ milestone = "M6" status = "shipped" commands = ["form.export_data", "form.import_data"] tools = ["doc_export_data", "doc_import_data"] -evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"] -notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. XML/XFDF exports retain tabs and line breaks in field identities through character references." +evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save", "crates/xfdf/src/tests.rs::tabular_import_keeps_tsv_quotes_literal_and_csv_value_tabs"] +notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; XML/XFDF exports retain tabs and line breaks in field identities through character references; plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Ambiguous externally authored tables remain heuristic." [[feature]] id = "form.merge-to-spreadsheet" @@ -4716,8 +4716,8 @@ milestone = "M6" status = "shipped" commands = ["form.merge_data"] tools = ["form_merge_data"] -evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet"] -notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file." +evidence = ["crates/xfdf/src/tests.rs::data_files_merge_into_a_spreadsheet", "crates/automation/tests/automation.rs::merging_form_data_into_a_spreadsheet", "crates/ui-egui/tests/javascript.rs::merge_data_files_into_a_spreadsheet", "crates/xfdf/src/tests.rs::csv_headers_with_tabs_survive_export_import_and_save"] +notes = "FDF, XFDF or filled-in PDF forms \u2192 CSV, a column per field and a row per file. CSV header tabs, quotes and line breaks retain field identities through import and save/reopen; ambiguous external table detection remains heuristic." [[feature]] id = "form.flatten-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", "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)" +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", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "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 (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro shows the public ISCIII form), 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: a page area's relevant=\"-print\" (Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); 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" @@ -5788,8 +5788,8 @@ milestone = "M8" status = "shipped" commands = ["protect.remove_hidden"] tools = ["doc_hidden_info", "doc_remove_hidden"] -evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools"] -notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts, private data; overlapping objects and cropped content pending" +evidence = ["crates/redact/src/tests.rs::hidden_information_is_counted_and_removed", "crates/ui-egui/tests/ui_group/redact.rs::removing_hidden_information_and_sanitizing", "crates/automation/tests/automation.rs::removing_hidden_information_through_tools", "crates/redact/src/tests.rs::removed_links_and_comments_leave_the_structure_tree_and_the_saved_file"] +notes = "metadata, attachments, comments, form fields (flattened), hidden text, hidden layers, bookmarks, links/actions/scripts (with the structure tree's references to removed links and comments, #2447), private data; overlapping objects and cropped content pending" [[feature]] id = "protect.sanitize" @@ -6976,8 +6976,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7076,8 +7076,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter" +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html"