mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-04 01:29:57 +00:00
Merge storytold/pdfcraft main (picks up the zbus advisory ignores)
This commit is contained in:
commit
4938bbf272
23 files changed
+981
-115
No files matched your search
Generated
+36
-36
@@ -3965,7 +3965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-quit",
|
||||
"eframe",
|
||||
@@ -3990,7 +3990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4002,7 +4002,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4013,7 +4013,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4024,7 +4024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4045,7 +4045,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4061,18 +4061,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-crypt",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4102,7 +4102,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4114,7 +4114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4130,7 +4130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4169,14 +4169,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4186,7 +4186,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4196,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4208,18 +4208,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4232,14 +4232,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4249,7 +4249,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4262,11 +4262,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4277,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4287,7 +4287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4296,7 +4296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4307,7 +4307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4323,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4342,7 +4342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4376,11 +4376,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
@@ -4424,7 +4424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4436,7 +4436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4450,7 +4450,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+34
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.8.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.8.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.8.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.8.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.8.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.8.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.8.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.8.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.8.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.8.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.8.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.8.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.8.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.8.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.8.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.8.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.8.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.8.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.8.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.9.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.9.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.9.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.9.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.9.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.9.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.9.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
+12
@@ -2,7 +2,10 @@
|
||||
|
||||
**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 (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-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **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)
|
||||
@@ -157,6 +160,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
- **2026-10-15 (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-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
@@ -168,6 +174,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-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.
|
||||
@@ -495,6 +504,9 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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. |
|
||||
|
||||
@@ -500,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() {
|
||||
let result = audit_redactions(&open(nested_forms(33)));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_across_streams_neither_invents_text_nor_a_cover() {
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f"));
|
||||
assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported");
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
|
||||
let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect();
|
||||
assert!(!text.contains("After comment"), "editor exposed phantom text: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_blocks_respect_stream_comments_and_split_operands() {
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Before comment");
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET"));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Split operand");
|
||||
// Marked content opened in one stream applies to text in the next: its /ActualText replaces
|
||||
// the glyphs, as when the streams are read joined.
|
||||
for (s1, s2) in [
|
||||
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)".as_slice(), b">> BDC (Glyphs) Tj EMC ET".as_slice()),
|
||||
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)>> BDC", b"(Glyphs) Tj EMC ET"),
|
||||
] {
|
||||
let doc = open(two_streams(s1, s2));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,10 @@ pub struct Parsed {
|
||||
pub ops: Vec<Op>,
|
||||
/// Bytes that could not be read as operands or operators.
|
||||
pub skipped: usize,
|
||||
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
|
||||
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
|
||||
/// depends on the number of pieces, not of comments.
|
||||
comment_edges: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
fn is_ws(b: u8) -> bool {
|
||||
@@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool {
|
||||
|
||||
/// Parse a (decoded) content stream.
|
||||
pub fn parse(data: &[u8]) -> Parsed {
|
||||
let mut out = Parsed::default();
|
||||
parse_pieces(data, &[])
|
||||
}
|
||||
|
||||
/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment
|
||||
/// crosses.
|
||||
fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
|
||||
let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() };
|
||||
let mut boundary = 0;
|
||||
let mut lx = Lexer::new(data, 0);
|
||||
let mut operands: Vec<Object> = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
loop {
|
||||
let trivia = lx.pos;
|
||||
lx.skip_ws();
|
||||
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
|
||||
// string, a name, an array or dictionary operand or inline image data is not one.
|
||||
if boundary < ends.len() {
|
||||
comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges);
|
||||
}
|
||||
let Some(&b) = data.get(lx.pos) else { break };
|
||||
let at = lx.pos;
|
||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
@@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed {
|
||||
out
|
||||
}
|
||||
|
||||
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
|
||||
/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward.
|
||||
fn comment_edges(data: &[u8], trivia: std::ops::Range<usize>, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) {
|
||||
let mut at = trivia.start;
|
||||
while at < trivia.end {
|
||||
if data.get(at) != Some(&b'%') {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let comment = at;
|
||||
while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) {
|
||||
at += 1;
|
||||
}
|
||||
// The separator of piece `boundary` is the last byte before its end.
|
||||
while let Some(&end) = ends.get(*boundary) {
|
||||
let separator = end.saturating_sub(1);
|
||||
if separator >= at {
|
||||
break;
|
||||
}
|
||||
if separator >= comment {
|
||||
if let Some(edge) = edges.get_mut(*boundary) {
|
||||
edge.1 = true;
|
||||
}
|
||||
// Includes a comment whose end of line is the next piece's first byte.
|
||||
if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) {
|
||||
edge.0 = true;
|
||||
}
|
||||
}
|
||||
*boundary += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
|
||||
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
|
||||
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
|
||||
@@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
|
||||
/// A page's content streams joined into the one stream they are. Writers may split it between
|
||||
/// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its
|
||||
/// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back
|
||||
/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together.
|
||||
/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro,
|
||||
/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece
|
||||
/// runs on into the next one, up to its first end of line.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pieces {
|
||||
data: Vec<u8>,
|
||||
@@ -319,7 +371,7 @@ impl Pieces {
|
||||
let mut ends = Vec::with_capacity(pieces.len());
|
||||
for p in pieces {
|
||||
data.extend_from_slice(p.as_ref());
|
||||
data.push(b'\n');
|
||||
data.push(b' ');
|
||||
ends.push(data.len());
|
||||
}
|
||||
Pieces { data, ends }
|
||||
@@ -341,12 +393,22 @@ impl Pieces {
|
||||
|
||||
/// Parse the pieces as one stream.
|
||||
pub fn parse(&self) -> Parsed {
|
||||
parse(&self.data)
|
||||
parse_pieces(&self.data, &self.ends)
|
||||
}
|
||||
|
||||
/// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs
|
||||
/// into it from the piece before, and whether one runs out of it into the next. A writer
|
||||
/// that rewrites a piece without its comments must keep both: end the incoming comment
|
||||
/// before its first operator, and leave a comment open at its end.
|
||||
pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> {
|
||||
let mut edges = parsed.comment_edges.clone();
|
||||
edges.resize(self.len(), (false, false));
|
||||
edges
|
||||
}
|
||||
|
||||
/// The piece holding joined position `at` (the last piece for positions past the end).
|
||||
pub fn piece_of(&self, at: usize) -> usize {
|
||||
self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1))
|
||||
self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The pieces an operator spans: (the one its first token is in, the one its keyword is in).
|
||||
|
||||
@@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() {
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_boundary_comment_matches_space_concatenation() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut tail = b"(HIDDEN) Tj".to_vec();
|
||||
tail.extend(eol);
|
||||
tail.extend(b"(LIVE) Tj");
|
||||
let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]);
|
||||
let parsed = pieces.parse();
|
||||
let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect();
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_terminate_at_next_piece_byte_zero() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut next = eol.to_vec();
|
||||
next.extend_from_slice(b"q Q");
|
||||
let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}");
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_span_empty_pieces_and_end_of_content() {
|
||||
let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
||||
|
||||
let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]);
|
||||
assert!(parsed.ops.is_empty());
|
||||
|
||||
let pieces = Pieces::join::<&[u8]>(&[]);
|
||||
assert!(pieces.comment_edges(&pieces.parse()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_ignore_percent_inside_operands_and_image_data() {
|
||||
for first in [
|
||||
b"(literal % not a comment) Tj".as_slice(),
|
||||
b"/Percent#25Name MP",
|
||||
b"[(literal % not a comment)] TJ",
|
||||
b"/P << /ActualText (literal % not a comment) >> BDC EMC",
|
||||
b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI",
|
||||
] {
|
||||
let pieces = Pieces::join(&[first, b"q Q"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}");
|
||||
assert_eq!(parsed.ops.last().unwrap().op, b"Q");
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_operand_comment_split_is_owned_by_spanning_operator() {
|
||||
for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] {
|
||||
let pieces = Pieces::join(&parts);
|
||||
let parsed = pieces.parse();
|
||||
// The object lexer consumes this nested comment. Its containing operator spans
|
||||
// both pieces, which forces both to be rewritten together by the redactor.
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]);
|
||||
assert_eq!(parsed.ops.len(), 1);
|
||||
assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1));
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() {
|
||||
let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edge_storage_depends_on_piece_count_not_comment_count() {
|
||||
let mut first = b"%\n".repeat(16_384);
|
||||
first.extend_from_slice(b"q % crossing");
|
||||
let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]);
|
||||
assert_eq!(parsed.ops.len(), 2);
|
||||
// Also exercise public parsing without piece-boundary metadata.
|
||||
assert_eq!(parse(&first).ops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() {
|
||||
// Joined layout: q<space><space>Q<space>; separator belongs to prior piece.
|
||||
let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]);
|
||||
for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] {
|
||||
assert_eq!(pieces.piece_of(at), expected, "position {at}");
|
||||
}
|
||||
let empty = Pieces::join::<&[u8]>(&[]);
|
||||
assert_eq!(empty.piece_of(0), 0);
|
||||
assert_eq!(empty.piece_of(usize::MAX), 0);
|
||||
}
|
||||
@@ -482,6 +482,19 @@ mod tests {
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_flate_fallback_streams_preserve_the_caller_limit() {
|
||||
// Raw deflate with a stored byte and a final empty block. 78 01 also
|
||||
// passes the zlib header check, so decoding must retry the whole input.
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]);
|
||||
let result = stream.decoded_within(0);
|
||||
assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}");
|
||||
assert_eq!(stream.decoded_within(1).unwrap(), b"A");
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_filter_decodes_only_the_outer_wrapper() {
|
||||
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
|
||||
|
||||
+188
-17
@@ -327,7 +327,37 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio
|
||||
}
|
||||
|
||||
const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
|
||||
const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
/// Starts with an end of line, so a comment left open at the end of the content before it
|
||||
/// can't swallow the `Q`. Older files have the close without it.
|
||||
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
|
||||
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
|
||||
/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped
|
||||
/// content left open, or [`OLD_WRAP_CLOSE`].
|
||||
fn is_wrap_close(bytes: &[u8]) -> bool {
|
||||
let mut rest = bytes;
|
||||
loop {
|
||||
if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE {
|
||||
return true;
|
||||
}
|
||||
match rest.strip_prefix(b"\nQ".as_slice()) {
|
||||
Some(shorter) => rest = shorter,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)`
|
||||
/// for a close.
|
||||
fn wrapper_role(doc: &Document, o: &Object) -> Option<bool> {
|
||||
if stream_tag(doc, o).as_deref() != Some("Wrap") {
|
||||
return None;
|
||||
}
|
||||
let Object::Stream(stream) = &*doc.resolve(o) else { return None };
|
||||
// Wrappers are a few bytes; one that decodes to more isn't PdfCraft's.
|
||||
let bytes = stream.decoded_within(64 * 1024).ok()?;
|
||||
if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
|
||||
@@ -350,27 +380,138 @@ fn tagged(tag: &str) -> Dict {
|
||||
d
|
||||
}
|
||||
|
||||
/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags,
|
||||
/// which another writer may keep, copy or forge).
|
||||
enum Isolation {
|
||||
/// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths.
|
||||
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
|
||||
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
|
||||
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
|
||||
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)> },
|
||||
/// The content could not be read that precisely (it doesn't decode, is too large, or has
|
||||
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
|
||||
/// the count of `q` left open at the end, when known.
|
||||
Unknown { unclosed: usize },
|
||||
}
|
||||
|
||||
const ISOLATION_MAX_STREAMS: usize = 100_000;
|
||||
const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
const ISOLATION_MAX_OPS: usize = 1_000_000;
|
||||
const ISOLATION_MAX_DEPTH: usize = 10_000;
|
||||
|
||||
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
|
||||
let unknown = Isolation::Unknown { unclosed: 0 };
|
||||
if list.len() > ISOLATION_MAX_STREAMS {
|
||||
return unknown;
|
||||
}
|
||||
let mut remaining = ISOLATION_MAX_BYTES;
|
||||
let mut data = Vec::with_capacity(list.len());
|
||||
for object in list {
|
||||
let resolved = doc.resolve(object);
|
||||
let Object::Stream(stream) = &*resolved else { return unknown };
|
||||
let Ok(bytes) = stream.decoded_within(remaining) else { return unknown };
|
||||
let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown };
|
||||
remaining = left;
|
||||
data.push(bytes);
|
||||
}
|
||||
let pieces = pdfcraft_content::Pieces::join(&data);
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS {
|
||||
return unknown;
|
||||
}
|
||||
let edges = pieces.comment_edges(&parsed);
|
||||
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut split = 0;
|
||||
let mut previous_last = None;
|
||||
let mut wrappers = Vec::new();
|
||||
for op in &parsed.ops {
|
||||
let (first, last) = pieces.pieces_of(op);
|
||||
// A wrapper can only go in before a whole stream, at the top level, between operators,
|
||||
// and where no comment runs into the stream (it would swallow the wrapper's `q`).
|
||||
if stack.is_empty()
|
||||
&& !text_open
|
||||
&& !path_open
|
||||
&& !dirty
|
||||
&& previous_last.is_none_or(|previous| first > previous)
|
||||
&& !edges.get(first).is_some_and(|edge| edge.0)
|
||||
{
|
||||
split = first;
|
||||
}
|
||||
previous_last = Some(last);
|
||||
match op.op.as_slice() {
|
||||
b"BT" if !text_open => text_open = true,
|
||||
b"ET" if text_open => text_open = false,
|
||||
b"BT" | b"ET" => malformed = true,
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true,
|
||||
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first),
|
||||
b"Q" if op.operands.is_empty() => match stack.pop() {
|
||||
Some(open) => {
|
||||
// Only a standalone PdfCraft wrapper pair may later be removed again.
|
||||
let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
if wrap(open)
|
||||
&& wrap(first)
|
||||
&& data.get(open).is_some_and(|d| d == WRAP_OPEN)
|
||||
&& data.get(first).is_some_and(|d| is_wrap_close(d))
|
||||
{
|
||||
wrappers.push((open, first));
|
||||
}
|
||||
}
|
||||
None => malformed = true,
|
||||
},
|
||||
b"q" | b"Q" => malformed = true,
|
||||
_ if stack.is_empty() => dirty = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if malformed || text_open || path_open || !stack.is_empty() {
|
||||
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } };
|
||||
}
|
||||
let comment_open = edges.last().is_some_and(|edge| edge.1);
|
||||
Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers }
|
||||
}
|
||||
|
||||
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
|
||||
/// original content in q/Q).
|
||||
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
place_tagged(doc, page, kind.tag(), content, behind)
|
||||
}
|
||||
|
||||
/// Put content on a page, tagged `tag` (see [`place`]).
|
||||
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
|
||||
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
|
||||
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
|
||||
/// content that leaves state set is wrapped in q/Q, once.
|
||||
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
let mut list = contents(doc, page)?;
|
||||
let wrap = if behind {
|
||||
None
|
||||
} else {
|
||||
match isolation(doc, &list) {
|
||||
Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)),
|
||||
// Content that can't be read precisely is wrapped whole, as before: after the
|
||||
// behind-marks at the start. A `q` it leaves open is closed at its end, where
|
||||
// closing it changes nothing already drawn.
|
||||
Isolation::Unknown { unclosed } => {
|
||||
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
|
||||
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
|
||||
(split < list.len()).then_some((split, unclosed))
|
||||
}
|
||||
}
|
||||
};
|
||||
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
|
||||
if behind {
|
||||
list.insert(0, mark);
|
||||
} else {
|
||||
let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
let original = list.iter().any(|o| stream_tag(doc, o).is_none());
|
||||
if !wrapped && original {
|
||||
if let Some((split, unclosed)) = wrap {
|
||||
let mut close_bytes = b"\nQ".repeat(unclosed);
|
||||
close_bytes.extend_from_slice(WRAP_CLOSE);
|
||||
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec()))));
|
||||
// Behind-marks stay outside the wrapper.
|
||||
let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len());
|
||||
list.insert(split, open);
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
|
||||
list.insert(split.min(list.len()), open);
|
||||
list.push(close);
|
||||
}
|
||||
list.push(mark);
|
||||
@@ -613,8 +754,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
|
||||
/// top of a page, the q/Q wrapper goes too, restoring the original content list.
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
|
||||
/// nothing after them go too, restoring the original content list; a wrapper that content
|
||||
/// after it still relies on (another tool's appended stream) stays.
|
||||
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
@@ -625,12 +767,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
|
||||
let before = list.len();
|
||||
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
||||
removed += before - kept.len();
|
||||
// Marks on top come after the closing wrapper; without any, the wrapper has no purpose.
|
||||
let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap");
|
||||
let close = kept.iter().rposition(&is_wrap);
|
||||
let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap")));
|
||||
if !marks_on_top {
|
||||
kept.retain(|o| !is_wrap(o));
|
||||
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
|
||||
// it): nothing comes after them to need the state they restore. Pairs come from the
|
||||
// operators; for content that can't be read that precisely, from the wrapper streams
|
||||
// alone, so updating a mark there doesn't nest another wrapper each time.
|
||||
if before != kept.len() {
|
||||
let wrappers = match isolation(doc, &kept) {
|
||||
Isolation::Clean { wrappers, .. } => wrappers,
|
||||
Isolation::Unknown { .. } => {
|
||||
let mut opens = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
for (i, o) in kept.iter().enumerate() {
|
||||
match wrapper_role(doc, o) {
|
||||
Some(true) => opens.push(i),
|
||||
Some(false) => pairs.extend(opens.pop().map(|open| (open, i))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
};
|
||||
let pairs: std::collections::HashMap<usize, usize> = wrappers.into_iter().map(|(open, close)| (close, open)).collect();
|
||||
let mut discarded = std::collections::HashSet::new();
|
||||
let mut end = kept.len();
|
||||
while let Some(close) = end.checked_sub(1) {
|
||||
if discarded.contains(&close) {
|
||||
end = close;
|
||||
} else if let Some(&open) = pairs.get(&close) {
|
||||
discarded.insert(open);
|
||||
discarded.insert(close);
|
||||
end = close;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect();
|
||||
}
|
||||
if before != kept.len() {
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
|
||||
|
||||
+225
-1
@@ -77,7 +77,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content()
|
||||
let s0 = streams(&doc, 0);
|
||||
// q-wrapper, original, Q-wrapper, header/footer.
|
||||
assert_eq!(s0.len(), 4, "{s0:?}");
|
||||
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n"));
|
||||
// The close starts on a new line, so a comment the original ends in can't swallow it.
|
||||
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n"));
|
||||
let mark = &s0[3];
|
||||
assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}");
|
||||
assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity");
|
||||
@@ -5014,3 +5015,226 @@ fn test_image_move_does_not_emit_whiteout() {
|
||||
"cm cleanly updated: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
|
||||
let bytes = streams(doc, 0);
|
||||
let joined = pdfcraft_content::Pieces::join(&bytes);
|
||||
let mut state = std::collections::BTreeMap::new();
|
||||
let mut stack = Vec::new();
|
||||
let mut probes = Vec::new();
|
||||
for op in joined.parse().ops {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(state.clone()),
|
||||
b"Q" => state = stack.pop().expect("balanced graphics state"),
|
||||
b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => {
|
||||
state.insert(op.op, op.operands);
|
||||
}
|
||||
b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
probes
|
||||
}
|
||||
|
||||
fn isolation_fixture(parts: &[(&str, &str)]) -> Document {
|
||||
let mut doc = fixture();
|
||||
let page = page_list(&doc).remove(0);
|
||||
let list = parts
|
||||
.iter()
|
||||
.map(|(tag, bytes)| {
|
||||
Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec()))))
|
||||
})
|
||||
.collect();
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_isolate_state_appended_after_old_wrappers() {
|
||||
for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] {
|
||||
for tag in ["", "OCR", "Wrap"] {
|
||||
let mut doc = isolation_fixture(&[
|
||||
("Wrap", "q %PdfCraft\n"),
|
||||
("", "2 0 0 2 0 0 cm"),
|
||||
("Wrap", "Q %PdfCraft\n"),
|
||||
("Added", "q /PCProbe Do Q"),
|
||||
(tag, dirt),
|
||||
]);
|
||||
let before = probe_states(&doc);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
let after = probe_states(&reopen(&doc));
|
||||
assert_eq!(&after[..before.len()], &before, "existing content must not change");
|
||||
assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_isolate_dirty_tagged_only_pages() {
|
||||
let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
assert!(probe_states(&doc).last().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() {
|
||||
let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]);
|
||||
for _ in 0..10 {
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
}
|
||||
assert_eq!(streams(&doc, 0).len(), 13);
|
||||
assert!(probe_states(&doc).iter().all(|s| s.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_close_wrapper_after_an_unterminated_comment() {
|
||||
let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
// Check literal concatenation too: a separator must exist in the saved stream itself,
|
||||
// not just in the inspection parser, or the comment can swallow the closing Q.
|
||||
let bytes = streams(&doc, 0).concat();
|
||||
let parsed = pdfcraft_content::parse(bytes.as_bytes());
|
||||
assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2);
|
||||
assert!(probe_states(&doc).last().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() {
|
||||
// Content that can't be isolated precisely is wrapped whole, as before #3084, instead of
|
||||
// refusing: redaction stamps its boxes after removing the text, so it can't back out.
|
||||
for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] {
|
||||
let mut doc = isolation_fixture(&[("Wrap", data)]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap();
|
||||
let after = streams(&doc, 0);
|
||||
assert_eq!(after.len(), 4, "{data}: {after:?}");
|
||||
assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}");
|
||||
// A `q` the content leaves open is closed after it, so the mark starts clean. A stray
|
||||
// `Q` pops the wrapper itself: nothing placed after it can undo that.
|
||||
let probe = probe_states_lenient(&doc);
|
||||
assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is,
|
||||
/// as renderers do.
|
||||
fn probe_states_lenient(doc: &Document) -> Vec<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
|
||||
let joined = pdfcraft_content::Pieces::join(&streams(doc, 0));
|
||||
let mut state = std::collections::BTreeMap::new();
|
||||
let mut stack = Vec::new();
|
||||
let mut probes = Vec::new();
|
||||
for op in joined.parse().ops {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(state.clone()),
|
||||
b"Q" => state = stack.pop().unwrap_or(state),
|
||||
b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => {
|
||||
state.insert(op.op, op.operands);
|
||||
}
|
||||
b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
probes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_preserve_cross_stream_operands_and_q_pairs() {
|
||||
let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
assert!(probe_states(&doc).last().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_marks_keeps_wrappers_needed_by_later_external_content() {
|
||||
let mut doc = isolation_fixture(&[
|
||||
("Wrap", "q %PdfCraft\n"),
|
||||
("", "2 0 0 2 0 0 cm"),
|
||||
("Wrap", "Q %PdfCraft\n"),
|
||||
("HeaderFooter", "q /PCProbe Do Q"),
|
||||
("", "/PCProbe Do"),
|
||||
]);
|
||||
remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap();
|
||||
assert_eq!(probe_states(&doc).len(), 1);
|
||||
assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_wrap_unfinished_text_paths_and_operands_whole() {
|
||||
for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] {
|
||||
let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap();
|
||||
// The behind-mark stays outside the wrapper and the content itself is untouched.
|
||||
assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_do_not_reveal_cross_stream_comments() {
|
||||
let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
assert!(probe_states(&doc).last().unwrap().is_empty());
|
||||
let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0));
|
||||
assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_marks_follow_clean_streams_ending_in_comments() {
|
||||
let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment");
|
||||
assert!(probe_states(&doc)[0].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() {
|
||||
let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]);
|
||||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
|
||||
assert_eq!(probe_states(&doc).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_marks_strips_nested_trailing_wrapper_pairs() {
|
||||
let mut doc = isolation_fixture(&[
|
||||
("Wrap", "q %PdfCraft\n"),
|
||||
("Wrap", "q %PdfCraft\n"),
|
||||
("", "2 0 0 2 0 0 cm"),
|
||||
("Wrap", "Q %PdfCraft\n"),
|
||||
("Wrap", "Q %PdfCraft\n"),
|
||||
("HeaderFooter", "q Q"),
|
||||
]);
|
||||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
|
||||
assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_marks_on_unreadable_content_does_not_nest_wrappers() {
|
||||
for data in ["q 50 Tz", "Q 50 Tz", "BT"] {
|
||||
let mut doc = isolation_fixture(&[("", data)]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
for _ in 0..3 {
|
||||
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap();
|
||||
assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0));
|
||||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
|
||||
assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_marks_strips_wrappers_that_closed_open_state() {
|
||||
let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]);
|
||||
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
|
||||
assert!(probe_states(&doc).last().unwrap().is_empty());
|
||||
// Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs.
|
||||
let page = page_list(&doc).remove(0);
|
||||
place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap();
|
||||
assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0));
|
||||
let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap();
|
||||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
|
||||
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
|
||||
}
|
||||
+18
-6
@@ -1237,12 +1237,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
visits: usize,
|
||||
}
|
||||
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) {
|
||||
/// `stream` gives the stream number of the operator at an index of `ops`.
|
||||
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
|
||||
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let mut cache = HashMap::new();
|
||||
let mut invoked = Vec::new();
|
||||
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
|
||||
let mut last = None;
|
||||
let mut last_stream = None;
|
||||
let mut forms = invoked.into_iter().peekable();
|
||||
for s in shown {
|
||||
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
|
||||
@@ -1250,7 +1252,12 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
// Text after a form starts a new line.
|
||||
last = None;
|
||||
}
|
||||
add_shown(&mut w.lines, &mut last, s, stream);
|
||||
let si = stream(s.op);
|
||||
// Text from another stream starts a new line, as when each stream was read alone.
|
||||
if last_stream.replace(si).is_some_and(|prev| prev != si) {
|
||||
last = None;
|
||||
}
|
||||
add_shown(&mut w.lines, &mut last, s, si);
|
||||
}
|
||||
for (_, name, ts) in forms {
|
||||
enter(w, resources, &name, ts);
|
||||
@@ -1266,7 +1273,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
if let Some(r) = form.obj {
|
||||
w.path.push(r);
|
||||
}
|
||||
walk(w, &ops, &form.resources, &mut carry, stream);
|
||||
walk(w, &ops, &form.resources, &mut carry, &|_| stream);
|
||||
if form.obj.is_some() {
|
||||
w.path.pop();
|
||||
}
|
||||
@@ -1276,9 +1283,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
|
||||
let mut carry = Carry::new();
|
||||
for (si, (_, data)) in streams.into_iter().enumerate() {
|
||||
walk(&mut w, &parse(&data).ops, &res, &mut carry, si);
|
||||
}
|
||||
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
|
||||
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
|
||||
// can run on from one stream into the next. Text still counts as the stream its operator
|
||||
// starts in, which keeps lines from different streams apart.
|
||||
let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0);
|
||||
walk(&mut w, &ops, &res, &mut carry, &stream_of);
|
||||
let mut lines = w.lines;
|
||||
// A visual-order Indic font draws pre-base vowel signs before their consonant.
|
||||
for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) {
|
||||
|
||||
@@ -24,7 +24,7 @@ encode_flate(data) -> Vec<u8> // zlib, level 6
|
||||
|
||||
- `chain` is applied first to last, as in a `/Filter` array.
|
||||
- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`.
|
||||
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`.
|
||||
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding.
|
||||
- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
|
||||
|
||||
## Behaviour
|
||||
|
||||
@@ -26,7 +26,12 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step {
|
||||
Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())),
|
||||
Some(_) => match inflate_raw(&data[2..], max) {
|
||||
// A header that only looked valid: retry the whole input as raw deflate.
|
||||
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f),
|
||||
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) {
|
||||
// Keep the original corruption diagnostic only if the retry is corrupt
|
||||
// too. A limit error must remain fatal even for tolerant decoding.
|
||||
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f),
|
||||
retry => retry,
|
||||
},
|
||||
r => r,
|
||||
},
|
||||
None => inflate_raw(data, max),
|
||||
|
||||
@@ -295,6 +295,26 @@ mod tests {
|
||||
assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_flate_fallback_preserves_output_limits() {
|
||||
// RFC 1951 stored blocks: the first is nonfinal with LEN=257 and
|
||||
// NLEN=!257, followed by a final empty block. Its ignored alignment
|
||||
// bits make the first two bytes also look like a valid zlib header.
|
||||
let data = vec![b'A'; 257];
|
||||
let mut raw = vec![0x78, 0x01, 0x01, 0xfe, 0xfe];
|
||||
raw.extend_from_slice(&data);
|
||||
raw.extend_from_slice(&[0x01, 0x00, 0x00, 0xff, 0xff]);
|
||||
let chain = [(Filter::Flate, Params::default())];
|
||||
for max in [0, 1, 128, 256] {
|
||||
assert!(matches!(decode(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max));
|
||||
assert!(matches!(decode_tolerant(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max));
|
||||
}
|
||||
for max in [257, 258] {
|
||||
assert_eq!(decode(&chain, &raw, max).unwrap(), data);
|
||||
assert_eq!(decode_tolerant(&chain, &raw, max).unwrap(), (data.clone(), false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_flate_returns_partial() {
|
||||
let data: Vec<u8> = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect();
|
||||
|
||||
@@ -380,6 +380,9 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
// next. Parse them joined, then give each operator back to the piece its keyword is in.
|
||||
let joined = Pieces::join(streams);
|
||||
let mut parsed = joined.parse();
|
||||
// A rewritten piece loses its comments, so keep where they began and ended: a comment that
|
||||
// ran on into the next piece still hides that piece's first line.
|
||||
let comment_edges = joined.comment_edges(&parsed);
|
||||
if !scope.hidden_layers.is_empty() {
|
||||
let props = res_dict(doc, resources, b"Properties");
|
||||
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
|
||||
@@ -405,7 +408,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
}
|
||||
}
|
||||
|
||||
for (piece, mut changed) in pieces.into_iter().zip(rewrite) {
|
||||
for ((piece, mut changed), (comment_in, comment_out)) in pieces.into_iter().zip(rewrite).zip(comment_edges) {
|
||||
let mut ops: Vec<Op> = Vec::with_capacity(piece.len());
|
||||
for op in piece {
|
||||
let o = op.op.as_slice();
|
||||
@@ -622,7 +625,16 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
if !path.is_empty() {
|
||||
ops.append(&mut path);
|
||||
}
|
||||
out.streams.push(changed.then(|| serialize_ops(&ops)));
|
||||
out.streams.push(changed.then(|| {
|
||||
// The piece before still ends inside a comment: end it before the first operator.
|
||||
let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() };
|
||||
bytes.extend(serialize_ops(&ops));
|
||||
// The piece ended inside a comment that hid the start of the next one: keep it open.
|
||||
if comment_out {
|
||||
bytes.push(b'%');
|
||||
}
|
||||
bytes
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -400,7 +400,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
// Tags must not keep what the page no longer shows.
|
||||
let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
|
||||
let (_, new_data) = page_streams(doc, &after, pi)?;
|
||||
let (changed_ids, empty_ids) = tags::touched(doc, &resources, &data.join(&b'\n'), &new_data.join(&b'\n'));
|
||||
let (changed_ids, empty_ids) =
|
||||
tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data());
|
||||
report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -1117,3 +1117,42 @@ fn masks_and_alternates_outside_redaction_stay() {
|
||||
assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates"));
|
||||
assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_boundary_comment_stays_inert_after_unrelated_redaction() {
|
||||
for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] {
|
||||
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]);
|
||||
let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec())));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap();
|
||||
mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
|
||||
let reopened = reopen(&doc);
|
||||
let page = &pdfcraft_model::pages(&reopened)[0];
|
||||
let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap();
|
||||
// Independent of Pieces::join: the renderer concatenates streams with spaces.
|
||||
let ops = pdfcraft_content::parse(&data.join(&b' ')).ops;
|
||||
assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable");
|
||||
let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE")));
|
||||
assert_eq!(live, tail.contains(&b'\n'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewriting_a_piece_keeps_its_leading_comment_terminator() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut doc = one_page(b"q Q % note", "", vec![]);
|
||||
let mut tail = eol.to_vec();
|
||||
tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET");
|
||||
let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail)));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap();
|
||||
mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let (_, data) = page_streams(&doc, &p.dict, 0).unwrap();
|
||||
let ops = pdfcraft_content::parse(&data.join(&b' ')).ops;
|
||||
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed");
|
||||
assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP"))));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
|
||||
- **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and
|
||||
tab-delimited text (a row of names, a row of values).
|
||||
|
||||
Plain AcroForm XML imports retain all direct text around comments and processing instructions,
|
||||
including CDATA and character references. Nested-element and XFA interpretation are unchanged.
|
||||
XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with
|
||||
character references, so XML attribute normalization cannot change their identities.
|
||||
|
||||
Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws
|
||||
appearances PdfCraft can draw, and sets values through the form's own checks (formats,
|
||||
validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read
|
||||
|
||||
@@ -197,6 +197,9 @@ fn esc(s: &str) -> String {
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
// XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references.
|
||||
'\t' => o.push_str("	"),
|
||||
'\n' => o.push_str(" "),
|
||||
'\r' => o.push_str(" "),
|
||||
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
|
||||
c => o.push(c),
|
||||
@@ -1114,7 +1117,8 @@ fn import_table(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
.map(|c| {
|
||||
let name =
|
||||
c.attributes().find(|a| a.name() == "original").map(|a| a.value().to_string()).unwrap_or_else(|| c.tag_name().name().to_string());
|
||||
(name, vec![c.text().unwrap_or("").to_string()])
|
||||
// Comments and processing instructions do not end a field's character data.
|
||||
(name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()])
|
||||
})
|
||||
.collect()
|
||||
} else if t.lines().next().is_some_and(|l| l.contains('\t')) {
|
||||
|
||||
@@ -142,6 +142,108 @@ fn values(doc: &Document) -> Vec<(String, Vec<String>)> {
|
||||
fields(doc).into_iter().map(|f| (f.name, f.value)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_exports_preserve_whitespace_in_field_names() {
|
||||
let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"];
|
||||
let value = "value\twith\nlines\r\nand <XML> & \"quotes\"";
|
||||
let mut src = one_field();
|
||||
pdfcraft_forms::delete_field(&mut src, "Name").unwrap();
|
||||
for (index, name) in names.iter().enumerate() {
|
||||
let placeholder = format!("field{index}");
|
||||
add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap();
|
||||
// Existing PDFs can have leading/trailing whitespace even though add_field trims it.
|
||||
let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap();
|
||||
src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap();
|
||||
set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap();
|
||||
}
|
||||
let source_bytes = write_full(&src, &SaveOptions::default()).unwrap();
|
||||
let src = Document::open(Arc::new(source_bytes.clone())).unwrap();
|
||||
for format in [Format::Xml, Format::Xfdf] {
|
||||
let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) };
|
||||
let xml = roxmltree::Document::parse(&exported).unwrap();
|
||||
let exported_names: Vec<_> = if format == Format::Xfdf {
|
||||
xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect()
|
||||
} else {
|
||||
xml.root_element()
|
||||
.children()
|
||||
.filter(|n| n.is_element())
|
||||
.map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(exported_names, names, "{format:?}: {exported}");
|
||||
let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap();
|
||||
for name in names {
|
||||
set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap();
|
||||
}
|
||||
let report = import(&mut dst, exported.as_bytes()).unwrap();
|
||||
assert_eq!(report.fields, names.len(), "{format:?}: {report:?}");
|
||||
assert!(report.rejected.is_empty(), "{report:?}");
|
||||
let saved = write_full(&dst, &SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||||
assert_eq!(values(&reopened), values(&src), "{format:?}");
|
||||
}
|
||||
assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfdf_exports_preserve_whitespace_in_comment_attributes() {
|
||||
let mut src = one_field();
|
||||
let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment };
|
||||
let names = ["note\tline\nidentity", "note line identity"];
|
||||
let author = "Ada\tLovelace\nReview";
|
||||
let subject = "Subject\twith\nlines\r\nand & quotes\"";
|
||||
for name in names {
|
||||
add_annotation(
|
||||
&mut src,
|
||||
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() },
|
||||
&Meta { id: name.into(), date: None },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let annotation_refs = |doc: &Document| {
|
||||
let page = pdfcraft_model::pages(doc)[0].obj;
|
||||
doc.get(page)
|
||||
.as_dict()
|
||||
.unwrap()
|
||||
.get(b"Annots")
|
||||
.map(|a| doc.resolve(a))
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Object::as_ref)
|
||||
.filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text"))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for annotation in annotation_refs(&src) {
|
||||
src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap();
|
||||
}
|
||||
let source_bytes = write_full(&src, &SaveOptions::default()).unwrap();
|
||||
let mut dst = Document::open(Arc::new(source_bytes)).unwrap();
|
||||
let file = "form\twith\nlines.pdf";
|
||||
let xfdf = export_xfdf(&dst, true, false, file);
|
||||
let xml = roxmltree::Document::parse(&xfdf).unwrap();
|
||||
let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect();
|
||||
assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::<Vec<_>>(), names);
|
||||
for note in notes {
|
||||
assert_eq!(note.attribute("title"), Some(author));
|
||||
assert_eq!(note.attribute("subject"), Some(subject));
|
||||
}
|
||||
assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file));
|
||||
let before = annotation_refs(&dst);
|
||||
let report = import(&mut dst, xfdf.as_bytes()).unwrap();
|
||||
assert_eq!(report.comments, names.len());
|
||||
assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments");
|
||||
let saved = write_full(&dst, &SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||||
for (annotation, name) in annotation_refs(&reopened).iter().zip(names) {
|
||||
let dict = reopened.get(*annotation);
|
||||
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name));
|
||||
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author));
|
||||
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfdf_round_trips_comments_and_values() {
|
||||
let src = filled();
|
||||
@@ -1011,3 +1113,34 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() {
|
||||
assert_eq!(values(&reopened)[0].1, want, "{xml_text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_form_import_preserves_text_around_comments_and_instructions() {
|
||||
for (body, expected) in [
|
||||
("left<!-- ignored -->right", "leftright"),
|
||||
("<!-- first -->left<?note ignored?>middle<!-- last -->right", "leftmiddleright"),
|
||||
("<![CDATA[<left>]]><!-- ignored -->&right", "<left>&right"),
|
||||
(" left <!-- ignored --> right ", " left right "),
|
||||
("<!-- empty --><?note ignored?>", ""),
|
||||
] {
|
||||
let mut original = one_field();
|
||||
let root = original.root().unwrap();
|
||||
original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap();
|
||||
set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap();
|
||||
let source = write_full(&original, &SaveOptions::default()).unwrap();
|
||||
let mut doc = Document::open(Arc::new(source.clone())).unwrap();
|
||||
let xml = format!("<?xml version=\"1.0\"?><fields><Name>{body}</Name></fields>");
|
||||
let report = import(&mut doc, xml.as_bytes()).unwrap();
|
||||
assert_eq!(report.fields, 1);
|
||||
assert!(report.rejected.is_empty());
|
||||
for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
|
||||
let reopened = Document::open(Arc::new(saved)).unwrap();
|
||||
let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap();
|
||||
let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] };
|
||||
assert_eq!(name.value, want, "XML body: {body}");
|
||||
let root = reopened.get(reopened.root().unwrap());
|
||||
assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained");
|
||||
}
|
||||
assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,13 @@ ignore = [
|
||||
# winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows
|
||||
# and is not in the binary.
|
||||
{ id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" },
|
||||
# zvariant, zbus_names and zvariant_derive are unmaintained (no vulnerability, and no fixed
|
||||
# release to move to). They reach the tree as the D-Bus stack behind `accesskit_unix` ->
|
||||
# `atspi`, which is Linux accessibility only, and as a dev-dependency of pdfcraft-platform.
|
||||
# Revisit when the accesskit/zbus chain moves to a maintained successor.
|
||||
{ id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained; Linux a11y stack via accesskit_unix" },
|
||||
{ id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained; Linux a11y stack via accesskit_unix" },
|
||||
{ id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained; Linux a11y stack via accesskit_unix" },
|
||||
]
|
||||
|
||||
# egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Where PdfCraft falls short of Acrobat Pro
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **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-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -140,6 +141,9 @@ update the numbers.
|
||||
(#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the
|
||||
selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place
|
||||
when a figure is marked decorative (#1324). The rest of the backlog is untriaged.
|
||||
- **Fix verified (2026-10-14):** raw-deflate fallback returns a decoding-limit error instead of
|
||||
accepting an empty partial stream when a misleading zlib header triggers a retry. Synthetic
|
||||
filter and COS stream regressions cover refusal at the limit and decoding within it.
|
||||
- **Impact:** every one is a user who lost trust. Crash reports outrank feature work
|
||||
(`AGENTS.md` §4).
|
||||
- **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file.
|
||||
@@ -379,6 +383,7 @@ update the numbers.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) |
|
||||
| 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. |
|
||||
| 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. |
|
||||
|
||||
+9
-2
@@ -1,5 +1,8 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -97,7 +100,7 @@ total matches the parity estimate.
|
||||
| M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). Missing: ≥ 250 tests, own image codecs |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
@@ -117,7 +120,8 @@ total matches the parity estimate.
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
@@ -179,6 +183,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
|
||||
| 2026-10-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. |
|
||||
|
||||
@@ -118,8 +118,8 @@ milestone = "M1"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors"]
|
||||
notes = ""
|
||||
evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit"]
|
||||
notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. Image codecs remain outside this general-purpose filter implementation."
|
||||
|
||||
[[feature]]
|
||||
id = "core.image-filters"
|
||||
@@ -4160,8 +4160,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.export"]
|
||||
tools = ["doc_export_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = ""
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"]
|
||||
notes = "XFDF exports retain tabs and line breaks in comment identities and metadata."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.export-selected"
|
||||
@@ -4692,8 +4692,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.export_data"]
|
||||
tools = ["doc_export_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)"
|
||||
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
|
||||
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references."
|
||||
|
||||
[[feature]]
|
||||
id = "form.data-xml-csv-txt"
|
||||
@@ -4704,8 +4704,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["form.export_data", "form.import_data"]
|
||||
tools = ["doc_export_data", "doc_import_data"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
|
||||
notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited"
|
||||
evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
|
||||
notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. XML/XFDF exports retain tabs and line breaks in field identities through character references."
|
||||
|
||||
[[feature]]
|
||||
id = "form.merge-to-spreadsheet"
|
||||
|
||||
Reference in new issue
Block a user