- 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.
-
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