diff --git a/Cargo.lock b/Cargo.lock index 5bcb1d80dc..0c1bcdec8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -4699,9 +4699,11 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-geom", + "pdfcraft-testkit", "serde", "skrifa 0.44.0", "thiserror 2.0.21", + "unicode-bidi", ] [[package]] @@ -4835,10 +4837,12 @@ dependencies = [ name = "pdfcraft-xfdf" version = "0.18.1" dependencies = [ + "base64 0.23.1", "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", + "proptest", "roxmltree 0.21.2", "thiserror 2.0.21", ] @@ -8762,7 +8766,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8777,18 +8780,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index d919a90383..b0f3411624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ 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 = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.18.1" } pdfcraft-filters = { path = "crates/filters", version = "0.18.1" } diff --git a/README.md b/README.md index f488d160cc..7ca789c6e0 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo adds it, as a first install would. Use the MSI for your architecture. Per-user installation overrides are not supported. +To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the +welcome screen (type it, or Browse…) or pass it on the command line: +`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1` +on the command line for everyone). An upgrade keeps the folder the installed version uses unless +the command line gives another one. + **Pin the checksum, not just the version.** Release candidates of a version share its MSI version (MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't diff --git a/ROADMAP.md b/ROADMAP.md index ee91096427..c2c7c6ad62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. +- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft. + +- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way. + +- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`. + - **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged. - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged. @@ -258,6 +264,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. - **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view. +- **2026-11-02 (M3, #5716):** A snapshot is drawn at 300 ppi, or at the zoom if that is finer, so one taken zoomed out stays readable; an area too large for one 4096-pixel tile comes out as fine as a tile allows (about 147 ppi for 2000 pt). A choice of resolution and a selection that stays active remain. - **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks. - **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. @@ -445,6 +452,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. +- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. - **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index c64f9e1c0f..86b31bafd3 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -421,6 +421,12 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); } + pdfcraft_render::RenderWarning::Type3WidthsMissing => { + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give" + ); + } } } Ok(()) diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 751785d0c5..448eb40d68 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -4,8 +4,9 @@ //! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe ""`, and //! a file manager or `xdg-open` starts `pdfcraft ` the same way, so until now every file got //! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10 -//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy, -//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same +//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft` (a portable copy's too, as +//! `portable-instance-…` and never in its own folder, #889) and is named after the Windows +//! session (`instance-s1.sock` for session 1), so two sign-ins of the same //! user never open files in each other's windows. On Linux and the BSDs it is in //! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a //! launch on another display or seat opens its own window there. Either way it lives in a folder diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 7996567f04..011656c7e7 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -4,7 +4,9 @@ //! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with //! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box. //! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line -//! endings), so callers never replace an appearance with a worse one. +//! endings), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` +//! itself; for the other values, callers that hold the document follow references first +//! (`draw::with_direct_values`). //! //! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard //! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3 @@ -147,6 +149,29 @@ pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> { .collect() } +/// The points where the cubic Bézier segment from `p0` (control points `c1`, `c2`) to `p3` turns +/// back in x or in y, strictly between its ends: with its ends, they bound the segment. +fn cubic_extremes(p0: (f64, f64), c1: (f64, f64), c2: (f64, f64), p3: (f64, f64)) -> Vec<(f64, f64)> { + let at = |t: f64| { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + (w[0] * p0.0 + w[1] * c1.0 + w[2] * c2.0 + w[3] * p3.0, w[0] * p0.1 + w[1] * c1.1 + w[2] * c2.1 + w[3] * p3.1) + }; + let mut out = Vec::new(); + for (q0, q1, q2, q3) in [(p0.0, c1.0, c2.0, p3.0), (p0.1, c1.1, c2.1, p3.1)] { + // The derivative over 3: a t² + 2 b t + c. + let (a, b, c) = (-q0 + 3.0 * q1 - 3.0 * q2 + q3, q0 - 2.0 * q1 + q2, q1 - q0); + let roots = if a.abs() < 1e-12 { + if b.abs() < 1e-12 { vec![] } else { vec![-c / (2.0 * b)] } + } else { + let disc = b * b - a * c; + if disc < 0.0 { vec![] } else { vec![(-b + disc.sqrt()) / a, (-b - disc.sqrt()) / a] } + }; + out.extend(roots.into_iter().filter(|t| *t > 0.0 && *t < 1.0).map(at)); + } + out +} + /// `c` operators for [`smooth_segments`] (the path already starts at the first point). fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() @@ -545,7 +570,15 @@ impl AppearanceDrawing { /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(doc: &Document, d: &Dict) -> Option { - let context = appearance_context(doc, d)?; + build_on_page(doc, d, None) +} + +/// [`build`] for an annotation on a page whose visible region is `page` (`Page::crop` in +/// pdfcraft-model): a text markup, line, polyline, polygon or ink annotation whose points reach +/// past its `/Rect` is drawn through them as far as the page shows ([`point_reach`]), and its +/// appearance's `/BBox` says how far ([`widened_rect`]). Without a page it is drawn within `/Rect`. +pub(crate) fn build_on_page(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> Option { + let context = appearance_context(doc, d, page)?; let drawing = AppearanceDrawing::new(&context); match context.subtype { b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), @@ -559,15 +592,15 @@ pub fn build(doc: &Document, d: &Dict) -> Option { finish_appearance(&context, drawing) } -fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option> { +fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]>) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } let subtype = d.name(b"Subtype")?; - let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let rect = normalised_rect(d)?; + let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect); // Cloudy borders (§12.5.4) are drawn for polygons only. let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); if cloudy && subtype != b"Polygon" { @@ -586,6 +619,170 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option Option<[f64; 4]> { + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"Rect")?).ok()?; + Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) +} + +/// The subtypes drawn through points of their own rather than within `/Rect`: text markup over +/// its `/QuadPoints` (ISO 32000-2 §12.5.6.10), lines along `/L` (§12.5.6.7), polygons and +/// polylines through `/Vertices` (§12.5.6.9) and ink through `/InkList` (§12.5.6.13). +pub(crate) fn is_drawn_from_points(d: &Dict) -> bool { + matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" | b"Line" | b"PolyLine" | b"Polygon" | b"Ink")) +} + +/// At most this many quadrilaterals widen a text markup's box ([`point_reach`]). Each takes in a +/// word or a group of contiguous words (ISO 32000-2 Table 182), usually a line, so even written +/// word by word a markup over a whole dense page stays well below it; a larger one is drawn +/// within its `/Rect`, as before the box was widened. +pub(crate) const MAX_MARKUP_QUADS: usize = 10_000; + +/// At most this many points widen a line's, polyline's, polygon's or ink annotation's box +/// ([`point_reach`]): as many as [`MAX_MARKUP_QUADS`] quadrilaterals have. A larger one is drawn +/// within its `/Rect`. +pub(crate) const MAX_REACH_POINTS: usize = 4 * MAX_MARKUP_QUADS; + +/// The points `d` is drawn through, as `x y` pairs, and how far its strokes reach past them: +/// half the border width, and for a line ending (`/LE`) or a cloudy border (§12.5.4) the size +/// they are drawn at. `None` for other subtypes, more points than the caps allow, and points that +/// aren't all finite numbers. +fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec, f64)> { + let w = border_width(d); + let has_ending = || d.get(b"LE").and_then(Object::as_array).is_some_and(|e| e.iter().any(|e| e.as_name().is_some_and(|e| e != b"None"))); + let (points, margin) = match d.name(b"Subtype")? { + subtype @ (b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly") => { + // The quads `draw_markup` draws (`quads`: resolved, malformed ones skipped), counted + // before the numbers are read. + let array = doc.resolve(d.get(b"QuadPoints")?); + let entries = array.as_array()?; + if entries.len() / 8 > MAX_MARKUP_QUADS { + return None; + } + let quads = pdfcraft_geom::quad_points::usable_quads(entries.iter().map(|x| doc.resolve(x).as_f64())); + if quads.is_empty() { + return None; + } + // How far each quad's stroke reaches past it: half the line's width, and a squiggle's + // peaks above a quad lower than they are (`draw_markup`). + let margin = quads.iter().map(|q| markup_overreach(subtype, (q[0] - q[4]).hypot(q[1] - q[5]))).fold(0.0, f64::max); + (quads.into_iter().flatten().collect(), margin) + } + b"Ink" => { + let strokes = d.get(b"InkList")?.as_array()?; + let count = strokes.iter().try_fold(0usize, |sum, s| sum.checked_add(s.as_array()?.len()))?; + if count / 2 > MAX_REACH_POINTS { + return None; + } + let mut points = Vec::with_capacity(count.saturating_mul(3)); + for s in strokes { + let stroke: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; + // A stroke's odd last number is no point (`draw_ink` skips it too). + let pts: Vec<(f64, f64)> = stroke.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + points.extend(pts.iter().flat_map(|p| [p.0, p.1])); + // The smooth curve through three or more points (`smooth_segments`) can bulge + // past them: its turning points count too. + if pts.len() > 2 { + let mut start = pts.first().copied().unwrap_or_default(); + for [c1, c2, end] in smooth_segments(&pts) { + points.extend(cubic_extremes(start, c1, c2, end).iter().flat_map(|p| [p.0, p.1])); + start = end; + } + } + } + (points, w / 2.0) + } + subtype @ (b"PolyLine" | b"Polygon") => { + let count = d.get(b"Vertices")?.as_array()?.len(); + if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS { + return None; + } + let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C")); + // A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`). + let reach = match subtype { + b"Polygon" if cloudy => 2.0 * cloud_radius(w), + b"PolyLine" if has_ending() => arrow_size(w), + _ => 0.0, + }; + (nums(d, b"Vertices")?, w / 2.0 + reach) + } + b"Line" => { + let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?; + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) { + return None; + } + // Leader lines move the line off `/L` (§12.5.6.7, Table 178). + let layout = line_layout(d, [(x0, y0), (x1, y1)], w); + let mut points = vec![x0, y0, x1, y1]; + for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()).chain(layout.tick.iter().flatten()) { + points.extend([*x, *y]); + } + // The caption's box: its width along the text matrix, from the descender to the + // cap height and a little over (Helvetica, as `draw_line` sets it). + if let Some((text, m)) = &layout.caption { + let width = text_width(text, CAPTION_SIZE); + for (x, y) in [(0.0, -0.25), (width, -0.25), (0.0, 1.0), (width, 1.0)].map(|(x, y)| (x, y * CAPTION_SIZE)) { + points.extend([m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]); + } + } + (points, w / 2.0 + if has_ending() { arrow_size(w) } else { 0.0 }) + } + _ => return None, + }; + (points.iter().all(|v| v.is_finite()) && margin.is_finite()).then_some((points, margin)) +} + +/// An annotation's `/Rect` (normalised, `rect`) grown to take in the points it is drawn through +/// ([`drawn_points`]) where they reach past it, as far as the page's visible region `page` goes; +/// `None` when they don't, aren't usable or number more than the caps allow, and for subtypes +/// drawn within `/Rect` ([`is_drawn_from_points`]). The points are what the annotation draws (ISO +/// 32000-2 Table 182 `/QuadPoints`, Table 178 `/L`, Table 181 `/Vertices`, Table 185 `/InkList`), +/// and the appearance is mapped onto `/Rect` (§12.5.5), so a `/Rect` that leaves them out would +/// cut off what is drawn: a highlight or ink stroke whose `/Rect` lies beside it drew nothing. +/// Once a point is outside, the box takes in the strokes' width and line endings too. Nothing +/// outside the crop box is shown or printed (§14.11.2), so the box grows no further than it; it +/// never shrinks `rect`, and a `/Rect` that holds every point is kept as it is. +fn point_reach(doc: &Document, d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> { + if !rect.iter().all(|v| v.is_finite()) { + return None; + } + let (points, margin) = drawn_points(doc, d)?; + let mut reach = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for p in points.as_chunks::<2>().0 { + reach = [reach[0].min(p[0]), reach[1].min(p[1]), reach[2].max(p[0]), reach[3].max(p[1])]; + } + let clip = |b: [f64; 4]| [b[0].max(page[0]), b[1].max(page[1]), b[2].min(page[2]), b[3].min(page[3])]; + let shown = clip(reach); + // Strokes wholly off the page widen nothing. Points `/Rect` already holds don't either, and + // points off the page whose strokes reach onto it only as far as `/Rect` holds. + let stroked = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]); + if stroked[0] > stroked[2] || stroked[1] > stroked[3] { + return None; + } + let inside = |b: [f64; 4]| rect[0] <= b[0] && rect[1] <= b[1] && b[2] <= rect[2] && b[3] <= rect[3]; + let on_page = shown[0] <= shown[2] && shown[1] <= shown[3]; + if inside(if on_page { shown } else { stroked }) { + return None; + } + let reach = stroked; + let out = [rect[0].min(reach[0]), rect[1].min(reach[1]), rect[2].max(reach[2]), rect[3].max(reach[3])]; + // A `/Rect` of its own can still be too wide for a width or height (`-1e308` to `1e308`). + let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite(); + (usable && out != rect).then_some(out) +} + +/// The `/Rect` an annotation takes with its built appearance `stream`: the appearance's `/BBox` +/// where [`point_reach`] widened it past `/Rect`, as the appearance is mapped onto `/Rect` +/// (§12.5.5); `None` (keep `/Rect`) otherwise and for subtypes drawn within `/Rect`. +pub(crate) fn widened_rect(d: &Dict, stream: &Stream) -> Option<[f64; 4]> { + if !is_drawn_from_points(d) { + return None; + } + let bbox = <[f64; 4]>::try_from(nums(&stream.dict, b"BBox")?).ok()?; + (bbox.iter().all(|v| v.is_finite()) && Some(bbox) != normalised_rect(d)).then_some(bbox) +} + fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { match context.subtype { b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), @@ -621,6 +818,30 @@ fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDra Some(form(context.rect, &out, drawing.resources)) } +/// The line width of an underline or strike-out over a quad `h` high. +fn markup_line_width(h: f64) -> f64 { + (h * 0.07).clamp(0.5, 3.0) +} + +/// A squiggle's line width and amplitude over a quad `h` high: its peaks stand twice the +/// amplitude above the quad's bottom edge. +fn squiggle(h: f64) -> (f64, f64) { + ((h * 0.05).clamp(0.5, 2.0), (h * 0.06).max(0.75)) +} + +/// How far a text markup of `subtype` drawn over a quad `h` high reaches past the quad: half its +/// line's width, and for a squiggle, how far its peaks stand above a quad lower than they are. +fn markup_overreach(subtype: &[u8], h: f64) -> f64 { + match subtype { + b"Underline" | b"StrikeOut" => markup_line_width(h) / 2.0, + b"Squiggly" => { + let (lw, amp) = squiggle(h); + lw / 2.0 + (amp * 2.0 - h).max(0.0) + } + _ => 0.0, + } +} + fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { let subtype = context.subtype; let stroke = context.stroke; @@ -639,15 +860,14 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); } b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); + let lw = markup_line_width(h); let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; let (a, b) = (at(p3, p1, t), at(p4, p2, t)); c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); } _ => { // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); + let (lw, amp) = squiggle(h); let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); let step = (h / 4.0).max(1.5); let steps = ((len / step).ceil() as usize).clamp(1, 10_000); @@ -1078,7 +1298,11 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin let mut rect = rect; if d.contains(b"CL") { let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + // `/RD` is optional (Table 177; null is absent, §7.3.9): without it the text box is `/Rect`. + let rd = match d.get(b"RD") { + None | Some(Object::Null) => vec![0.0; 4], + Some(_) => nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?, + }; rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { return None; diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 5c9701ce81..c0e82eb151 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -33,14 +33,70 @@ pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; - apply_appearance(doc, r, &d, stream) + let page = appearance::is_drawn_from_points(&d).then(|| page_box_listing(doc, r)).flatten(); + let direct = with_direct_values(doc, &d); + let Some(stream) = build_appearance(doc, &d, &direct, page) else { return Err(AnnotError::Unsupported(subtype)) }; + let built = Built::new(stream, &direct); + apply_appearance(doc, r, &d, built) } -/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. -/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. -fn build_appearance(doc: &Document, d: &Dict) -> Option { - let mut stream = appearance::build(doc, d)?; +/// Whether [`set_appearance`] can draw the annotation `d`: read as the display reads it, with +/// references followed and the same fallback. Edits check this before changing anything. +pub(crate) fn can_draw(doc: &Document, d: &Dict) -> bool { + build_appearance(doc, d, &with_direct_values(doc, d), None).is_some() +} + +/// The visible region (`Page::crop`) of the page that lists `r` among its annotations: the page +/// its `/P` names when that page lists it, read up its own `/Parent` chain, else the first page +/// of the tree that lists it. An annotation is listed by one page only (ISO 32000-2 §12.5.2), so +/// both find the same page, and the first reads one page instead of walking the whole tree for +/// every comment redrawn. +fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> { + let named = annot_dict(doc, r).reference(b"P").filter(|p| annots(doc, *p).iter().any(|a| a.as_ref() == Some(r))); + if let Some(page) = named { + return Some(page_with_inherited_boxes(doc, page).crop(doc)); + } + pdfcraft_model::pages(doc).into_iter().find(|p| annots(doc, p.obj).iter().any(|a| a.as_ref() == Some(r))).map(|p| p.crop(doc)) +} + +/// The page `page` with the `/MediaBox` and `/CropBox` it inherits from its ancestors filled in +/// (ISO 32000-2 §7.7.3.4), enough for [`pdfcraft_model::Page::crop`]. Follows at most 64 +/// `/Parent` links and stops at a loop. +fn page_with_inherited_boxes(doc: &Document, page: ObjRef) -> pdfcraft_model::Page { + let mut dict = doc.get(page).as_dict().cloned().unwrap_or_default(); + let mut seen = std::collections::HashSet::from([page]); + let mut up = dict.reference(b"Parent"); + while let Some(node) = up { + if seen.len() > 64 || !seen.insert(node) { + break; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { break }; + for key in [&b"MediaBox"[..], b"CropBox"] { + if !dict.contains(key) + && let Some(v) = d.get(key) + { + dict.set(key.to_vec(), v.clone()); + } + } + up = d.reference(b"Parent"); + } + pdfcraft_model::Page { obj: page, dict } +} + +/// Draw the appearance for an annotation dictionary on a page whose visible region is `page` +/// (used by annotations drawn through points of their own, [`appearance::build_on_page`]): the +/// expensive half of [`set_appearance`]. `direct` is `d` with references followed +/// ([`with_direct_values`]). Pure from the dictionary, the objects it points at and the page, so +/// [`AppearanceCache`] can reuse it. +fn build_appearance(doc: &Document, d: &Dict, direct: &Dict, page: Option<[f64; 4]>) -> Option { + // References followed first. Where that turns up something the builder doesn't draw (a + // cloudy `/BE` on a square), the drawing made without them stays, rather than none. + let mut stream = match appearance::build_on_page(doc, direct, page) { + Some(stream) => stream, + None if !std::ptr::eq(direct, d) => appearance::build_on_page(doc, d, page)?, + None => return None, + }; // Picture-stamp restyling must retain the placement's page-axis correction. if d.get(b"PCPicture").is_some() { let matrix = d.get(b"AP").and_then(|ap| { @@ -56,10 +112,170 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option { Some(stream) } +/// Entries that link the annotation to other objects rather than describe how it looks: left as +/// references by [`with_direct_values`]. +const LINK_KEYS: [&[u8]; 5] = [b"P", b"Popup", b"IRT", b"Parent", b"AP"]; + +/// How deep inside an entry [`with_direct_values`] follows references: `/InkList` is an array of +/// arrays, `/Border` may end in a dash array. +const INLINE_DEPTH: usize = 4; + +/// Values [`with_direct_values`] copies out of referenced arrays and dictionaries, or walks +/// before finding one too large to copy, for one annotation, so a file can't have a large object +/// copied or scanned for every entry that points at it. +pub(crate) const INLINE_BUDGET: usize = 1 << 16; + +/// Values copied or walked for all the annotations one display pass draws +/// ([`with_missing_appearances_cached`]), so a large object that many annotations point at is +/// not copied, or scanned and found too large, for each of them without bound. Past it, +/// references are left as they are. +pub(crate) const PASS_BUDGET: usize = 1 << 22; + +/// `d` with the references among the values [`appearance::build`] reads followed. Any value may be +/// given by reference (ISO 32000-2 §7.3.10): a border style dictionary (`/BS 5 0 R`, Table 168), a +/// colour, `/RD`, `/CL`, a `/W` inside `/BS`. The builder reads only direct values (apart from +/// `/QuadPoints`, which it resolves itself), so it draws from this copy. The entries in +/// [`LINK_KEYS`] and streams stay references; past [`INLINE_DEPTH`] levels or [`INLINE_BUDGET`] +/// copied values a reference is left as it is, which the builder treats as unreadable. `d` itself +/// when nothing is a reference. +pub(crate) fn with_direct_values<'d>(doc: &Document, d: &'d Dict) -> std::borrow::Cow<'d, Dict> { + with_direct_values_within(doc, d, &mut INLINE_BUDGET.clone()) +} + +/// [`with_direct_values`], copying at most [`INLINE_BUDGET`] values and no more than `pass` has +/// left, which is reduced by what was copied. +pub(crate) fn with_direct_values_within<'d>(doc: &Document, d: &'d Dict, pass: &mut usize) -> std::borrow::Cow<'d, Dict> { + let allowed = INLINE_BUDGET.min(*pass); + let mut budget = allowed; + let mut out: Option = None; + for (key, value) in d.iter() { + if LINK_KEYS.contains(&key.as_slice()) { + continue; + } + if let Some(direct) = direct_value(doc, value, INLINE_DEPTH, &mut budget) { + out.get_or_insert_with(|| d.clone()).set(key.clone(), direct); + } + } + *pass = pass.saturating_sub(allowed.saturating_sub(budget)); + out.map_or(std::borrow::Cow::Borrowed(d), std::borrow::Cow::Owned) +} + +/// The number of values in `o`, counting into its arrays and dictionaries (not through +/// references, which are charged when followed), or, past `limit`, `Err` with the values it +/// visited or queued before finding that out (at least one), so a rejected walk is charged too. +/// Walks at most `limit` values and holds no more than `limit` waiting. +fn weight(o: &Object, limit: usize) -> Result { + let mut count = 0usize; + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + let spent = count.saturating_add(stack.len()).max(1); + count = match count.checked_add(1).filter(|c| *c <= limit) { + Some(c) => c, + None => return Err(spent), + }; + let inner = match o { + Object::Array(items) => items.len(), + Object::Dict(entries) => entries.len(), + _ => continue, + }; + // Every value waiting is counted later, so more than `limit` in all is over it already. + if count.saturating_add(stack.len()).saturating_add(inner) > limit { + return Err(spent.saturating_add(1)); + } + match o { + Object::Array(items) => stack.extend(items.iter()), + Object::Dict(entries) => stack.extend(entries.iter().map(|(_, v)| v)), + _ => {} + } + } + Ok(count) +} + +/// `o` with references followed `depth` levels into arrays and dictionaries, or `None` when there +/// is nothing to follow (or it may not be: a stream, an over-budget object, a chain of references). +fn direct_value(doc: &Document, o: &Object, depth: usize, budget: &mut usize) -> Option { + let held; + let (base, followed) = match o { + Object::Ref(_) => { + // A spent budget follows nothing more, not even to find out what is there. + if *budget == 0 { + return None; + } + held = doc.resolve(o); + if matches!(&*held, Object::Stream(_) | Object::Ref(_)) { + return None; + } + // Everything copied counts, inner arrays and dictionaries too, and so does the walk + // that finds an object too large: many references to one would otherwise each walk + // it again for free. + match weight(&held, *budget) { + Ok(n) => *budget = budget.checked_sub(n)?, + Err(spent) => { + *budget = budget.saturating_sub(spent); + return None; + } + } + (&*held, true) + } + other => (other, false), + }; + let Some(next) = depth.checked_sub(1) else { return followed.then(|| base.clone()) }; + match base { + Object::Array(items) => { + let mut changed: Option> = None; + for (i, item) in items.iter().enumerate() { + if let Some(direct) = direct_value(doc, item, next, budget) { + let copy = changed.get_or_insert_with(|| items.clone()); + if let Some(slot) = copy.get_mut(i) { + *slot = direct; + } + } + } + changed.map(Object::Array).or_else(|| followed.then(|| base.clone())) + } + Object::Dict(entries) => { + let mut changed: Option = None; + for (key, value) in entries.iter() { + if let Some(direct) = direct_value(doc, value, next, budget) { + changed.get_or_insert_with(|| entries.clone()).set(key.clone(), direct); + } + } + changed.map(Object::Dict).or_else(|| followed.then(|| base.clone())) + } + _ => followed.then(|| base.clone()), + } +} + +/// A built appearance and what storing it needs from the dictionary it was drawn from, read +/// with references followed, so a cached one is stored without following them again. +#[derive(Clone, Debug)] +struct Built { + stream: Stream, + /// The first character the standard fonts can't draw (it came out as `?`). + missing: Option, + /// A note's or attachment's `/Rect`, as written. + rect: Option>, + /// Points (/QuadPoints, /L, /Vertices, /InkList) past /Rect: its appearance covers them, so + /// /Rect does too. + markup_rect: Option<[f64; 4]>, +} + +impl Built { + fn new(stream: Stream, direct: &Dict) -> Self { + let missing = appearance::undrawable_text(direct).map(|(_, c)| c); + let rect = matches!(direct.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| direct.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten(); + let markup_rect = appearance::widened_rect(direct, &stream); + Built { stream, missing, rect, markup_rect } + } +} + /// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut stream: Stream) -> Result<(), AnnotError> { - // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. - let missing = appearance::undrawable_text(d).map(|(_, c)| c); +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, built: Built) -> Result<(), AnnotError> { + // `missing`: characters the standard fonts can't draw came out as `?`; the annotation is + // marked with the first. + let Built { mut stream, missing, rect, markup_rect } = built; // A text box's Thai glyphs are streams of their own. pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict); let ap = doc.add(Object::Stream(stream)); @@ -72,17 +288,13 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut stream: Stream) // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. - let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) - .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) - .flatten() - .filter(|q| q.len() == 4 && finite(q)) - .map(|q| { - let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); - [x, top - NOTE_SIZE, x + NOTE_SIZE, top] - }); + let icon_rect = rect.filter(|q| q.len() == 4 && finite(q)).map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); - if let Some(rect) = icon_rect { + if let Some(rect) = icon_rect.or(markup_rect) { d.set(b"Rect".to_vec(), num_array(&rect)); } d.remove(b"AS"); @@ -103,7 +315,9 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut stream: Stream) /// refreshes) and keep it for content-only ones. #[derive(Clone, Debug, Default)] pub struct AppearanceCache { - entries: std::collections::HashMap, + /// Each appearance with the page region it was drawn for (see [`appearance::build_on_page`]): + /// a changed crop box draws it again. + entries: std::collections::HashMap, Built)>, bytes: usize, hits: u64, builds: u64, @@ -118,20 +332,21 @@ impl AppearanceCache { *self = Self::default(); } - fn get(&mut self, r: ObjRef) -> Option { - let stream = self.entries.get(&r)?.clone(); + fn get(&mut self, r: ObjRef, crop: Option<[f64; 4]>) -> Option { + let built = self.entries.get(&r).filter(|(drawn_for, _)| *drawn_for == crop)?.1.clone(); self.hits += 1; - Some(stream) + Some(built) } - fn insert(&mut self, r: ObjRef, stream: &Stream) { + fn insert(&mut self, r: ObjRef, crop: Option<[f64; 4]>, built: &Built) { + let stream = &built.stream; self.builds += 1; if self.entries.len() >= 1_000_000 { self.clear(); } if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { self.bytes += stream.raw.len(); - self.entries.insert(r, stream.clone()); + self.entries.insert(r, (crop, built.clone())); } // Else over budget: keep serving the entries already cached instead of growing. } @@ -165,6 +380,9 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac // reads `doc`, which never gets the appearance, so every repeat used to add another stream to // the copy, and the display bytes grew with the number of listings. let mut seen = std::collections::HashSet::new(); + // Pages' visible regions, read once and only when an annotation drawn through its points is. + let mut crops: Option> = None; + let mut budget = PASS_BUDGET; for page in page_refs(doc).ok()? { for r in annots(doc, page).iter().filter_map(Object::as_ref) { if !seen.insert(r) { @@ -178,9 +396,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac if has_normal { continue; } - if let Some(stream) = cache.get(r) { + let crop = appearance::is_drawn_from_points(&d) + .then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied()) + .flatten(); + if let Some(built) = cache.get(r, crop) { // Fails only when `r` isn't a dictionary, which the cached build ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok(); continue; } // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its @@ -190,10 +411,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac bare.remove(b"AP"); appearance::display_stamp(&bare) }; - let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; - cache.insert(r, &stream); + let direct = with_direct_values_within(doc, &d, &mut budget); + let Some(stream) = build_appearance(doc, &d, &direct, crop).or_else(other_stamp) else { continue }; + let built = Built::new(stream, &direct); + cache.insert(r, crop, &built); // Fails only when `r` isn't a dictionary, which the build just ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok(); } } copy diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index c4913e7ab8..c86b9f8f78 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -245,7 +245,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let p = page_ref(doc, page).ok()?; let entry = annots(doc, p).get(index).cloned()?; let obj = doc.resolve(&entry); - let d = obj.as_dict()?; + // Values given by reference (`/C 7 0 R`, `/BS 5 0 R`, ISO 32000-2 §7.3.10) read as the display draws them. + let raw = obj.as_dict()?; + let direct = crate::draw::with_direct_values(doc, raw); + let d = direct.as_ref(); let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { drawn_color(d, b"C") }; let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { @@ -263,7 +266,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { width, icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), modified: text_value(doc, d, b"M"), - restylable: appearance::build(doc, d).is_some(), + restylable: crate::draw::can_draw(doc, raw), locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, fillable: fillable(&subtype), fill: fill_of(d), diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 7cf4328c7c..e2199e4d23 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -8,13 +8,77 @@ use crate::{AnnotError, annots, page_ref, page_refs, set_annots}; /// Where a link goes. #[derive(Clone, Debug, PartialEq)] pub enum LinkAction { - /// A page of this document (0-based), shown fitting the window (`/Fit`). - Page(usize), + /// A page of this document (0-based), shown at `zoom`. + Page { + page: usize, + zoom: LinkZoom, + }, Uri(String), /// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is. Other(String), } +/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an +/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the +/// reader keeps its position in that direction, as a new bookmark does. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LinkZoom { + /// `/Fit`: the whole page in the window. + FitPage, + /// `/FitH null`: the page's width across the window. + FitWidth, + /// `/FitV null`: the page's height down the window. + FitHeight, + /// `/FitB`: the page's content (its bounding box) in the window. + FitVisible, + /// `/XYZ null null null`: the reader's zoom is kept. + Inherit, + /// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written + /// within 1–6400%; a link rewritten with the zoom it has keeps its own factor. + Percent(f64), +} + +impl LinkZoom { + /// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The + /// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown + /// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom + /// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its + /// zoom is written (`write_action`) but not its position. + fn of_dest(doc: &Document, dest: &[Object]) -> Self { + let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec)); + match kind.as_deref() { + Some(b"FitB") => LinkZoom::FitVisible, + Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth, + Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight, + Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) { + // Finite even for a factor near f64::MAX. A link given a new page with this zoom + // keeps the factor itself, however far outside 1–6400% (`write_action`). + Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)), + // A zoom of 0 or null keeps the reader's (Table 149). + _ => LinkZoom::Inherit, + }, + _ => LinkZoom::FitPage, + } + } + + /// The destination's elements after the page reference. + fn operands(self) -> Result, AnnotError> { + Ok(match self { + LinkZoom::FitPage => vec![Object::name("Fit")], + LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null], + LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null], + LinkZoom::FitVisible => vec![Object::name("FitB")], + LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null], + LinkZoom::Percent(p) => { + if !(p.is_finite() && p > 0.0) { + return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into())); + } + vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))] + } + }) + } +} + /// Acrobat's Link Properties ▸ Appearance. #[derive(Clone, Copy, Debug, PartialEq)] pub struct LinkStyle { @@ -87,17 +151,18 @@ fn nums(doc: &Document, o: Option<&Object>) -> Vec { } fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { - let dest_page = |dest: &Object| -> Option { + let page_view = |dest: &Object| -> Option { let a = doc.resolve(dest); - let first = a.as_array()?.first()?.clone(); - match first { - Object::Ref(r) => pages.iter().position(|p| *p == r), - Object::Int(i) => usize::try_from(i).ok(), + let a = a.as_array()?; + let page = match a.first()? { + Object::Ref(r) => pages.iter().position(|p| p == r), + Object::Int(i) => usize::try_from(*i).ok(), _ => None, - } + }?; + Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) }) }; if let Some(dest) = d.get(b"Dest") { - return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page); + return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into())); } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { @@ -115,7 +180,7 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) None => LinkAction::Other("web address too long".into()), } } - Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), + Some(b"GoTo") => a.get(b"D").and_then(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), } @@ -214,12 +279,52 @@ fn uri_bytes(u: &str) -> Vec { out } +/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`. +fn current_dest(doc: &Document, d: &Dict) -> Option> { + let dest = match d.get(b"Dest") { + Some(dest) => doc.resolve(dest), + None => { + let action = doc.resolve(d.get(b"A")?); + let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?; + doc.resolve(go_to.get(b"D")?) + } + }; + dest.as_array().cloned() +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { - LinkAction::Page(p) => { - let target = page_ref(doc, *p)?; + LinkAction::Page { page, zoom } => { + let target = page_ref(doc, *page)?; + let mut operands = zoom.operands()?; + let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom); + if let Some(c) = current { + // The page and zoom the link has (`link_edit` naming its own page): the + // destination is left as it is, position on the page and GoTo form included. + let same_page = match c.first() { + Some(Object::Ref(r)) => *r == target, + Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page), + _ => false, + }; + if same_page { + return Ok(()); + } + // A new page with the zoom the link has keeps how that zoom is written: a boxed + // fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400% + // a new zoom is written within. Positions belong to the old page and are dropped. + let (slot, own) = match zoom { + LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)), + LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)), + _ => (None, None), + }; + if let (Some(slot), Some(own)) = (slot, own) { + *slot = doc.resolve(own).as_ref().clone(); + } + } + let mut dest = vec![Object::Ref(target)]; + dest.extend(operands); d.remove(b"A"); - d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); + d.set(b"Dest".to_vec(), Object::Array(dest)); } LinkAction::Uri(u) => { let u = u.trim(); diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 6fbb0f35c3..78200f5c16 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -785,7 +785,7 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); // Check before changing anything: a stale appearance would contradict the new style. - if appearance::build(doc, &d).is_none() { + if !crate::draw::can_draw(doc, &d) { return Err(AnnotError::Unsupported(subtype.clone())); } if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { @@ -805,8 +805,12 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> // So does a text box inside an oval or a circle, which goes around it again. let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle }; let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL"); + // A border style given by reference (`/BS 5 0 R`, ISO 32000-2 §7.3.10): its width and other + // entries, which a new width keeps. + let direct = crate::draw::with_direct_values(doc, &d); + let (old_width, old_bs) = (border_width_of(&direct), direct.get(b"BS").and_then(Object::as_dict).cloned()); let regrow = width.filter(|_| typewriter).and_then(|w| { - let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 }; + let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { old_width } else { 0.0 }; let rect = grow(text_box_of(&d)?, by); (by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0))) }); @@ -830,9 +834,9 @@ fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o }); } if let Some(w) = width { - let old_w = border_width_of(d); + let old_w = old_width; let w = w.max(0.0); - let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); + let mut bs = old_bs.unwrap_or_default(); bs.set(b"W".to_vec(), Object::Real(w)); d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); @@ -946,7 +950,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); } // Check before changing anything: a stale appearance would contradict the new fill. - if appearance::build(doc, &d).is_none() { + if !crate::draw::can_draw(doc, &d) { return Err(AnnotError::Unsupported(subtype)); } if fill.is_some_and(|c| !finite(&c)) { @@ -987,7 +991,7 @@ pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Opt return Err(AnnotError::Invalid("invalid opacity".into())); } // Check before changing anything: a stale appearance would contradict the new opacity. - if appearance::build(doc, &d).is_none() { + if !crate::draw::can_draw(doc, &d) { return Err(AnnotError::Unsupported(subtype)); } refuse_foreign_text(doc, &d)?; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 7561938676..b0220b1023 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1003,12 +1003,12 @@ fn stamps_draw_their_label_and_by_line() { #[test] fn links_are_added_edited_listed_and_removed() { - use crate::links::{self, Highlight, LinkAction, LinkStyle}; + use crate::links::{self, Highlight, LinkAction, LinkStyle, LinkZoom}; let mut doc = fixture(); let before = links::list(&doc).len(); let i = links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap(); let style = LinkStyle { visible: true, color: [1.0, 0.0, 0.0], width: 2.0, dashed: true, underline: false, highlight: Highlight::Outline }; - links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page(0), &style).unwrap(); + links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &style).unwrap(); let doc = reopen(&doc); let all = links::list(&doc); assert_eq!(all.len(), before + 2); @@ -1017,7 +1017,7 @@ fn links_are_added_edited_listed_and_removed() { (web.action.clone(), web.style.visible, web.style.highlight), (LinkAction::Uri("https://example.org".into()), false, Highlight::Invert) ); - let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page(0)).unwrap().clone(); + let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }).unwrap().clone(); assert_eq!(page.style, style); let mut doc = doc; links::set(&mut doc, 1, page.index, Some([0.0, 0.0, 50.0, 20.0]), Some(&LinkAction::Uri("mailto:ada@example.org".into())), None).unwrap(); @@ -1027,7 +1027,201 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::delete(&mut doc, 0, 999).is_err(), "no such annotation"); let n = links::remove_all(&mut doc, None).unwrap(); assert!(n >= 1 && links::list(&doc).is_empty()); - assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); + assert!( + links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &LinkStyle::default()).is_err(), + "too small" + ); +} + +#[test] +fn page_links_keep_their_zoom() { + use crate::links::{self, LinkAction, LinkStyle, LinkZoom}; + let page1 = page_refs(&fixture()).unwrap()[1]; + let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()); + // Each zoom is written as its destination type (Table 149) and reads back after a save. + let null = Object::Null; + for (zoom, written) in [ + (LinkZoom::FitPage, vec![Object::name("Fit")]), + (LinkZoom::FitWidth, vec![Object::name("FitH"), null.clone()]), + (LinkZoom::FitHeight, vec![Object::name("FitV"), null.clone()]), + (LinkZoom::FitVisible, vec![Object::name("FitB")]), + (LinkZoom::Inherit, vec![Object::name("XYZ"), null.clone(), null.clone(), null.clone()]), + (LinkZoom::Percent(100.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(1.0)]), + (LinkZoom::Percent(250.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(2.5)]), + ] { + let mut doc = fixture(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom }, &LinkStyle::default()).unwrap(); + let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap(); + let mut expected = vec![Object::Ref(page1)]; + expected.extend(written); + assert_eq!(dest_of(&doc, link.obj.unwrap()), Some(Object::Array(expected)), "{zoom:?}"); + let doc = reopen(&doc); + let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap(); + assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{zoom:?} after a save"); + } + // What other writers use reads as the nearest zoom: a zero zoom keeps the reader's, the boxed + // fits as the plain ones, a rectangle as Fit Page, and a GoTo action like a /Dest. + let mut doc = fixture(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + let dest = |kind: &str, operands: Vec| { + let mut d = vec![Object::Ref(page1), Object::name(kind)]; + d.extend(operands); + Object::Array(d) + }; + for (key, value, zoom) in [ + ("Dest", dest("XYZ", vec![Object::Int(100), Object::Int(700), Object::Int(0)]), LinkZoom::Inherit), + ("Dest", dest("FitBH", vec![Object::Int(500)]), LinkZoom::FitWidth), + ("Dest", dest("FitBV", vec![Object::Int(20)]), LinkZoom::FitHeight), + ("Dest", dest("FitR", vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)]), LinkZoom::FitPage), + ("A", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1.5)]), LinkZoom::Percent(150.0)), + // A factor outside what PdfCraft writes reads as the destination has it, not capped, so + // it agrees with the viewer's `destination`; one whose percentage overflows stays finite. + ("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(100.0)]), LinkZoom::Percent(10000.0)), + ("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(0.005)]), LinkZoom::Percent(0.5)), + ("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1e307)]), LinkZoom::Percent(f64::MAX)), + ] { + let mut d = doc.get(obj).as_dict().cloned().unwrap(); + d.remove(b"Dest"); + d.remove(b"A"); + if key == "A" { + let mut go_to = pdfcraft_cos::Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), value); + d.set(b"A".to_vec(), Object::Dict(go_to)); + } else { + d.set(b"Dest".to_vec(), value); + } + doc.set(obj, Object::Dict(d)); + let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap(); + assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{key}"); + } + // A zoom that isn't a positive percentage is refused before anything is written. + for p in [0.0, -50.0, f64::NAN, f64::INFINITY] { + let before = links::list(&doc).len(); + let refused = + links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(p) }, &LinkStyle::default()); + assert!(refused.is_err(), "{p}"); + assert_eq!(links::list(&doc).len(), before, "{p}: no link added"); + } +} + +/// A link rewritten with the zoom it has keeps its own `/XYZ` factor, however far outside the +/// 1–6400% a new zoom is written within: giving it a new page (Link Properties' OK, `link_edit` +/// without `zoom`) must not change its zoom. A zoom chosen anew is still written within 1–6400%. +#[test] +fn a_rewritten_link_keeps_its_own_zoom_factor() { + use crate::links::{self, LinkAction, LinkStyle, LinkZoom}; + let pages = page_refs(&fixture()).unwrap(); + let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()); + let xyz = |page: ObjRef, factor: Object| Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, factor]); + for (key, factor) in [ + ("Dest", Object::Real(0.005)), + ("Dest", Object::Real(100.0)), + ("Dest", Object::Int(100)), + ("Dest", Object::Real(1e307)), + ("A", Object::Real(0.005)), + ] { + let mut doc = fixture(); + let i = + links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + let mut d = doc.get(obj).as_dict().cloned().unwrap(); + d.remove(b"Dest"); + if key == "A" { + let mut go_to = pdfcraft_cos::Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), xyz(pages[1], factor.clone())); + d.set(b"A".to_vec(), Object::Dict(go_to)); + } else { + d.set(b"Dest".to_vec(), xyz(pages[1], factor.clone())); + } + doc.set(obj, Object::Dict(d)); + let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else { + panic!("{key} {factor:?}") + }; + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap(); + assert_eq!(dest_of(&doc, obj), Some(xyz(pages[0], factor.clone())), "{key} {factor:?}: the factor is the link's own"); + let doc = reopen(&doc); + assert_eq!( + links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action, + LinkAction::Page { page: 0, zoom }, + "{key} {factor:?}" + ); + } + // A zoom other than the link's is written within 1–6400%. + let mut doc = fixture(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(50.0) }, &LinkStyle::default()) + .unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 1, zoom: LinkZoom::Percent(10000.0) }), None).unwrap(); + assert_eq!(dest_of(&doc, obj), Some(xyz(pages[1], Object::Real(64.0)))); +} + +/// A go-to edited without a change (its own page and zoom, as `link_edit` naming its page does) +/// is left as it is: the `/XYZ` position, a fit's position and the GoTo form stay. Given a new +/// page with its zoom, a link keeps how that zoom is written, a boxed fit (`/FitBH`, `/FitBV`) +/// included, but not its position, which belongs to the old page. +#[test] +fn an_unchanged_go_to_keeps_its_destination_and_a_new_page_keeps_its_fit() { + use crate::links::{self, LinkAction, LinkStyle, LinkZoom}; + let pages = page_refs(&fixture()).unwrap(); + let dest = |page: ObjRef, kind: &str, operands: Vec| { + let mut d = vec![Object::Ref(page), Object::name(kind)]; + d.extend(operands); + Object::Array(d) + }; + let link_with = |key: &str, value: Object| { + let mut doc = fixture(); + let i = + links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + let mut d = doc.get(obj).as_dict().cloned().unwrap(); + d.remove(b"Dest"); + if key == "A" { + let mut go_to = pdfcraft_cos::Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), value); + d.set(b"A".to_vec(), Object::Dict(go_to)); + } else { + d.set(b"Dest".to_vec(), value); + } + doc.set(obj, Object::Dict(d)); + (doc, i, obj) + }; + let (num, null) = (|n: i64| Object::Int(n), Object::Null); + // The same page and zoom: nothing is rewritten. + for (key, value) in [ + ("Dest", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)])), + ("Dest", dest(pages[1], "FitBH", vec![num(250)])), + ("Dest", dest(pages[1], "FitV", vec![num(20)])), + ("A", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(0.005)])), + ] { + let (mut doc, i, obj) = link_with(key, value); + let before = doc.get(obj).as_dict().cloned(); + let LinkAction::Page { page, zoom } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else { + panic!("{key}") + }; + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page, zoom }), None).unwrap(); + assert_eq!(doc.get(obj).as_dict().cloned(), before, "{key} {zoom:?}: left as it is"); + } + // A new page: the fit's kind and the factor stay, positions go. + for (value, written) in [ + (dest(pages[1], "FitBH", vec![num(250)]), dest(pages[0], "FitBH", vec![null.clone()])), + (dest(pages[1], "FitBV", vec![num(20)]), dest(pages[0], "FitBV", vec![null.clone()])), + (dest(pages[1], "FitH", vec![num(250)]), dest(pages[0], "FitH", vec![null.clone()])), + ( + dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)]), + dest(pages[0], "XYZ", vec![null.clone(), null.clone(), Object::Real(2.0)]), + ), + ] { + let (mut doc, i, obj) = link_with("Dest", value.clone()); + let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else { + panic!("{value:?}") + }; + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap(); + assert_eq!(doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()), Some(written), "{value:?}"); + } } #[test] @@ -1530,6 +1724,190 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() { assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged"); } +/// Any value may be given by reference (ISO 32000-2 §7.3.10). A Square whose border style +/// dictionary (Table 168), colour and dash array are indirect objects is drawn with them, not +/// with the 1 pt solid default; a reference cycle inside `/BS` is not followed for ever. +#[test] +fn indirect_border_style_and_colour_are_drawn() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Square /P 3 0 R /Rect [20 20 180 180] /BS 5 0 R /C 6 0 R >>", + b"<< /Type /Border /W 7 0 R /S /D /D 8 0 R /Self 5 0 R >>", + b"[0 0 1]", + b"6", + b"[4 2]", + ]); + let display = with_missing_appearances(&doc).expect("the square gets a display appearance"); + let shown = list(&display, 0).remove(0); + let content = ap_content(&display, &shown); + assert!(content.contains("0 0 1 RG\n6 w\n[4 2] 0 d\n"), "the indirect /BS, /W, /D and /C are used: {content}"); + assert!(content.contains("23 23 154 154 re"), "the 6 pt border is inset by half its width: {content}"); + assert_eq!(shown.get(b"BS"), Some(&Object::Ref(ObjRef::new(5, 0))), "the display copy keeps the reference"); + assert_eq!(shown.get(b"P"), Some(&Object::Ref(ObjRef::new(3, 0)))); + let props = props(&doc, 0, 0).expect("props"); + assert_eq!(props.width, Some(6.0), "the properties show the width drawn"); +} + +/// A followed reference the builder can't draw (a cloudy border effect on a square, which it +/// leaves alone) keeps the drawing made without it instead of none. +#[test] +fn an_undrawable_indirect_value_keeps_the_plain_drawing() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [20 20 180 180] /BE 5 0 R /C [1 0 0] >>", + b"<< /S /C /I 1 >>", + ]); + let display = with_missing_appearances(&doc).expect("the square is still drawn"); + let shown = list(&display, 0).remove(0); + assert!(ap_content(&display, &shown).contains("1 0 0 RG\n1 w\n")); +} + +/// Following references copies a bounded number of values: an indirect array past the budget +/// stays a reference (and the comment is not drawn from it), a chain that never ends is not +/// followed, and links to other objects are never copied. +#[test] +fn direct_values_are_bounded() { + let mut doc = fixture(); + let huge = doc.add(Object::Array(vec![Object::Int(0); (1 << 16) + 1])); + let chain = doc.add(Object::Null); + doc.set(chain, Object::Ref(chain)); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Rect".to_vec(), Object::Ref(doc.add(num_array(&[0.0, 0.0, 10.0, 10.0])))); + d.set(b"Vertices".to_vec(), Object::Ref(huge)); + d.set(b"IC".to_vec(), Object::Ref(chain)); + d.set(b"Popup".to_vec(), Object::Ref(huge)); + let direct = crate::draw::with_direct_values(&doc, &d); + assert_eq!(direct.get(b"Rect").and_then(Object::as_array).map(Vec::len), Some(4), "a small array is followed"); + assert_eq!(direct.get(b"Vertices"), Some(&Object::Ref(huge)), "over the budget: left as a reference"); + assert_eq!(direct.get(b"IC"), Some(&Object::Ref(chain)), "a chain of references is left alone"); + assert_eq!(direct.get(b"Popup"), Some(&Object::Ref(huge)), "a link is not copied"); + let mut plain = Dict::new(); + plain.set(b"W".to_vec(), Object::Int(2)); + assert!(matches!(crate::draw::with_direct_values(&doc, &plain), std::borrow::Cow::Borrowed(_)), "nothing to follow: no copy"); +} + +/// A new width for a comment whose border style is an indirect object (§7.3.10) keeps that +/// dictionary's other entries (a dashed style stays dashed) and leaves the shared object alone. +#[test] +fn new_width_keeps_an_indirect_border_style() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [20 20 180 180] /BS 5 0 R /C [1 0 0] >>", + b"<< /W 6 /S /D /D [4 2] >>", + ]); + set_style(&mut doc, 0, 0, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap(); + let doc = reopen(&doc); + let d = list(&doc, 0).remove(0); + let bs = d.get(b"BS").and_then(Object::as_dict).expect("a direct /BS"); + assert_eq!(bs.get(b"W").and_then(Object::as_f64), Some(3.0)); + assert_eq!(bs.name(b"S"), Some(b"D".as_slice()), "the dashed style is kept"); + assert!(ap_content(&doc, &d).contains("3 w\n[4 2] 0 d\n")); + let shared = doc.get(ObjRef::new(5, 0)); + assert_eq!(shared.as_dict().and_then(|b| b.get(b"W")).and_then(Object::as_f64), Some(6.0), "the shared object is unchanged"); +} + +/// A colour given by reference (`/C 6 0 R`, §7.3.10) is read by the properties and the edits as +/// the display draws it: the properties show it, the appearance can be restyled and filled. +#[test] +fn an_indirect_colour_reads_and_restyles_as_drawn() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [20 20 180 180] /C 5 0 R /IC 6 0 R >>", + b"[0 0 1]", + b"[1 1 0]", + ]); + let p = props(&doc, 0, 0).expect("props"); + assert_eq!((p.color, p.fill, p.restylable), (Some([0.0, 0.0, 1.0]), Some([1.0, 1.0, 0.0]), true)); + set_style(&mut doc, 0, 0, StyleChange::new(None, None, Some(3.0), None), &meta("")).expect("restylable"); + set_fill(&mut doc, 0, 0, Some([0.0, 1.0, 0.0]), &meta("")).expect("fillable"); + let d = list(&doc, 0).remove(0); + let content = ap_content(&doc, &d); + assert!(content.contains("0 0 1 RG") && content.contains("0 1 0 rg") && content.contains("3 w"), "{content}"); +} + +/// References are followed into everything they point at, and all of it is counted: a referenced +/// array of two arrays of 40,000 numbers is over the per-annotation budget. One display pass +/// copies at most [`crate::draw::PASS_BUDGET`] values in all, so an array many annotations point +/// at is not copied again for each of them. +#[test] +fn following_references_is_bounded_per_value_and_per_pass() { + let mut doc = fixture(); + let inner = Object::Array(vec![Object::Int(0); 40_000]); + let nested = doc.add(Object::Array(vec![inner.clone(), inner])); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Junk".to_vec(), Object::Ref(nested)); + let direct = crate::draw::with_direct_values(&doc, &d); + assert!(direct.get(b"Junk") == Some(&Object::Ref(nested)), "80,003 values: left as a reference"); + + let shared = doc.add(Object::Array(vec![Object::Int(0); 60_000])); + d.set(b"Junk".to_vec(), Object::Ref(shared)); + let mut pass = crate::draw::PASS_BUDGET; + let copies = (0..100).filter(|_| matches!(crate::draw::with_direct_values_within(&doc, &d, &mut pass), std::borrow::Cow::Owned(_))).count(); + assert_eq!(copies, crate::draw::PASS_BUDGET / 60_001, "copies stop when the pass budget is spent"); +} + +/// Finding an object too large to copy costs the walk that found it out: an array of two arrays +/// of 40,000 numbers that many annotations point at is walked about 40,000 values deep before it +/// is rejected, and that is charged to the annotation and to the pass, so after a bounded number +/// of such walks the pass has no room left to walk it again. A reference to it repeated in one annotation +/// is not walked again in full, and a small referenced colour after it is still read. +#[test] +fn a_rejected_large_object_is_charged_to_the_budgets() { + let mut doc = fixture(); + let inner = Object::Array(vec![Object::Int(0); 40_000]); + let nested = doc.add(Object::Array(vec![inner.clone(), inner])); + let red = doc.add(Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)])); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Junk".to_vec(), Object::Array(vec![Object::Ref(nested); 1_000])); + d.set(b"C".to_vec(), Object::Ref(red)); + let mut pass = crate::draw::PASS_BUDGET; + let direct = crate::draw::with_direct_values_within(&doc, &d, &mut pass); + assert_eq!(direct.get(b"C"), Some(&Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)]))); + let charged = crate::draw::PASS_BUDGET - pass; + assert!((40_000..=crate::draw::INLINE_BUDGET).contains(&charged), "the rejected walk is charged once: {charged}"); + d.set(b"Junk".to_vec(), Object::Ref(nested)); + for _ in 0..crate::draw::PASS_BUDGET / 40_000 { + crate::draw::with_direct_values_within(&doc, &d, &mut pass); + } + assert!(pass < 40_000, "after {} annotations the pass has no room for another full walk: {pass}", crate::draw::PASS_BUDGET / 40_000 + 1); +} + +/// `/RD` is optional (ISO 32000-2 Table 177): a callout without it has its text box at `/Rect` +/// and is drawn, box, text and leader line. A callout line and `/RD` given by reference are read too. +#[test] +fn callout_without_rd_is_drawn() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /FreeText /IT /FreeTextCallout /Rect [100 100 300 160] /CL [20 20 60 60 100 130] /LE /OpenArrow /C [1 1 0] /DA (0 g /Helv 12 Tf) /Contents (callout) >>", + b"<< /Type /Annot /Subtype /FreeText /IT /FreeTextCallout /Rect [10 200 300 300] /CL 7 0 R /RD 8 0 R /C [1 1 0] /DA (0 g /Helv 12 Tf) /Contents (indirect) >>", + b"<< /Type /Annot /Subtype /FreeText /IT /FreeTextCallout /Rect [10 300 300 390] /CL [5 305 10 350] /RD [1 2] /C [1 1 0] /DA (0 g /Helv 12 Tf) /Contents (bad) >>", + b"[15 205 40 250 100 250]", + b"[90 0 0 0]", + ]); + let display = with_missing_appearances(&doc).expect("the callouts get a display appearance"); + let shown = list(&display, 0); + let plain = ap_content(&display, &shown[0]); + assert!(plain.contains("1 1 0 rg\n100 100 200 60 re f"), "the yellow box fills /Rect: {plain}"); + assert!(plain.contains("20 20 m\n60 60 l\n100 130 l\nS"), "the leader line: {plain}"); + assert!(plain.contains("(callout) Tj"), "the text: {plain}"); + let indirect = ap_content(&display, &shown[1]); + assert!(indirect.contains("100 200 200 100 re f") && indirect.contains("15 205 m"), "indirect /CL and /RD: {indirect}"); + assert!(shown[2].get(b"AP").is_none(), "a malformed /RD is still not guessed at"); +} + /// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner /// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it /// to nothing in one with no width. @@ -2895,8 +3273,10 @@ fn a_referenced_colour_is_listed_like_a_direct_one() { } /// A reference among the four entries is not a non-number read as 0: `/C [1 0 0 6 0 R]` with -/// object 6 the number 1 is CMYK black (§7.3.10), so drawing it cyan would be wrong. The builder -/// doesn't follow it and draws no appearance, as for any colour it can't read. +/// object 6 the number 1 is CMYK black (§7.3.10), so drawing it cyan would be wrong. The display +/// and the properties follow the reference (`draw::with_direct_values`) and draw and +/// read it black; the comment list, which follows only a `/C` given whole by reference, reads no +/// colour rather than cyan. #[test] fn a_four_entry_colour_with_a_reference_is_not_read_as_zero() { let doc = document_of(&[ @@ -2907,11 +3287,374 @@ fn a_four_entry_colour_with_a_reference_is_not_read_as_zero() { b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>", b"1", ]); - let display = with_missing_appearances(&doc).expect("the RGB square gets a fallback appearance"); + let display = with_missing_appearances(&doc).expect("both squares get a fallback appearance"); let squares = list(&display, 0); - assert!(!squares[0].contains(b"AP"), "no cyan stand-in for CMYK black"); - assert_eq!(props(&doc, 0, 0).and_then(|p| p.color), None); - assert_eq!(summaries(&doc)[0].color, None, "nor in the comment list"); + let black = ap_content(&display, &squares[0]); + assert!(black.contains("0 0 0 RG") && !black.contains("0 1 1 RG"), "CMYK black, no cyan stand-in: {black}"); + assert_eq!(props(&doc, 0, 0).and_then(|p| p.color), Some([0.0, 0.0, 0.0])); + assert_ne!(summaries(&doc)[0].color, Some([0.0, 1.0, 1.0]), "nor cyan in the comment list"); +} + +/// A text markup annotation is drawn over its `/QuadPoints` (ISO 32000-2 Table 182), and an +/// appearance is mapped onto `/Rect` (§12.5.5). A highlight whose `/Rect` lies beside its text +/// (here written with its corners swapped, as a real file has it) got an appearance cut to that +/// `/Rect`, so nothing showed. The display copy's appearance and `/Rect` now take in the +/// quadrilaterals; a markup whose `/Rect` already holds them keeps it. +#[test] +fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [335.1 370.83 324.6 471.78] /C [1 1 0] /QuadPoints [370 287 471 287 370 277 471 277] >>", + b"<< /Type /Annot /Subtype /Underline /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [100 120 200 120 100 100 200 100] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [50 50 150 70] /C [1 1 0] /QuadPoints [60 68 140 68 60 52 140 52] >>", + b"<< /Type /Annot /Subtype /StrikeOut /Rect [10 10 20 20] /C [1 0 0] /QuadPoints [100 220 200 220 100 200 200 200] >>", + b"<< /Type /Annot /Subtype /Squiggly /Rect [10 10 20 20] /C [0 1 0] /QuadPoints [100 320 200 320 100 300 200 300] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [-1e30 20 1e30 20 -1e30 10 1e30 10] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [-1e308 10 1e308 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] >>", + ]); + let display = with_missing_appearances(&doc).expect("the markups need an appearance"); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + let bbox = |d: &Dict| { + let n = d.get(b"AP").map(|a| display.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).unwrap(); + let obj = display.get(n); + let Object::Stream(s) = &*obj else { panic!("AP is not a stream") }; + nums_of(&s.dict, b"BBox") + }; + assert_eq!(rect(&shown[0]), [324.6, 277.0, 471.0, 471.78], "the highlight's /Rect takes in its text"); + assert_eq!(bbox(&shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled"); + // Half the underline's width (0.07 × 20 / 2) past the quad. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.7, 120.7], "an underline's too"); + assert_eq!(bbox(&shown[1]), rect(&shown[1])); + assert_eq!(rect(&shown[2]), [50.0, 50.0, 150.0, 70.0], "a /Rect that holds the quads stays as it is"); + assert_eq!(rect(&shown[3]), [10.0, 10.0, 200.7, 220.7], "a strikeout's too"); + assert_eq!(bbox(&shown[3]), rect(&shown[3])); + assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.5, 320.5], "a squiggly's too"); + assert_eq!(bbox(&shown[4]), rect(&shown[4])); + assert_eq!(rect(&shown[5]), [0.0, 10.0, 612.0, 20.0], "quads far past the page reach only as far as it shows"); + // A /Rect too wide for its width to be a number keeps its size: the on-page quad above it + // doesn't make it taller. + assert_eq!(rect(&shown[6]), [-1e308, 10.0, 1e308, 20.0], "an overflowing /Rect is not widened"); + assert_eq!(rect(&list(&doc, 0)[0]), [335.1, 370.83, 324.6, 471.78], "the source document is unchanged"); +} + +/// The box widens over the quads the markup is drawn over, read as every `/QuadPoints` reader +/// reads them (`pdfcraft_geom::quad_points::usable_quads`): an indirect array or entry is +/// followed, and a malformed quad or trailing partial one is skipped while the others still widen +/// the box, so what is drawn is not cut to `/Rect`. +#[test] +fn text_markup_reach_reads_quads_like_the_drawing() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints 7 0 R >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 /oops 120 100 100 200 100 100 220 200 220 100 200 200 200 1 2 3] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 8 0 R 100] >>", + b"[100 120 200 120 100 100 200 100]", + b"200", + ]); + let display = with_missing_appearances(&doc).expect("the markups need an appearance"); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.0, 120.0], "an indirect array widens the box"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.0, 220.0], "the usable quad still widens it"); + assert_eq!(ap_bbox(&display, &shown[1]), rect(&shown[1])); + assert_eq!(rect(&shown[2]), [10.0, 10.0, 200.0, 120.0], "an indirect entry is followed"); +} + +/// `n` copies of the quadrilateral `[100 120 200 120 100 100 200 100]`, as `/QuadPoints`. +fn repeated_quads(n: usize) -> String { + format!("[{}]", vec!["100 120 200 120 100 100 200 100"; n].join(" ")) +} + +/// The `/BBox` of `d`'s normal appearance in `doc`. +fn ap_bbox(doc: &Document, d: &Dict) -> Vec { + let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).expect("has /AP /N"); + let obj = doc.get(n); + let Object::Stream(s) = &*obj else { panic!("AP is not a stream") }; + nums_of(&s.dict, b"BBox") +} + +/// The widened box stops at the page's visible region (its crop box, ISO 32000-2 §14.11.2), and +/// quadrilaterals wholly outside it widen nothing. A markup with more than +/// `appearance::MAX_MARKUP_QUADS` quadrilaterals keeps its `/Rect`. An annotation with an +/// appearance of its own, and one that isn't text markup, keep theirs whatever `/QuadPoints` say. +#[test] +fn text_markup_reach_stops_at_the_page_and_the_quad_cap() { + let cap = appearance::MAX_MARKUP_QUADS; + let at_cap = format!("<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints {} >>", repeated_quads(cap)); + let past_cap = format!("<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints {} >>", repeated_quads(cap + 1)); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 380 500 380 100 360 500 360] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [400 500 450 500 400 480 450 480] >>", + at_cap.as_bytes(), + past_cap.as_bytes(), + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] /AP << /N 10 0 R >> >>", + b"<< /Type /Annot /Subtype /Square /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [100 120 200 120 100 100 200 100] >>", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream", + ]); + let display = with_missing_appearances(&doc).expect("the markups need an appearance"); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 300.0, 380.0], "the box stops at the crop box"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert_eq!(rect(&shown[1]), [10.0, 10.0, 20.0, 20.0], "quads off the page widen nothing"); + assert_eq!(rect(&shown[2]), [10.0, 10.0, 200.0, 120.0], "as many quads as the cap widen the box"); + assert_eq!(rect(&shown[3]), [10.0, 10.0, 20.0, 20.0], "more quads than the cap keep the /Rect"); + assert_eq!(ap_bbox(&display, &shown[3]), rect(&shown[3])); + assert_eq!(rect(&shown[4]), [10.0, 10.0, 20.0, 20.0], "a markup with an appearance of its own keeps its /Rect"); + assert_eq!( + shown[4].get(b"AP").map(|a| display.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))), + Some(ObjRef::new(10, 0)), + "and its appearance" + ); + assert_eq!(rect(&shown[5]), [10.0, 10.0, 20.0, 20.0], "a square is not text markup"); + assert_eq!(ap_bbox(&display, &shown[5]), rect(&shown[5])); +} + +/// A restyle (the edit path, `set_appearance`) redraws a text markup over its quadrilaterals and +/// saves the `/Rect` its appearance covers, as far as the page shows, since the appearance is +/// mapped onto `/Rect` (ISO 32000-2 §12.5.5). A markup whose `/Rect` already holds its quads keeps +/// that `/Rect` as written, swapped corners and all. +#[test] +fn restyled_text_markup_saves_the_rect_its_appearance_covers() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [335.1 370.83 324.6 471.78] /C [1 1 0] /QuadPoints [370 287 471 287 370 277 471 277] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [150 70 50 50] /C [1 1 0] /QuadPoints [60 68 140 68 60 52 140 52] >>", + b"<< /Type /Annot /Subtype /Underline /Rect [10 10 20 20] /C [0 0 1] /QuadPoints [500 120 700 120 500 100 700 100] >>", + ]); + for index in 0..3 { + set_style(&mut doc, 0, index, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + } + let saved = reopen(&doc); + let shown = list(&saved, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [324.6, 277.0, 471.0, 471.78], "the saved /Rect takes in the highlighted text"); + assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0])); + assert!(ap_content(&saved, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled"); + assert_eq!(rect(&shown[1]), [150.0, 70.0, 50.0, 50.0], "a /Rect that holds the quads is kept as written"); + assert_eq!(rect(&shown[2]), [10.0, 10.0, 612.0, 120.7], "the saved /Rect stops at the page"); + assert_eq!(ap_bbox(&saved, &shown[2]), rect(&shown[2])); +} + +/// A one-page document whose page lists `annots` (objects 4 on) and the display copy +/// `with_missing_appearances` draws for it. +fn displayed(annots: &[&[u8]]) -> (Document, Document) { + let kids: Vec = (0..annots.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")); + let mut objs: Vec<&[u8]> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", page.as_bytes()]; + objs.extend_from_slice(annots); + let doc = document_of(&objs); + let display = with_missing_appearances(&doc).expect("the annotations need an appearance"); + (doc, display) +} + +/// Ink strokes (`/InkList`, ISO 32000-2 Table 185) outside an ink annotation's `/Rect` are drawn: +/// the display copy's `/Rect` and `/BBox` take in the strokes and half their width. Shaped like +/// the pencil marks of `issue13447.pdf`, whose `/Rect` has x and y swapped. An ink annotation +/// whose `/Rect` holds its points keeps it, even where its wide stroke reaches past it. +#[test] +fn ink_whose_strokes_reach_past_its_rect_is_drawn_through_them() { + let (doc, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Ink /Rect [109.25 260.01 21.25 313.51] /BS << /W 3 >> /C [1 0 0] /InkList [[260 502.75 300 537 330 520] [280 510]] >>", + b"<< /Type /Annot /Subtype /Ink /Rect [0 0 100 100] /BS << /W 30 >> /C [1 0 0] /InkList [[10 10 90 90]] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + // The smooth curve rises past its highest point (537) to 537.33; half the width (1.5) more. + assert_rect_near(&rect(&shown[0]), [21.25, 260.01, 331.5, 538.825], "the ink's /Rect takes in its strokes"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("260 502.75 m"), "the stroke is drawn"); + assert_eq!(rect(&shown[1]), [0.0, 0.0, 100.0, 100.0], "a /Rect that holds the points stays as it is"); + assert_eq!(rect(&list(&doc, 0)[0]), [109.25, 260.01, 21.25, 313.51], "the source document is unchanged"); +} + +/// A polyline's `/Vertices` (Table 181) outside its `/Rect` are drawn, with its line endings +/// (`/LE`): the box takes in the arrow at the last vertex. +#[test] +fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /PolyLine /Rect [667.5 250.69 567.75 344.44] /BS << /W 2 >> /C [0 0 1] /Vertices [250 44 350 94 285 138] >>", + b"<< /Type /Annot /Subtype /PolyLine /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /LE [/None /OpenArrow] /Vertices [100 100 200 100 200 200] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [249.0, 43.0, 667.5, 344.44], "the polyline's /Rect takes in its vertices"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("250 44 m"), "the polyline is drawn"); + // Half the width (1) and the arrow (6 + 3 × 2) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 213.0, 213.0], "and its arrow"); +} + +/// A polygon's `/Vertices` outside its `/Rect` are drawn, its cloudy border (§12.5.4) included. +#[test] +fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>", + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn"); + // Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border"); +} + +/// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader +/// lines. Shaped like the arrows of `issue13447.pdf`. +#[test] +fn line_whose_points_reach_past_its_rect_is_drawn_through_them() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Line /Rect [598.31 146.63 537 316.13] /BS << /W 6 >> /C [0 0 1] /LE [/None /ClosedArrow] /L [176 45 178 154] >>", + b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /L [100 100 200 100] /LL 20 /LLE 5 >>", + b"<< /Type /Annot /Subtype /Line /Rect [90 90 210 110] /BS << /W 2 >> /C [0 0 1] /LE [/OpenArrow /OpenArrow] /L [100 100 200 100] >>", + ]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + // Half the width (3) and the arrow (6 + 3 × 6) past `/L`. + assert_eq!(rect(&shown[0]), [149.0, 18.0, 598.31, 316.13], "the line's /Rect takes in /L and its arrow"); + assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); + assert!(ap_content(&display, &shown[0]).contains("176 45 m"), "the line is drawn"); + // The leaders rise 20 + 5 above `/L`; half the width (1) past them. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 201.0, 126.0], "and its leader lines"); + assert_eq!(rect(&shown[2]), [90.0, 90.0, 210.0, 110.0], "a /Rect that holds /L stays as it is"); +} + +/// Points past the caps (`appearance::MAX_REACH_POINTS`) leave `/Rect` as it is, as do points +/// that aren't numbers; a restyle (the edit path) saves the widened `/Rect` of an ink annotation. +#[test] +fn path_reach_stops_at_the_point_cap_and_is_saved_on_restyle() { + let cap = appearance::MAX_REACH_POINTS; + let stroke = |n: usize| format!("[{}]", vec!["100 120"; n].join(" ")); + let at_cap = format!("<< /Type /Annot /Subtype /Ink /Rect [10 10 20 20] /BS << /W 0 >> /C [1 0 0] /InkList [{} [200 200]] >>", stroke(cap - 1)); + let past_cap = format!("<< /Type /Annot /Subtype /Ink /Rect [10 10 20 20] /BS << /W 0 >> /C [1 0 0] /InkList [{} [200 200]] >>", stroke(cap)); + let (_, display) = displayed(&[at_cap.as_bytes(), past_cap.as_bytes()]); + let shown = list(&display, 0); + let rect = |d: &Dict| nums_of(d, b"Rect"); + assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.0, 200.0], "as many points as the cap widen the box"); + assert_eq!(rect(&shown[1]), [10.0, 10.0, 20.0, 20.0], "more points than the cap keep the /Rect"); + + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Ink /Rect [109.25 260.01 21.25 313.51] /BS << /W 3 >> /C [1 0 0] /InkList [[260 502.75 300 537 330 520]] >>", + ]); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + let saved = reopen(&doc); + let shown = list(&saved, 0); + assert_rect_near(&rect(&shown[0]), [21.25, 260.01, 331.5, 538.825], "the saved /Rect takes in the strokes"); + assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0])); +} + +/// A squiggle's peaks stand 1.5 above a quad's bottom edge however low the quad is, so over a quad +/// 1 high they reach past it: the widened box takes them in, with half the line's width. +#[test] +fn squiggly_peaks_over_a_low_quad_are_inside_the_widened_box() { + let (_, display) = + displayed(&[b"<< /Type /Annot /Subtype /Squiggly /Rect [10 10 20 20] /C [0 1 0] /QuadPoints [100 301 200 301 100 300 200 300] >>"]); + let shown = list(&display, 0); + // Peak 300 + 1.5, half the 0.5 line width: 301.75; the sides reach 0.75 past the quad. + assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 200.75, 301.75]); + assert_eq!(ap_bbox(&display, &shown[0]), nums_of(&shown[0], b"Rect")); +} + +/// A line's caption (`/Cap`, ISO 32000-2 Table 178) is drawn beside `/L`: the widened box takes +/// in the caption's text as well as the line. +#[test] +fn a_line_caption_is_inside_the_widened_box() { + let (_, display) = displayed(&[ + b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /L [100 100 200 100] /Cap true /CP /Top /Contents (Caption) >>", + ]); + let shown = list(&display, 0); + let rect = nums_of(&shown[0], b"Rect"); + // The caption sits on top of the line: its 10-point text rises well past the stroke's 101. + assert!(rect[3] > 108.0, "{rect:?}"); + assert_eq!(ap_bbox(&display, &shown[0]), rect); +} + +/// A line just above the crop box whose wide stroke reaches down onto the page is drawn as far as +/// the page shows, not cut to its `/Rect`. +#[test] +fn a_stroke_that_reaches_onto_the_page_from_points_off_it_widens_the_box() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 6 >> /C [0 0 1] /L [100 401 200 401] >>", + ]); + let display = with_missing_appearances(&doc).expect("the line needs an appearance"); + let shown = list(&display, 0); + // The stroke spans 398 to 404 across and 97 to 203 along; the page shows up to 400. + assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 203.0, 400.0]); +} + +/// A cached display appearance was drawn for its page's crop box: once the crop box grows, the +/// next refresh draws the annotation again for the larger region. +#[test] +fn a_cached_appearance_is_redrawn_for_a_changed_crop_box() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 380 500 380 100 360 500 360] >>", + ]); + let mut cache = AppearanceCache::default(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("drawn"); + assert_eq!(nums_of(&list(&display, 0)[0], b"Rect"), [10.0, 10.0, 300.0, 380.0]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"CropBox".to_vec(), Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()))).unwrap(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("drawn"); + let shown = list(&display, 0); + assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 500.0, 380.0], "drawn for the larger crop box"); + assert_eq!(ap_bbox(&display, &shown[0]), nums_of(&shown[0], b"Rect")); +} + +/// Redrawing a comment drawn through its points finds its page through `/P` when that page +/// lists it, reading that one page's `/Annots` rather than every page's: here the last of 300. +#[test] +fn restyling_a_comment_drawn_through_its_points_reads_only_its_page() { + let pages = 300; + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] >>", kids.join(" "))); + for i in 0..pages { + let (page, annot) = (3 + 2 * i, 4 + 2 * i); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{annot} 0 R] >>")); + objs.push(format!( + "<< /Type /Annot /Subtype /Highlight /P {page} 0 R /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] >>" + )); + } + let bytes: Vec<&[u8]> = objs.iter().map(|o| o.as_bytes()).collect(); + let mut doc = document_of(&bytes); + let last = pages - 1; + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + set_style(&mut doc, last, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert!(copied < 20, "{copied} annotation entries copied for one restyle"); + assert_eq!(nums_of(&list(&doc, last)[0], b"Rect"), [10.0, 10.0, 200.0, 120.0], "drawn for its page"); +} + +/// `got` is `want` to within 0.001 in every coordinate. +fn assert_rect_near(got: &[f64], want: [f64; 4], what: &str) { + assert!(got.len() == 4 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}"); +} + +fn nums_of(d: &Dict, key: &[u8]) -> Vec { + d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default() } /// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits. diff --git a/crates/automation/README.md b/crates/automation/README.md index 9da911c932..e25ff9b8b2 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -61,7 +61,8 @@ do not keep an old PDF byte buffer alive. This bounds retained text-cache data, not total process memory: active extraction batches, renderers, document buffers and the requested output have separate lifetimes. Search still reads -growing batches (8 to 128 pages), with one lookahead match to determine continuation. +growing batches (8 to 128 pages), with one lookahead match to determine continuation, unless +`stop_at_limit` is set (below), which starts at one page and looks no further than its limit. `text_find` keeps matches from readable pages when another reached page fails extraction. It returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and @@ -75,6 +76,16 @@ that reaches the end with warnings therefore has `complete: false` **without** ` should follow `next` when present and separately inspect warnings. A capped search can return both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged. +That lookahead means a search with exactly `limit` matches reads on to the end of the document to +learn that no further match exists. `stop_at_limit: true` skips it: the search ends at the +`limit`th match. It reads one page first, then batches of 2, 4, … up to 128 pages, so nothing past +the batch holding that match is read or can add warnings (a match on the first page reads only that +page). The answer carries +`stopped_at_limit`. When that is `true`, `complete` is `false` and `next` is only the position just +past the last match, not a promise of another one; following it may return no matches and +`complete: true`. When it is `false`, the search ran out of pages first and `complete` means what +it always does. + `text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed semantics; they never silently act on partial text. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1f0c9cb8a5..8fb1e0fd52 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; -use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic}; mod documents; mod form_tools; diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index 5ae3d4fe46..ceb520e9d4 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { @@ -148,18 +148,7 @@ impl Automation { .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); out.push(match result { Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; + let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?; json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) } Err(e) => json!({ "path": p, "error": e }), diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index f650e565b7..14a98f23e2 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -281,7 +281,7 @@ impl Automation { } /// Publish atomically without replacing any existing name, even if another writer wins a race. -fn publish(target: &Path, bytes: &[u8]) -> Result { +pub(crate) fn publish(target: &Path, bytes: &[u8]) -> Result { let dir = target.parent().ok_or_else(|| failed("output has no parent"))?; std::fs::create_dir_all(dir).map_err(failed)?; let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?; diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs index 6493df9968..71ae6b3b91 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -415,7 +415,11 @@ mod tests { let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap(); assert!(acl.success()); - assert!(matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied)); + // Where the deny entry does not bind the test's own account (it did not on CI's Windows image), + // there is no denied folder to probe. + if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) { + return; + } let direct_probe = dir.path().join("direct-probe"); let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap(); drop(file); diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 95f94f3b88..8741b2829f 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, nest_rows, security, summary}; use super::paths::{child, read_input, write_atomic}; -use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, TextBlock, Value, comments, json}; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; @@ -135,18 +135,18 @@ impl Automation { pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { let doc = self.doc(a)?; let id = doc.id; - let target = match a.opt_str("path")? { + let explicit = a.opt_str("path")?; + let target = match explicit { Some(p) => self.resolve(p, true)?, None => { // Saving in place writes where the file was opened from, which the read root - // allows and the write root may not. + // allows and the write root may not. It also goes through --root again: a + // folder on the remembered path may have become a link out since (#1791). let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; - let own_path = PathBuf::from(&own); - self.check_roots(&own, &own_path, file_access::Access::Write)?; - own_path + self.resolve(&own, true)? } }; - let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + let same_file = explicit.is_none() || doc.path.as_deref().is_some_and(|p| Path::new(p) == target); // Saving to a new file is a full rewrite unless asked otherwise, like Save As. let full = a.opt_bool("full")?.unwrap_or(!same_file); let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c950e72f05..5231132421 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value { }) } +/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks, +/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs. pub(crate) fn info(d: &Document) -> Value { let i = &d.info; let page1 = |p: usize| p + 1; @@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value { "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, })).collect::>(), - "outline": outline(&i.outline), + "outline": outline(i, &i.outline), "outline_more": i.outline_more, // Rectangles use the tools' convention (top-left of the displayed page, like // comment_list and link_list), not raw PDF user space, so they can be fed back to @@ -329,32 +331,7 @@ pub(crate) fn info(d: &Document) -> Value { })).collect::>(), "links": i.links.iter().map(|l| json!({ "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), - "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), - pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ - "action": if *launch { "Launch" } else { "GoToR" }, - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, + "target": link_target(i, &l.target), })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with @@ -389,6 +366,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [ i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) } -fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { - Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`). +fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array( + items + .iter() + .map(|o| { + json!({ + "title": o.title, "page": o.page.map(|p| p + 1), + "action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children), + }) + }) + .collect(), + ) +} + +/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer +/// changes, another PDF, or the name of an action that isn't run. +fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value { + // A remote page number is the file's, however large. + let page1 = |p: usize| p.saturating_add(1); + match target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + } } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 28cbbfdca4..594a129d8b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -70,7 +70,7 @@ use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content}; use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; #[cfg(test)] pub(crate) use paths::MAX_INPUT; -use paths::{child, lexical, unused, write_atomic}; +use paths::{child, lexical, write_atomic}; pub(crate) use paths::{read_input, read_input_bytes}; #[cfg(test)] use paths::{write_atomic_synced, write_atomic_with}; diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index 41fda9b7db..a3bf9cd28b 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -2,7 +2,7 @@ //! all links. Rectangles are points from the top-left of the displayed page. use pdfcraft_engine::{ - Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, + Edit, LinkAction, LinkHighlight, LinkStyle, LinkZoom, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; use pdfcraft_render::{DestView, LinkTarget, RemoteDestination}; use serde_json::{Value, json}; @@ -25,18 +25,30 @@ impl Automation { Ok(Some([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])) } - fn link_action(&self, a: &Args) -> Result> { + /// The action `url`, `to_page` and `zoom` ask for, if any. `current` is the link's action now + /// (link_edit): a new page keeps the link's zoom, and a zoom alone keeps its page. + fn link_action(&self, a: &Args, current: Option<&LinkAction>) -> Result> { + let zoom = link_zoom(a)?; + let (current_page, current_zoom) = match current { + Some(LinkAction::Page { page, zoom }) => (Some(*page), Some(*zoom)), + _ => (None, None), + }; match (a.opt_str("url")?, a.opt_int("to_page")?) { (Some(_), Some(_)) => Err(bad("pass url or to_page, not both")), + (Some(_), None) if zoom.is_some() => Err(bad("zoom goes with to_page, not url")), (Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))), (None, Some(p)) => { let n = self.doc(a)?.info.pages.len() as i64; if p < 1 || p > n { return Err(bad(format!("to_page must be 1–{n}"))); } - Ok(Some(LinkAction::Page(p as usize - 1))) + Ok(Some(LinkAction::Page { page: p as usize - 1, zoom: zoom.or(current_zoom).unwrap_or(LinkZoom::FitPage) })) } - (None, None) => Ok(None), + (None, None) => match (zoom, current_page) { + (None, _) => Ok(None), + (Some(zoom), Some(page)) => Ok(Some(LinkAction::Page { page, zoom })), + (Some(_), None) => Err(bad("zoom needs to_page: the link doesn't go to a page")), + }, } } @@ -86,11 +98,16 @@ impl Automation { if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) { v["destination"] = d; } - if matches!(action, LinkAction::Other(_)) { - v["named"] = json!(true); + match action { + LinkAction::Page { zoom, .. } => v["zoom"] = zoom_value(*zoom), + LinkAction::Other(_) => v["named"] = json!(true), + LinkAction::Uri(_) => {} } } - (LinkAction::Page(p), None) => v["to_page"] = json!(p + 1), + (LinkAction::Page { page, zoom }, None) => { + v["to_page"] = json!(page + 1); + v["zoom"] = zoom_value(*zoom); + } (LinkAction::Other(o), None) => v["other"] = json!(o), } v @@ -168,12 +185,13 @@ impl Automation { pub(crate) fn link_add(&mut self, a: &Args) -> Result { let page = self.page(a)?; let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?; - let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?; + let action = self.link_action(a, None)?.ok_or_else(|| bad("pass url or to_page"))?; let style = self.link_style(a, LinkStyle::default())?; self.apply(a, Edit::AddLink { page, rect, action, style }) } - fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> { + /// The link `page` and `index` name: its page, index in `/Annots`, appearance and action. + fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle, LinkAction)> { let page = self.page(a)?; let index = a.int("index")?; let doc = self.doc(a)?; @@ -182,19 +200,19 @@ impl Automation { .iter() .find(|l| l.page == page && l.index as i64 + 1 == index) .ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?; - Ok((page, l.index, l.style)) + Ok((page, l.index, l.style, l.action.clone())) } pub(crate) fn link_edit(&mut self, a: &Args) -> Result { - let (page, index, style) = self.link_index(a)?; + let (page, index, style, current) = self.link_index(a)?; let rect = self.view_rect(a, page)?; - let action = self.link_action(a)?; + let action = self.link_action(a, Some(¤t))?; let new_style = self.link_style(a, style)?; self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) }) } pub(crate) fn link_delete(&mut self, a: &Args) -> Result { - let (page, index, _) = self.link_index(a)?; + let (page, index, ..) = self.link_index(a)?; self.apply(a, Edit::DeleteLink { page, index }) } @@ -225,6 +243,40 @@ impl Automation { } } +/// The zoom a `zoom` argument names: fit_page, fit_width, fit_height, fit_visible, actual +/// (100%), inherit (keep the reader's zoom), or a percentage, kept within 1–6400. +fn link_zoom(a: &Args) -> Result> { + match a.get("zoom") { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(p)) => match p.as_f64() { + Some(p) if p.is_finite() && p > 0.0 => Ok(Some(LinkZoom::Percent(p.clamp(1.0, 6400.0)))), + _ => Err(bad("zoom must be a positive percentage")), + }, + Some(Value::String(z)) => Ok(Some(match z.as_str() { + "fit_page" => LinkZoom::FitPage, + "fit_width" => LinkZoom::FitWidth, + "fit_height" => LinkZoom::FitHeight, + "fit_visible" => LinkZoom::FitVisible, + "actual" => LinkZoom::Percent(100.0), + "inherit" => LinkZoom::Inherit, + o => return Err(bad(format!("unknown zoom {o:?}"))), + })), + Some(_) => Err(bad("zoom is a name or a percentage")), + } +} + +/// How `link_list` reports a link's zoom: the names `link_zoom` reads, a percentage as a number. +fn zoom_value(zoom: LinkZoom) -> Value { + match zoom { + LinkZoom::FitPage => json!("fit_page"), + LinkZoom::FitWidth => json!("fit_width"), + LinkZoom::FitHeight => json!("fit_height"), + LinkZoom::FitVisible => json!("fit_visible"), + LinkZoom::Inherit => json!("inherit"), + LinkZoom::Percent(p) => json!(p), + } +} + fn dest_view_value(view: DestView) -> Value { match view { DestView::Top => json!({ "kind": "top" }), diff --git a/crates/automation/src/measure.rs b/crates/automation/src/measure.rs index ac3d2d95f1..932f85ad9a 100644 --- a/crates/automation/src/measure.rs +++ b/crates/automation/src/measure.rs @@ -147,7 +147,7 @@ impl Automation { Ok(json!({"point":point,"kind":s.kind,"distance":measure::distance(at,point)})) }) .transpose()?; - Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segments.len()})) + Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segment_count()})) } pub(crate) fn measurement_export(&self, a: &Args) -> Result { let all = self.doc(a)?.measurements().map_err(failed)?; diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 6db58ab475..04ab8dace4 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -270,6 +270,8 @@ impl Automation { /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported /// as bounded warnings; `complete` requires both no continuation and no failed pages. + /// `stop_at_limit` drops that lookahead: the search ends at the `limit`th match, so a lone match + /// never sends it to the end of the document, and `next` is only where to resume (#5328). pub(crate) fn text_find(&mut self, a: &Args) -> Result { self.text_find_with(a, &mut extract_parallel) } @@ -289,11 +291,15 @@ impl Automation { Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), }; let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); + let stop_at_limit = a.opt_bool("stop_at_limit")?.unwrap_or(false); let mut matches = Vec::new(); let mut next = None; + let mut stopped = false; let mut warnings = Vec::new(); let mut warning_count = 0usize; - let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + // A search that stops at its limit starts with one page, so a match on it reads nothing + // more; the batches still double for a match further in. + let (mut first, mut batch) = (start, if stop_at_limit { 1 } else { FIND_FIRST_BATCH }); 'search: while first < n { let end = first.saturating_add(batch).min(n); let pages: Vec = (first..end).collect(); @@ -315,13 +321,19 @@ impl Automation { let skip_here = if p == start { skip } else { 0 }; // One extra match proves that the caller needs a continuation. Include skipped // matches on the starting page when bounding the temporary per-page result. - let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(usize::from(!stop_at_limit)); for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { if matches.len() == limit { next = Some(json!({ "start_page": p + 1, "skip": k })); break 'search; } matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + if stop_at_limit && matches.len() == limit { + // Resume just past this match; whether another exists isn't looked at. + next = Some(json!({ "start_page": p + 1, "skip": k.saturating_add(1) })); + stopped = true; + break 'search; + } } } first = end; @@ -329,8 +341,13 @@ impl Automation { } let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); - if let (Some(next), Some(o)) = (next, out.as_object_mut()) { - o.insert("next".into(), next); + if let Some(o) = out.as_object_mut() { + if let Some(next) = next { + o.insert("next".into(), next); + } + if stop_at_limit { + o.insert("stopped_at_limit".into(), stopped.into()); + } } Ok(out) } @@ -393,6 +410,7 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str match warning { RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give", } } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index acfa204b4b..408cb7cac0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -146,6 +146,84 @@ fn search_bounds_warning_volume_and_does_not_reach_distant_failures() { assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true)); } +#[test] +fn stop_at_limit_ends_at_a_lone_match_without_reading_the_distant_tail() { + let mut a = Automation::new(); + let id = search_document(&mut a, 200); + // #5328: "Page 1" is only on page 1 (every other readable page reads "Page 2"), and the last + // page fails, so only a search that runs on to the end hears about it. + let requested = std::cell::RefCell::new(Vec::new()); + let mut extract = |bytes: &Arc>, password: Option>, pages: &[usize]| { + requested.borrow_mut().extend_from_slice(pages); + let filler = extract_parallel(bytes, password.clone(), &[1]).remove(0).1; + let first = extract_parallel(bytes, password, &[0]).remove(0).1; + pages + .iter() + .map(|&p| { + ( + p, + match p { + 0 => first.clone(), + 199 => Err("synthetic page 200 failure".into()), + _ => filler.clone(), + }, + ) + }) + .collect::>() + }; + let looked_ahead = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page 1", "limit": 1 })), &mut extract).unwrap(); + assert_eq!((looked_ahead["count"].clone(), looked_ahead["warning_count"].clone()), (json!(1), json!(1)), "{looked_ahead}"); + assert!(looked_ahead.get("next").is_none() && looked_ahead.get("stopped_at_limit").is_none()); + + a.texts.remove(&id); + requested.borrow_mut().clear(); + let args = json!({ "doc": id.0, "query": "Page 1", "limit": 1, "stop_at_limit": true }); + let stopped = a.text_find_with(&Args(&args), &mut extract).unwrap(); + assert_eq!(stopped["matches"][0]["page"], 1); + assert_eq!((stopped["count"].clone(), stopped["warning_count"].clone()), (json!(1), json!(0)), "{stopped}"); + assert_eq!((stopped["complete"].clone(), stopped["stopped_at_limit"].clone()), (json!(false), json!(true))); + assert_eq!(stopped["next"], json!({ "start_page": 1, "skip": 1 })); + assert_eq!(*requested.borrow(), [0], "a match on the first page reads only that page"); + + // Resuming finds nothing more and reaches the failing page, which it now reports. + let mut resume = args.clone(); + resume["start_page"] = stopped["next"]["start_page"].clone(); + resume["skip"] = stopped["next"]["skip"].clone(); + let rest = a.text_find_with(&Args(&resume), &mut extract).unwrap(); + assert_eq!((rest["count"].clone(), rest["stopped_at_limit"].clone(), rest["complete"].clone()), (json!(0), json!(false), json!(false))); + assert_eq!(rest["warnings"][0]["page"], 200); + assert!(rest.get("next").is_none()); +} + +#[test] +fn stop_at_limit_continuations_return_every_match_once_and_end_complete() { + let mut a = Automation::new(); + let id = search_document(&mut a, 120); + let all = json(&mut a, "text_find", json!({ "doc": id.0, "query": "1", "limit": 10_000 })); + assert_eq!(all["complete"], true); + // "1" appears twice on pages like 11 and 111, so resuming has to skip within a page. + for limit in [1, 2, 3] { + let mut args = json!({ "doc": id.0, "query": "1", "limit": limit, "stop_at_limit": true }); + let mut pieced = Vec::new(); + for _ in 0..=all["count"].as_u64().unwrap() { + let part = json(&mut a, "text_find", args.clone()); + assert_eq!(part["warning_count"], 0); + pieced.extend(part["matches"].as_array().unwrap().iter().cloned()); + if part["stopped_at_limit"] == false { + assert_eq!(part["complete"], true); + assert!(part.get("next").is_none()); + break; + } + assert_eq!((part["count"].clone(), part["complete"].clone()), (json!(limit), json!(false))); + args["start_page"] = part["next"]["start_page"].clone(); + args["skip"] = part["next"]["skip"].clone(); + } + assert_eq!(pieced, *all["matches"].as_array().unwrap(), "limit {limit}: no match lost or repeated"); + } + let none = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent", "limit": 1, "stop_at_limit": true })); + assert_eq!((none["count"].clone(), none["complete"].clone(), none["stopped_at_limit"].clone()), (json!(0), json!(true), json!(false))); +} + #[test] fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id())); @@ -192,6 +270,30 @@ fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn a_save_without_a_path_stays_inside_the_root() { + let base = std::env::temp_dir().join(format!("pdfcraft-automation-save-root-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let mut a = Automation::new().with_root(&root).unwrap(); + let bytes = Arc::new(numbered_pages(1)); + // Remembered path inside the root: still saves. + let inside = root.canonicalize().unwrap().join("in.pdf"); + let id = a.session.open("in.pdf", Some(inside.to_string_lossy().into_owned()), bytes.clone(), None).unwrap(); + json(&mut a, "doc_save", json!({ "doc": id.0 })); + assert!(inside.exists()); + // Remembered path outside the root: refused, and nothing is written there. + let escaped = outside.join("out.pdf"); + let id = a.session.open("out.pdf", Some(escaped.to_string_lossy().into_owned()), bytes, None).unwrap(); + let error = a.call("doc_save", &json!({ "doc": id.0 })).unwrap_err(); + assert!(error.to_string().contains("outside the allowed directory"), "{error}"); + assert!(!escaped.exists()); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { let base = std::env::temp_dir(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 986f79e249..62346f4a6c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -246,12 +246,13 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.") + t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation. With stop_at_limit the search ends at the limit-th match instead of looking for one more, so a lone early match doesn't read the rest of the document.") .ro() .cmd("edit.find") .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." }, "start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." }, - "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])), + "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." }, + "stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])), t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.") .cmd("page.rotate") .with(schema( @@ -826,7 +827,7 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).") + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") @@ -834,22 +835,24 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), &["doc", "page", "index"], )), - t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.") + t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page, shown at zoom: fit_page (default), fit_width, fit_height, fit_visible, actual, inherit (keep the reader's zoom) or a percentage. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.") .cmd("edit.link") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 }, + "zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" }, "visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] }, }), &["doc", "page", "rect"], )), - t("link_edit", "Edit a link", "Change a link's rect, destination (url or to_page) or appearance. Undoable.") + t("link_edit", "Edit a link", "Change a link's rect, destination (url, or to_page and zoom as in link_add; a new to_page keeps the link's zoom, a zoom alone keeps its page) or appearance. Undoable.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 }, + "zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" }, "visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] }, }), &["doc", "page", "index"], @@ -1372,7 +1375,7 @@ pub fn tools() -> Vec { t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), - t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") + t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 2b3b0fb3ba..d71d379db9 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6201,6 +6201,80 @@ fn links_through_tools() { assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn link_zoom_through_tools() { + let dir = workdir("link-zoom"); + std::fs::write(dir.join("notes.txt"), "Contents").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap(); + let link = |a: &mut Automation| ok(a, "link_list", json!({ "doc": doc }))["links"][0].clone(); + // A page link opens at Fit Page unless told otherwise. + ok(&mut a, "link_add", json!({ "doc": doc, "page": 1, "rect": [72, 300, 200, 320], "to_page": 1 })); + assert_eq!((link(&mut a)["to_page"].clone(), link(&mut a)["zoom"].clone()), (json!(1), json!("fit_page"))); + let index = link(&mut a)["index"].clone(); + // A zoom alone keeps the page; a page alone keeps the zoom. + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": 200 })); + let l = link(&mut a); + assert_eq!((l["to_page"].clone(), l["zoom"].clone()), (json!(1), json!(200.0))); + assert_eq!(l["destination"]["zoom"], 2.0, "the viewer reads the zoom the link writes: {l}"); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": "fit_width" })); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "to_page": 1 })); + assert_eq!(link(&mut a)["zoom"], "fit_width"); + for (name, zoom) in [("actual", json!(100.0)), ("inherit", json!("inherit")), ("fit_visible", json!("fit_visible"))] { + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": name })); + assert_eq!(link(&mut a)["zoom"], zoom, "{name}"); + } + // A zoom needs a page to show: refused with a web address, unknown, or not positive. + for args in [ + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "url": "https://example.org", "zoom": "fit_page" }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "zoom": "fit_page" }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": "sideways" }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": 0 }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": [2] }), + ] { + assert!(matches!(a.call("link_add", &args), Err(ToolError::InvalidArgs(_))), "{args}"); + } + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "url": "https://example.org" })); + assert!(matches!(a.call("link_edit", &json!({ "doc": doc, "page": 1, "index": index, "zoom": 150 })), Err(ToolError::InvalidArgs(_)))); +} + +/// `link_edit` with a page and no zoom keeps the link's zoom, and with it the link's own `/XYZ` +/// factor, even outside the 1–6400% a chosen zoom is written within: 0.5% and 10000% stay so, in +/// link_list and in the viewer's destination. Naming the link's own page changes nothing, so its +/// position stays too. A zoom chosen anew is still kept within 1–6400%. +#[test] +fn link_edit_keeps_a_links_own_zoom_outside_the_range_it_writes() { + let (_base, root) = sandbox("link-own-zoom"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Link /Rect [10 10 100 30] /Border [0 0 0] /Dest [3 0 R /XYZ null null 0.005] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 50 100 70] /Border [0 0 0] /Dest [3 0 R /XYZ null null 100] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 90 100 110] /Border [0 0 0] /Dest [3 0 R /XYZ 72 300 2] >>", + ]); + std::fs::write(root.join("zooms.pdf"), pdf).unwrap(); + let mut a = auto(&root); + let doc = ok(&mut a, "doc_open", json!({ "path": "zooms.pdf" }))["doc"].as_u64().unwrap(); + let link = |a: &mut Automation, index: u64| { + ok(a, "link_list", json!({ "doc": doc }))["links"].as_array().unwrap().iter().find(|l| l["index"] == index).unwrap().clone() + }; + for (index, percent, factor) in [(1, 0.5, 0.005), (2, 10000.0, 100.0)] { + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "to_page": 2 })); + let l = link(&mut a, index); + assert_eq!((l["to_page"].clone(), l["zoom"].clone()), (json!(2), json!(percent)), "{l}"); + let shown = l["destination"]["zoom"].as_f64().unwrap(); + assert!((shown - factor).abs() <= factor * 1e-6, "the viewer's zoom is the link's own: {l}"); + } + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": 3, "to_page": 1 })); + let l = link(&mut a, 3); + // `/XYZ 72 300` on a 400 pt page: 72 from the left and 100 from the top, as the tools measure. + assert_eq!((&l["zoom"], &l["destination"]["left"], &l["destination"]["top"]), (&json!(200.0), &json!(72.0), &json!(100.0)), "{l}"); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": 1, "zoom": 10000 })); + assert_eq!(link(&mut a, 1)["zoom"], json!(6400.0)); +} + /// #4168: a Launch action whose file is a PDF (here with Windows `\` separators, relative to the /// source as a remote link is) opens that PDF in PdfCraft through link_open, within the root; one /// naming a program is listed but never followed. @@ -6228,6 +6302,40 @@ fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() { assert_eq!(a.session().docs().len(), before, "nothing was opened or launched"); } +/// A bookmark's action is reported as a link's target is, and only a go-to names a page here: a +/// remote go-to's `[1 /Fit]` is the other file's page 2, so doc_info and bookmark_list give this +/// file's no page for it. +#[test] +fn bookmarks_report_their_actions_and_a_remote_go_to_names_no_page_here() { + let (_base, root) = sandbox("bookmark-actions"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>", + "<< /Title (Web) /Parent 5 0 R /Next 7 0 R /A << /S /URI /URI (https://example.org/manual) >> >>", + "<< /Title (Other file) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>", + "<< /Title (Here) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>", + ]); + std::fs::write(root.join("bookmarks.pdf"), pdf).unwrap(); + let mut a = auto(&root); + let doc = ok(&mut a, "doc_open", json!({ "path": "bookmarks.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let outline = &info["outline"]; + assert_eq!((&outline[0]["page"], &outline[0]["action"]), (&Value::Null, &json!({ "uri": "https://example.org/manual" })), "{info}"); + assert_eq!(outline[1]["page"], Value::Null, "{info}"); + assert_eq!( + (&outline[1]["action"]["action"], &outline[1]["action"]["remote_document"], &outline[1]["action"]["destination"]["page"]), + (&json!("GoToR"), &json!("other.pdf"), &json!(2)), + "{info}" + ); + assert_eq!((&outline[2]["page"], &outline[2]["action"]), (&json!(2), &Value::Null), "{info}"); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + let pages: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].clone(), b["page"].clone())).collect(); + assert_eq!(pages, [(json!("Web"), Value::Null), (json!("Other file"), Value::Null), (json!("Here"), json!(2))], "{marks}"); +} + #[test] fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { let (_base, root) = sandbox("gotor-not-a-file"); @@ -8260,6 +8368,22 @@ fn action_run_keeps_outputs_with_duplicate_input_basenames() { assert_eq!(std::fs::read(first_path).unwrap(), first_bytes, "an earlier output must not be replaced"); } +#[cfg(unix)] +#[test] +fn action_run_never_replaces_an_entry_that_exists_check_misses() { + let dir = workdir("action-noclobber"); + std::fs::create_dir_all(dir.join("results")).unwrap(); + std::fs::write(dir.join("report.pdf"), fixture(1)).unwrap(); + // A dangling link is invisible to `exists()` but is still an occupied name. + std::os::unix::fs::symlink(dir.join("nowhere.pdf"), dir.join("results/report.pdf")).unwrap(); + let mut a = auto(&dir); + let args = json!({ "steps": [{ "step": "set_title", "arg": "Processed" }], "paths": ["report.pdf"], "folder": "results" }); + let result = ok(&mut a, "action_run", args); + let output = result["files"][0]["output"].as_str().unwrap(); + assert!(output.ends_with("report (2).pdf"), "{output}"); + assert!(dir.join("results/report.pdf").symlink_metadata().unwrap().file_type().is_symlink(), "the existing entry must stay"); +} + #[test] fn pdfa_through_tools() { let dir = workdir("pdfa"); diff --git a/crates/compare/src/lib.rs b/crates/compare/src/lib.rs index 26a50eaf71..e611b0a59c 100644 --- a/crates/compare/src/lib.rs +++ b/crates/compare/src/lib.rs @@ -411,6 +411,12 @@ mod tests { } } +/// Byte offset of pixel (`x`, `y`) in a `w`-wide RGBA buffer, in wide integers so an image past 4 GiB +/// (its `y * w` or `* 4` would wrap a `u32`) still indexes the right pixel; past `usize` it is out of range. +fn rgba_index(x: u32, y: u32, w: u32) -> usize { + usize::try_from((u128::from(y) * u128::from(w) + u128::from(x)) * 4).unwrap_or(usize::MAX) +} + /// Visual compare: the regions where two renderings of a page differ, as pixel boxes /// `[x0, y0, x1, y1]` (y down) in `a`'s image. Both images are RGBA; `b` is sampled at the /// same relative position when sizes differ. Pixels count as different when a channel differs @@ -421,6 +427,11 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8) if wa == 0 || ha == 0 || wb == 0 || hb == 0 { return Vec::new(); } + // Declared sizes the buffers cannot hold (a damaged render) would overflow the u32 index maths. + let holds = |px: &[u8], w: u32, h: u32| u128::from(w) * u128::from(h) * 4 <= px.len() as u128; + if !holds(pa, wa, ha) || !holds(pb, wb, hb) { + return Vec::new(); + } const CELL: u32 = 8; let (gw, gh) = (wa.div_ceil(CELL), ha.div_ceil(CELL)); let mut grid = vec![false; (gw * gh) as usize]; @@ -428,9 +439,8 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8) let yb = (y as u64 * hb as u64 / ha as u64) as u32; for x in 0..wa { let xb = (x as u64 * wb as u64 / wa as u64) as u32; - let ia = ((y * wa + x) * 4) as usize; - let ib = ((yb * wb + xb) * 4) as usize; - let (Some(ca), Some(cb)) = (pa.get(ia..ia + 3), pb.get(ib..ib + 3)) else { continue }; + let (ia, ib) = (rgba_index(x, y, wa), rgba_index(xb, yb, wb)); + let (Some(ca), Some(cb)) = (pa.get(ia..ia.saturating_add(3)), pb.get(ib..ib.saturating_add(3))) else { continue }; if ca.iter().zip(cb).any(|(p, q)| p.abs_diff(*q) > tolerance) { grid[((y / CELL) * gw + x / CELL) as usize] = true; } @@ -500,3 +510,28 @@ mod visual_tests { assert_eq!(visual_regions((&a, 200, 200), (&c, 200, 200), 16).len(), 2); } } + +#[cfg(test)] +mod visual_hostile_tests { + use super::*; + + #[test] + fn absurd_dimensions_with_short_buffers_return_quickly() { + let px = [0u8; 16]; + let r = visual_regions((&px, u32::MAX, u32::MAX), (&px, 2, 2), 0); + assert!(r.is_empty()); + } + + #[test] + fn pixel_offsets_past_four_gib_do_not_wrap() { + // A 32768 x 32769 RGBA image is 4,295,098,368 bytes, so a buffer that large passes the size check; + // its last pixel sits past u32::MAX and must not wrap back onto an early pixel. + let (w, h) = (32768u32, 32769u32); + let last = rgba_index(w - 1, h - 1, w); + assert_eq!(last as u64, (u64::from(h - 1) * u64::from(w) + u64::from(w - 1)) * 4); + assert!(last as u64 > u64::from(u32::MAX)); + assert_eq!(rgba_index(0, 0, w), 0); + // Out of range for any buffer: saturates instead of overflowing. + assert_eq!(rgba_index(u32::MAX, u32::MAX, u32::MAX), usize::MAX); + } +} diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 5271e591b7..56286ce4a0 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -753,6 +753,38 @@ mod image_tests { assert_eq!(budget.work_left(), u64::MAX); } + /// PDFBOX-3932: an image and soft mask declared `/Predictor 15` that their writer never + /// applied, so their rows carry no PNG filter-type byte. Exported, they were "shorter than + /// their size says"; they are read as the samples they are, and tagged rows as before. + #[test] + fn an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples() { + let doc = Document::new_empty(); + let samples: Vec = (0..36u8).map(|i| 200 - i).collect(); + let image = |height: Option, raw: Vec| { + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(15)); + parms.set(b"Colors".to_vec(), Object::Int(3)); + parms.set(b"Columns".to_vec(), Object::Int(3)); + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(3)); + if let Some(h) = height { + dict.set(b"Height".to_vec(), Object::Int(h)); + } + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, raw) + }; + let untagged = pdfcraft_filters::encode_flate(&samples); + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(budget.decode(&doc, &image(Some(4), untagged.clone()), 1 << 20).unwrap(), samples); + // Without a height to tell, the predictor is undone, as before. + assert_ne!(budget.decode(&doc, &image(None, untagged.clone()), 1 << 20).ok(), Some(samples.clone())); + assert_ne!(budget.decode(&doc, &image(Some(5), untagged), 1 << 20).ok(), Some(samples.clone())); + let p = pdfcraft_filters::Params { predictor: 15, colors: 3, columns: 3, ..pdfcraft_filters::Params::default() }; + let tagged = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &samples).unwrap(); + assert_eq!(budget.decode(&doc, &image(Some(4), tagged), 1 << 20).unwrap(), samples); + } + /// The images of a file share its globals, so an image refused doesn't read them: this one's /// can't be read, which is how to tell. #[test] diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index e322f22a15..2bf4bce42b 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -48,6 +48,8 @@ pub struct Document { /// `fill_xref_gaps`. located: Arc>, repair_log: Arc>, + /// See [`Self::rebuilt_unlinked_update`]. + unlinked_update: bool, /// What loading each file object gave, a failure as well: a damaged object is otherwise /// loaded again on every read, and a member of an object stream that can't be decoded /// decodes the stream again (`Document::load`). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 91b8656a3f..bef0d4f543 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -90,6 +90,13 @@ impl Document { &self.repair_log } + /// Whether the file was rebuilt from its object headers because its last update has no `/Prev` + /// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The + /// renderer rebuilds such a file under the same conditions, so the two read the same objects. + pub fn rebuilt_unlinked_update(&self) -> bool { + self.unlinked_update + } + /// Objects of the file that could not be read, lowest number first, as lines such as /// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each /// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost: @@ -479,7 +486,14 @@ impl Document { let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? }; return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter); } - let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) }; + let Some(p) = self.fax_params(s) else { + // The last filter gives the image's rows: rows without the tags of the PNG predictor + // they declare are read as they are (`pdfcraft_filters::decode_image_tolerant_within`). + return match height { + Some(rows) => s.decoded_image_rows_spending(max, rows, work), + None => s.decoded_spending(max, work), + }; + }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and // rows past /Height aren't drawn. let p = pdfcraft_filters::CcittParams { rows: 0, ..p }; diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index b628cea89c..8caf0b83bc 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -21,6 +21,7 @@ impl Document { xref_oddities: Arc::default(), located: Arc::default(), repair_log: Arc::new(Vec::new()), + unlinked_update: false, cache: Arc::default(), objstms: Arc::default(), unreadable: Arc::default(), @@ -109,6 +110,7 @@ impl Document { xref_oddities: Arc::default(), located: Arc::default(), repair_log: Arc::new(Vec::new()), + unlinked_update: false, cache: Arc::default(), objstms: Arc::default(), unreadable: Arc::default(), @@ -134,6 +136,8 @@ impl Document { } let parsed = doc.read_xref_chain(&mut log); let mut authenticated = false; + // The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`). + let mut cut_off = false; let ok = match parsed { Ok((entries, trailer, revisions, oddities)) => { doc.entries = Arc::new(entries); @@ -147,17 +151,22 @@ impl Document { doc.authenticate(&enc, &credentials)?; authenticated = true; } - doc.root_is_catalog() + let catalog = doc.root_is_catalog(); + cut_off = catalog && doc.update_cuts_off_the_pages(); + catalog && !cut_off } Err(e) => { log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers")); false } }; + if cut_off { + log.push(UNLINKED_UPDATE.into()); + doc.unlinked_update = true; + } else if !ok && !doc.entries.is_empty() { + log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into()); + } if !ok { - if !doc.entries.is_empty() { - log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into()); - } doc.reconstruct(&mut log)?; } // Header generations are plaintext, including in encrypted files. Inspect only the @@ -182,6 +191,24 @@ impl Document { Ok(doc) } + /// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one + /// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages` + /// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require + /// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file + /// is rebuilt from its object headers as a damaged one is (the newest definition of each + /// object wins), and the repair log says why. (A file whose `/Root` itself is missing is + /// rebuilt anyway.) + fn update_cuts_off_the_pages(&self) -> bool { + if self.revisions.len() != 1 { + return false; + } + let Some(root) = self.root() else { return false }; + let Ok(catalog) = self.try_get_reference(root) else { return false }; + let Object::Dict(catalog) = &*catalog else { return false }; + // The page tree first: the scan for an earlier `startxref` reads the whole file. + !catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data) + } + fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> { // Authentication can change how the same input is decoded. self.source_identity = Arc::new(()); @@ -203,13 +230,24 @@ impl Document { // Read here, while nothing is decrypted yet. let members = self.encryption_members(enc); let handler = match credentials { - Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { - pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, - pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, - other => CosError::Security(other.to_string()), - })?), - // The same encryption as the document whose key this is: that key opens it. - Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler), + Credentials::Password(password) => match pdfcraft_crypt::SecurityHandler::open(params.clone(), &id0, *password) { + Ok(handler) => Arc::new(handler), + // No password, or the empty one: what only the embedded files need isn't asked. + Err(pdfcraft_crypt::CryptError::WrongPassword) => match self.without_password(&dict, params) { + Some(handler) if password.is_none_or(str::is_empty) => Arc::new(handler), + _ if password.is_none() => return Err(CosError::NeedsPassword), + _ => return Err(CosError::WrongPassword), + }, + Err(other) => return Err(CosError::Security(other.to_string())), + }, + // The same encryption as the document whose key this is: that key opens it. A handler + // without the key opens only what it would open by itself. + Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => { + if !handler.has_key() && !crate::security::efopen_only(self, &dict) { + return Err(CosError::NeedsPassword); + } + Arc::clone(handler) + } Credentials::Handler { .. } => return Err(CosError::NeedsPassword), }; self.security = Some(handler); @@ -222,6 +260,46 @@ impl Document { Ok(()) } + /// The handler for a document opened without a password that only its embedded files need + /// (see [`crate::security::efopen_only`]). Not a repair: ISO 32000-2 §7.6.6 has such a + /// document open so; [`Document::embedded_files_locked`] tells it. + fn without_password(&self, dict: &Dict, params: pdfcraft_crypt::EncryptDict) -> Option { + crate::security::efopen_only(self, dict).then(|| pdfcraft_crypt::SecurityHandler::without_key(params)).flatten() + } + + /// A document opened without the password its embedded files need takes either password + /// later: with the file key its embedded files decrypt, so what was read as stored is read + /// again. Refused while an edited stream holds data read as stored, which the key would + /// encrypt a second time. + fn unlock_embedded_files(&mut self, password: &str) -> Result<(), CosError> { + let Some(current) = self.security.as_deref() else { return Ok(()) }; + if self.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if self.reads_as_stored(s))) { + return Err(CosError::Security("an embedded file was edited before the password was given: save or undo that edit first".into())); + } + let handler = pdfcraft_crypt::SecurityHandler::open(current.dict().clone(), &self.first_id(), Some(password)).map_err(|e| match e { + pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, + other => CosError::Security(other.to_string()), + })?; + self.security = Some(Arc::new(handler)); + self.source_identity = Arc::new(()); + self.cache = Arc::default(); + self.objstms = Arc::default(); + Ok(()) + } + + /// Whether the document opened without the password its embedded files need (`/AuthEvent + /// /EFOpen`): their data is held, and saved, as stored, and isn't to be read as the files. + pub fn embedded_files_locked(&self) -> bool { + self.security.as_deref().is_some_and(|h| !h.has_key()) + } + + /// Whether `s`, a stream of this document, holds encrypted data that reads as stored because the + /// document opened without the password its embedded files need (see + /// [`Document::embedded_files_locked`]). Such data isn't the file it encrypts. + pub fn reads_as_stored(&self, s: &crate::Stream) -> bool { + self.security.as_deref().is_some_and(|h| crate::security::stream_reads_as_stored(self, h, s)) + } + /// The first file identifier, part of the encryption key (empty when there is none). fn first_id(&self) -> Vec { let id = self.trailer.get(b"ID").map(|id| self.resolve(id)); @@ -268,6 +346,9 @@ impl Document { /// Both passwords unlock the same file key, so nothing read so far is decoded again. pub fn unlock(&mut self, password: &str) -> Result<(), CosError> { let Some(current) = self.security.as_deref() else { return Ok(()) }; + if !current.has_key() { + return self.unlock_embedded_files(password); + } if current.auth() == pdfcraft_crypt::Auth::Owner { return Ok(()); } @@ -411,3 +492,12 @@ impl Document { } } } + +/// The repair-log line for a file rebuilt because its last update has no `/Prev`. +const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers"; + +/// Whether `data` has more than one `startxref`: an update was appended to it, or it is +/// linearized (whose first-page section links to the main one). +fn earlier_sections(data: &[u8]) -> bool { + find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some() +} diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index f69ffae042..a6116762ea 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -276,6 +276,10 @@ fn fax_images_decode_with_their_parameters() { let mut understated = parms.clone(); understated.set(b"Rows".to_vec(), Object::Int(1)); assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says"); + // More /Rows than /Height (PDFBOX-771: 1,084,975,104), as the renderer reads it too. + let mut overstated = parms.clone(); + overstated.set(b"Rows".to_vec(), Object::Int(1_000_000_000)); + assert_eq!(image(&doc, Some(Object::Dict(overstated)), 10, 3).unwrap(), bits, "/Height rows, not /Rows past it"); // The parameters as the file gives them, /Rows included, and Table 11's defaults. let mut d = Dict::new(); d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); @@ -2190,6 +2194,69 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() { assert_eq!(cache.owned(), sum, "the kept total is the cache's own"); } +/// The width (`/MediaBox` x1) of each of `pages`, in order. +fn page_widths(pages: &[Dict]) -> Vec> { + pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect() +} + +/// The pages of `doc` in page-tree order, as the dictionaries the document holds. +fn page_tree_pages(doc: &Document) -> Vec { + let catalog = doc.get(doc.root().expect("root")); + let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages")); + let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default(); + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect() +} + +/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`. +/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers +/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and +/// the repair log says why; a save rewrites it in full, with every page. +#[test] +fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() { + let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens"); + let pages = page_tree_pages(&doc); + assert_eq!(pages.len(), 3); + assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}"); + assert!(doc.reconstructed() && doc.rebuilt_unlinked_update()); + assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log()); + let catalog = doc.get(doc.root().expect("root")); + assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins"); + + let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves"); + let reopened = Document::open(Arc::new(saved)).expect("the saved file opens"); + let pages = page_tree_pages(&reopened); + assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order"); + assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}"); + let catalog = reopened.get(reopened.root().expect("root")); + assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved"); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + + // The same file with `/Prev` reads through its chain, as before. + let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens"); + assert_eq!(page_tree_pages(&linked).len(), 3); + assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update()); + assert_eq!(linked.revisions().len(), 2); + assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log()); +} + +/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the +/// update, so the earlier revision is cut off just the same, and the file is rebuilt. +#[test] +fn a_hybrid_update_without_prev_is_rebuilt_too() { + let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens"); + let pages = page_tree_pages(&doc); + assert_eq!(pages.len(), 3); + assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}"); + assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log()); + assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log()); + let catalog = doc.get(doc.root().expect("root")); + assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins"); + let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens"); + assert_eq!(page_tree_pages(&linked).len(), 3); + assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log()); + assert_eq!(linked.revisions().len(), 2); +} + #[test] fn find_agrees_with_a_plain_window_search() { let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| { diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 9e9b6a8764..7f7f32fedc 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -30,7 +30,7 @@ pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, }; -pub use writer::{SaveOptions, civil_from_days, pdf_date, reachable_objects, serialize, write_full, write_incremental}; +pub use writer::{SaveOptions, civil_from_days, new_file_id, pdf_date, reachable_objects, serialize, write_full, write_incremental}; #[derive(Debug, thiserror::Error, Clone, PartialEq)] pub enum CosError { diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 5ca4e7e4eb..f40f175d69 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -390,6 +390,14 @@ impl Stream { pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter) } + /// [`Stream::decoded_spending`] for the samples of an image `rows` high (its `/Height`): + /// [`pdfcraft_filters::decode_image_tolerant_within`]. + pub(crate) fn decoded_image_rows_spending(&self, max: usize, rows: u32, work: &mut u64) -> Result, CosError> { + let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); + let chain = self.readable_filters().map_err(filter)?; + pdfcraft_filters::decode_image_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), rows, work).map_err(filter) + } + /// The stream decoded through its filters by `decode`, or copied when it has none, within /// `max` and [`MAX_DECODED`]. fn decode_chain( diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 709659f537..d2cb904871 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -502,14 +502,19 @@ fn parse_indirect_in( let mut after = Lexer::new(data, end); after.eat_keyword(b"endstream") }; + // A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt. + let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len())); let len = match declared { Some(l) if valid(l) => l as usize, - // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). - _ => { - find_endstream(data, start, endstreams) - .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - - start - } + // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none + // left in the file, a /Length that fits is the stream's extent, "how many bytes of the + // PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers + // take it: a producer that wrote `endstrea` after every stream lost every font and + // every page's content. + _ => match find_endstream(data, start, endstreams) { + Some(end) => end - start, + None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?, + }, }; let range = start..start + len; let raw = match shared { @@ -660,6 +665,24 @@ mod tests { assert_eq!(s.dict.int(b"Length"), Some(5)); } + /// With no `endstream` left in the file, as a producer that writes `endstrea` leaves every + /// stream, a stream is the bytes its /Length gives, "how many bytes of the PDF file are used + /// for the stream's data" (ISO 32000-2 §7.3.8.2): it was an error, and the object null, so + /// such a file lost every font and page content. A /Length past the file, or none, is still + /// no stream. + #[test] + fn indirect_stream_without_endstream_is_its_length() { + for end in [&b"\r\nendstrea\rendobj\r\n"[..], b"\nendobj\n", b""] { + let mut data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO".to_vec(); + data.extend_from_slice(end); + let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{end:?}"); + } + assert!(parse_indirect(b"7 0 obj\n<< /Length 6 >>\nstream\nHELLO", 0, &|_| None).is_err(), "past the file"); + assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length"); + } + #[test] fn indirect_stream_with_a_space_before_the_line_break() { // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 61c4f92a4c..28813b0ac9 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -128,6 +128,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict { } } +/// Whether `d`, an `/Encrypt` dictionary, has crypt filters that encrypt (`/CFM` `V2`, `AESV2` or +/// `AESV3`), each authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO +/// 32000-2 §7.6.6, Table 25): what a document asks for its embedded files alone, which it opens +/// without (§7.6.6: "EFOpen Authorization shall be required when accessing embedded files"). The +/// files use them through `/EFF` or through a `/Crypt` filter of their own. Its strings and streams +/// have to be in the clear too, which [`SecurityHandler::without_key`] checks. +pub(crate) fn efopen_only(doc: &Document, d: &Dict) -> bool { + let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec)); + let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) else { return false }; + let mut encrypting = Vec::new(); + for (k, v) in cf.iter() { + let Some(f) = doc.resolve(v).as_dict().cloned() else { continue }; + if !matches!(name(&f, b"CFM").as_deref(), Some(b"V2" | b"AESV2" | b"AESV3")) { + continue; + } + if name(&f, b"AuthEvent").as_deref() != Some(b"EFOpen") { + return false; + } + encrypting.push(k.clone()); + } + !encrypting.is_empty() +} + +/// Whether `s`, read from a document whose handler has no file key, holds data that handler would +/// decrypt, which therefore reads as stored: still encrypted. +pub(crate) fn stream_reads_as_stored(doc: &Document, h: &SecurityHandler, s: &Stream) -> bool { + if h.has_key() { + return false; + } + let _scope = FollowScope::enter(); + let named = named_crypt_filter(doc, &s.dict); + let ty = s.dict.get(b"Type").map(|t| followed(doc, t)); + let kind = match (&named, ty.as_deref().and_then(Object::as_name)) { + (Some(n), _) => StreamKind::Named(n), + (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, + _ => StreamKind::Normal, + }; + h.decrypts(kind) +} + /// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`. fn is_crypt(doc: &Document, f: &Object) -> bool { followed(doc, f).as_name() == Some(b"Crypt") diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 3037669aea..56731f7e39 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -166,6 +166,24 @@ fn write_stream(s: &Stream, out: &mut Vec) { out.extend_from_slice(b"\nendstream"); } +/// A document opened without the password its embedded files need holds their data as stored and +/// can't encrypt: a save keeps its `/Encrypt` as it is, and writes no edited or added stream that +/// would have to be encrypted (an attached file put in, say), which would be written in the clear. +fn keyless_check(doc: &Document) -> Result<(), CosError> { + if !doc.embedded_files_locked() { + return Ok(()); + } + let refuse = + |what: &str| Err(CosError::Security(format!("{what} without the password of the document's embedded files: open it with the password"))); + if doc.encryption_changed() || doc.trailer().get(b"Encrypt").is_none() { + return refuse("the document's security can't change"); + } + if doc.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if doc.reads_as_stored(s))) { + return refuse("an edited or added embedded file can't be encrypted"); + } + Ok(()) +} + /// The object as written: encrypted with its (output) number when the document is encrypted, /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). /// `o` has the document's numbers in its references ([`crate::security::transform`]). @@ -242,6 +260,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, if doc.stream_limited() { return Err(CosError::ReadOnlyLimit); } + keyless_check(doc)?; // Reconstructed files have no chain to append to; added or removed encryption must // rewrite every object; redaction must not leave the old revision behind. All need a full save. if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() { @@ -384,6 +403,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if doc.stream_limited() { return Err(CosError::ReadOnlyLimit); } + keyless_check(doc)?; + // Renumbering breaks a key that mixes in the object number. + if doc.security().is_some_and(|h| !h.has_key() && !h.survives_renumbering()) { + return Err(CosError::Security( + "the embedded files are encrypted and the document was opened without their password: open it with the password to save it in full" + .into(), + )); + } // A full traversal must not fill the editor's shared caches (which undo snapshots also // own). Parsed objects and decoded object streams are temporary working data here. let mut reader = doc.object_reader(); @@ -762,13 +789,21 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) { } } -/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4). +/// Keep any existing two-element `/ID` array as it is, even a malformed one such as `[<> <>]` or `[1 2]` (an ordinary +/// save preserves what it doesn't understand; Save as PDF/A replaces or completes it), or create one with +/// [`new_file_id`] when there is none or it isn't a two-element array. fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) { return; } + trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed)); +} + +/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of +/// two equal 16-byte strings, the same for the same content and `seed`. +pub fn new_file_id(content: &[u8], seed: u64) -> Object { // FNV-1a over a sample of the content and the seed: stable, dependency-free. - let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed; + let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed; for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) { h ^= *b as u64; h = h.wrapping_mul(0x0100_0000_01b3); @@ -777,7 +812,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { id.extend_from_slice(&h.to_be_bytes()); id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes()); let s = Object::String(PdfString { bytes: id, hex: true }); - trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); + Object::Array(vec![s.clone(), s]) } /// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not diff --git a/crates/cos/tests/it/efopen.rs b/crates/cos/tests/it/efopen.rs new file mode 100644 index 0000000000..4bf3470ea2 --- /dev/null +++ b/crates/cos/tests/it/efopen.rs @@ -0,0 +1,224 @@ +//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent +//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored, +//! and its embedded files keep their encrypted bytes, flagged as such, through every save. + +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental}; +use pdfcraft_crypt::StreamKind; + +const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET"; +const ATTACHED: &[u8] = b"the attached file's own words"; +const PASSWORD: &str = "attach pw"; +const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 }; +const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 }; + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02X}")).collect() +} + +/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is +/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`. +fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec { + fixture_with(algorithm, auth_event, false) +} + +/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with +/// no `/EFF` (ISO 32000-2 §7.4.10). +fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec { + let id0 = [7u8; 16]; + let params = + NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] }; + let handler = pdfcraft_crypt::create(¶ms, &id0).unwrap(); + let d = handler.dict(); + let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile }; + let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind); + let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") }; + let (version, cfm, r6) = match algorithm { + Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))), + _ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()), + }; + let encrypt = format!( + "<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>", + hex(&d.o), + hex(&d.u), + d.p + ); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(), + b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(), + ]; + let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes(); + stream.extend_from_slice(&cipher); + stream.extend_from_slice(b"\nendstream"); + objs.push(stream); + objs.push(encrypt.into_bytes()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + let trailer = format!( + "trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n", + objs.len() + 1, + hex(&id0), + hex(&id0) + ); + out.extend_from_slice(trailer.as_bytes()); + out +} + +fn stream(doc: &Document, r: ObjRef) -> pdfcraft_cos::Stream { + match &*doc.get(r) { + Object::Stream(s) => s.clone(), + other => panic!("object {} isn't a stream: {other:?}", r.num), + } +} + +/// The attachment as the password reads it from `bytes`. +fn attachment_with_password(bytes: Vec) -> Vec { + let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap(); + assert!(!doc.embedded_files_locked()); + let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap(); + assert!(embedded.get(b"Names").is_some()); + stream(&doc, EMBEDDED).decoded().unwrap() +} + +#[test] +fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() { + let bytes = fixture(Algorithm::Aes256, "EFOpen"); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + assert!(doc.embedded_files_locked()); + // Not a repair: the specification has it open so. + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert!(doc.trailer().get(b"Encrypt").is_some()); + // The page reads as stored; the attachment keeps its encrypted bytes and says so. + let content = stream(&doc, CONTENT); + assert_eq!(content.decoded().unwrap(), PAGE.as_bytes()); + assert!(!doc.reads_as_stored(&content)); + let embedded = stream(&doc, EMBEDDED); + assert!(doc.reads_as_stored(&embedded)); + assert_ne!(embedded.decoded().unwrap(), ATTACHED); + // The password still opens it, and reads the attachment. + assert_eq!(attachment_with_password(bytes.clone()), ATTACHED); + // A wrong password is still refused. + assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword))); +} + +#[test] +fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() { + let bytes = fixture(Algorithm::Aes256, "DocOpen"); + assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword))); + assert_eq!(attachment_with_password(bytes), ATTACHED); +} + +#[test] +fn saves_keep_the_encrypted_embedded_files_as_stored() { + for algorithm in [Algorithm::Aes256, Algorithm::Aes128] { + let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap(); + assert!(doc.embedded_files_locked(), "{algorithm:?}"); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap(); + // An incremental save keeps /Encrypt, and the attachment decrypts with the password. + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}"); + // A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do. + match algorithm { + Algorithm::Aes256 => { + let full = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(full.clone())).unwrap(); + assert!(reopened.embedded_files_locked()); + let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap(); + let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap(); + let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap(); + let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap(); + let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap(); + let file = match &*doc.resolve(ef.get(b"F").unwrap()) { + Object::Stream(s) => s.decoded().unwrap(), + other => panic!("{other:?}"), + }; + assert_eq!(file, ATTACHED); + } + _ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"), + } + } +} + +#[test] +fn security_cannot_change_without_the_embedded_files_password() { + let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap(); + doc.remove_encryption(); + assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_)))); + assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_)))); +} + +/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a +/// document opened without the key open no other. +#[test] +fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() { + let bytes = fixture(Algorithm::Aes256, "EFOpen"); + let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap(); + assert!(doc.embedded_files_locked()); + assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked()); + // The same /Encrypt values and /ID, authorised when the document opens. + let doc_open = fixture(Algorithm::Aes256, "DocOpen"); + assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword))); + assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword))); +} + +/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way. +#[test] +fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() { + let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + assert!(doc.embedded_files_locked()); + assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED))); + assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes()); + assert_eq!(attachment_with_password(bytes), ATTACHED); +} + +/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an +/// embedded file, is refused rather than written in the clear. +#[test] +fn saves_refuse_what_would_need_the_key() { + let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap(); + let mut doc = open(); + doc.trailer_mut().remove(b"Encrypt"); + assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_)))); + assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_)))); + let mut doc = open(); + let mut replaced = stream(&doc, EMBEDDED); + replaced.raw = b"new words".to_vec().into(); + doc.set(EMBEDDED, Object::Stream(replaced)); + assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_)))); +} + +/// The password given after opening unlocks the embedded files; a wrong one doesn't. +#[test] +fn the_password_given_later_unlocks_the_embedded_files() { + let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap(); + assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword))); + assert!(doc.embedded_files_locked()); + doc.unlock(PASSWORD).unwrap(); + assert!(!doc.embedded_files_locked()); + assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED); + assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes()); + // Not while an embedded file read as stored has been edited. + let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap(); + let edited = stream(&doc, EMBEDDED); + doc.set(EMBEDDED, Object::Stream(edited)); + assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_)))); +} diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs index f8e75ba804..0f4d9d7c2b 100644 --- a/crates/cos/tests/it/main.rs +++ b/crates/cos/tests/it/main.rs @@ -2,6 +2,7 @@ mod bounded_writer; mod corpus; +mod efopen; mod encryption; mod generations; mod proptests; diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index be9def0704..9478e74130 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -234,6 +234,42 @@ impl SecurityHandler { Ok(Self::new(dict, key, auth, recovered_user)) } + /// A handler with no file key, for a document opened without a password whose strings and + /// streams aren't encrypted and whose encrypted crypt filters are all ones the caller found + /// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, + /// Table 25). Data such a filter encrypts is left as stored, read and written alike, so it + /// keeps its encrypted bytes; [`SecurityHandler::decrypts`] tells which. `None` when strings or + /// streams are encrypted, which needs the key, or the handler is older than crypt filters (V4). + pub fn without_key(dict: EncryptDict) -> Option { + if dict.v < 4 { + return None; + } + let methods = Methods::new(&dict); + if methods.string != Method::Identity || methods.stream != Method::Identity { + return None; + } + Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods }) + } + + /// Whether the handler has the file key: false for [`SecurityHandler::without_key`]. + pub fn has_key(&self) -> bool { + !self.key.is_empty() + } + + /// Whether data of `kind` is encrypted, so that reading it needs the file key. + pub fn decrypts(&self, kind: StreamKind) -> bool { + self.method(Some(kind)) != Method::Identity + } + + /// Whether data left as stored by a handler without the key still decrypts after its object + /// is renumbered: only AES-256 doesn't mix the object number into the key (Algorithm 1.A). + pub fn survives_renumbering(&self) -> bool { + [self.methods.string, self.methods.stream, self.methods.embedded_file] + .into_iter() + .chain(self.methods.named.values().copied()) + .all(|m| matches!(m, Method::Identity | Method::Aes256)) + } + /// Upgrade to owner access with the owner (permissions) password, as when a restricted /// document is unlocked after opening. The user password, or any other, is `WrongPassword`. /// Both passwords unlock the same file key, so what was decrypted so far stays valid; a @@ -267,7 +303,7 @@ impl SecurityHandler { /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. fn perms_p(&self) -> Option { - if !matches!(self.dict.r, 5 | 6) { + if !matches!(self.dict.r, 5 | 6) || !self.has_key() { return None; } let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); @@ -332,6 +368,10 @@ impl SecurityHandler { } fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec { + // Without the key, encrypted data stays as stored (see `without_key`). + if !self.has_key() { + return data.to_vec(); + } match method { Method::Identity => data.to_vec(), Method::Rc4 => rc4(&self.object_key(method, num, generation), data), diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index c54b062af6..73c5389c25 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -136,6 +136,160 @@ fn xobject_name(existing: &Dict, pending: &Dict, n: &mut u32) -> String { } } +/// Copy usable property resources, including inherited resources supplied by the page model. +/// Invalid data must stay intact rather than being replaced while transferring membership. +fn properties(doc: &Document, page: &Dict) -> Option { + let Some(resources) = page.get(b"Resources") else { return Some(Dict::new()) }; + let resources = doc.resolve(resources); + let Object::Dict(resources) = &*resources else { return None }; + let Some(properties) = resources.get(b"Properties") else { return Some(Dict::new()) }; + match &*doc.resolve(properties) { + Object::Dict(properties) => Some(properties.clone()), + _ => None, + } +} + +/// Validate an ISO 32000-1 §8.11.2.2 `/VE` visibility expression within recursion depth 16 +/// and a shared node-visit `budget`. +fn valid_ve(doc: &Document, expr: &Object, depth: usize, budget: &mut usize) -> bool { + if depth > 16 || *budget == 0 { + return false; + } + *budget -= 1; + let resolved = doc.resolve(expr); + if let Some(arr) = resolved.as_array() { + let [op, rest @ ..] = arr.as_slice() else { return false }; + let op_obj = doc.resolve(op); + let Some(name) = op_obj.as_name() else { return false }; + match (name, rest) { + (b"Not", [operand]) => valid_ve(doc, operand, depth + 1, budget), + (b"And" | b"Or", [_, ..]) => rest.iter().all(|o| valid_ve(doc, o, depth + 1, budget)), + _ => false, + } + } else if depth > 0 { + expr.as_ref().is_some_and(|r| matches!(&*doc.get(r), Object::Dict(d) if d.name(b"Type") == Some(b"OCG"))) + } else { + false + } +} + +/// Validate an `/OCMD` dictionary's `/P` policy and `/VE` visibility expression, if present. +fn valid_ocmd(doc: &Document, d: &Dict) -> bool { + if let Some(p) = d.get(b"P") + && !matches!(doc.resolve(p).as_name(), Some(b"AllOn" | b"AnyOn" | b"AnyOff" | b"AllOff")) + { + return false; + } + if let Some(ve) = d.get(b"VE") + && !valid_ve(doc, ve, 0, &mut 4096) + { + return false; + } + true +} + +/// Preserve the stored value, not a reconstructed membership dictionary. A direct inline +/// dictionary lacks an indirect reference (ObjRef) to identify it in page resources, and an +/// OCMD with a malformed or overly deep `/VE` expression stays on the annotation. +fn transferable(doc: &Document, annotation: &Dict, usable_properties: bool) -> bool { + let Some(membership) = annotation.get(b"OC") else { return true }; + if !usable_properties { + return false; + } + match &*doc.resolve(membership) { + Object::Dict(d) => match d.name(b"Type") { + Some(b"OCG") => membership.as_ref().is_some(), + Some(b"OCMD") => valid_ocmd(doc, d), + _ => false, + }, + _ => false, + } +} + +#[derive(Default)] +struct Retained { + refs: HashSet, + inline: HashSet, +} + +impl Retained { + fn contains(&self, index: usize, entry: &Object) -> bool { + self.inline.contains(&index) || entry.as_ref().is_some_and(|r| self.refs.contains(&r)) + } + + fn insert(&mut self, index: usize, entry: &Object) { + if let Some(r) = entry.as_ref() { + self.refs.insert(r); + } else { + self.inline.insert(index); + } + } +} + +/// Retained annotations keep their replies and pop-ups, including transitive reply chains. +/// +/// Retention is strictly downward (parent -> child/reply/popup). A non-transferable child does not +/// keep its transferable parent, matching the engine invariant for undrawn children (#1794). +fn retained_annotations(doc: &Document, list: &[Object], usable_properties: bool) -> Retained { + let mut children: HashMap> = HashMap::new(); + let mut inline_children: HashMap> = HashMap::new(); + let mut pending = Vec::new(); + let mut retained = Retained::default(); + for (k, entry) in list.iter().enumerate() { + let object = doc.resolve(entry); + let Some(annotation) = object.as_dict() else { continue }; + let not_transferable = !transferable(doc, annotation, usable_properties); + if let Some(reference) = entry.as_ref() { + if not_transferable { + pending.push(reference); + } + if let Some(parent) = annotation.reference(b"IRT") { + children.entry(parent).or_default().push(reference); + } + if annotation.name(b"Subtype") == Some(b"Popup") + && let Some(parent) = annotation.reference(b"Parent") + { + children.entry(parent).or_default().push(reference); + } + } else { + if not_transferable { + retained.inline.insert(k); + } + if let Some(parent) = annotation.reference(b"IRT") { + inline_children.entry(parent).or_default().push(k); + } + if annotation.name(b"Subtype") == Some(b"Popup") + && let Some(parent) = annotation.reference(b"Parent") + { + inline_children.entry(parent).or_default().push(k); + } + } + } + while let Some(current) = pending.pop() { + if !retained.refs.insert(current) { + continue; + } + if let Some(kids) = children.get(¤t) { + pending.extend(kids.iter().copied()); + } + if let Some(inline_kids) = inline_children.get(¤t) { + retained.inline.extend(inline_kids.iter().copied()); + } + } + retained +} + +/// A Properties resource name that is not already on the page or in `pending`. +fn property_name(existing: &Dict, pending: &Dict, n: &mut u64) -> Option { + loop { + let name = format!("PCFlOC{n}"); + *n = n.checked_add(1)?; + if !existing.contains(name.as_bytes()) && !pending.contains(name.as_bytes()) { + return Some(name); + } + } +} + /// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged /// into page content. pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result { @@ -157,10 +311,15 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result Option<(usize, bool, char)> { for &i in pages { let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - for entry in &list { + let usable_properties = properties(doc, &all[i].dict).is_some(); + let retained = retained_annotations(doc, &list, usable_properties); + for (k, entry) in list.iter().enumerate() { + if retained.contains(k, entry) { + continue; + } let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue }; let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 { + if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) { continue; } let widget = d.name(b"Subtype") == Some(b"Widget"); @@ -174,6 +333,26 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi None } +/// Ensure comments without stored appearances have appearances generated before flattening. +fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retained: &HashSet, usable_properties: bool) { + for entry in list { + let Some(r) = entry.as_ref() else { continue }; + if retained.contains(&r) { + continue; + } + let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default(); + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"Widget" || !selected(doc, &d, which) || !transferable(doc, &d, usable_properties) { + continue; + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + continue; + } + let _ = pdfcraft_annot::set_appearance(doc, r); + } +} + /// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on /// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result Result Result> /AP << /N 6 0 R >>", 1, false); } +/// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and +/// valid OCMDs, including ISO 32000-1 §8.11.2.2 `/VE` expressions within depth 16 and the +/// 4,096-node visit budget) in `/OC /PCFlOC0 BDC … EMC` and registers them in +/// `/Resources /Properties`, while retaining annotations with untransferable, malformed, +/// overly deep, or over-budget `/OC` definitions in `/Annots`. +#[test] +fn flattening_transfers_annotation_optional_content_to_marked_content() { + let page = stream("", " "); + let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; + let case = |extra: &str, drawn: usize, left: bool, bdc: bool| { + let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /AP << /N 6 0 R >> {extra} >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R 8 0 R 13 0 R] /D << /OFF [7 0 R] /ON [8 0 R 13 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>", + &annot, + &page, + form, + "<< /Type /OCG /Name (Hidden layer) >>", + "<< /Type /OCG /Name (Shown layer) >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOn >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOff >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOff >>", + "<< /Type /OCG /Name (Second shown layer) >>", + "[ /Or 15 0 R 15 0 R 15 0 R 15 0 R ]", + "[ /Or 16 0 R 16 0 R 16 0 R 16 0 R ]", + "[ /Or 17 0 R 17 0 R 17 0 R 17 0 R ]", + "[ /Or 18 0 R 18 0 R 18 0 R 18 0 R ]", + "[ /Or 8 0 R 8 0 R 8 0 R 8 0 R ]", + ]); + let before = streams(&doc, 0); + let expected_oc = doc.get(pdfcraft_cos::ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"OC").cloned()); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn, "{extra}"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(p.dict.contains(b"Annots"), left, "{extra}"); + if drawn == 0 { + assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the retained comment: {extra}"); + } else { + let flat = streams(&doc, 0).pop().unwrap(); + assert_eq!(flat.contains("/OC /PCFlOC0 BDC") && flat.contains("EMC"), bdc, "{extra}: {flat}"); + if bdc { + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let props = doc.resolve(res.as_dict().unwrap().get(b"Properties").unwrap()); + assert_eq!(props.as_dict().unwrap().get(b"PCFlOC0").cloned(), expected_oc, "{extra}"); + } + } + }; + case("/OC 7 0 R", 1, false, true); + case("/OC 8 0 R", 1, false, true); + case("/OC 9 0 R", 1, false, true); + case("/OC 10 0 R", 1, false, true); + case("/OC 11 0 R", 1, false, true); + case("/OC 12 0 R", 1, false, true); + case("", 1, false, false); + // Valid ISO 32000-1 §8.11.2.2 /VE visibility expressions are transferred with their OCMD. + case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /And 8 0 R 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Or 8 0 R 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Or 7 0 R [ /And 8 0 R 13 0 R ] ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /And 8 0 R [ /Not 13 0 R ] ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] /OCGs [8 0 R] /P /AllOn >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] /OCGs [7 0 R] /P /AllOn >>", 1, false, true); + // Malformed /VE, invalid /P, inline /OCG, and /VE exceeding depth 16 or the node budget fail closed and stay in /Annots. + case("/OC << /Type /OCG /Name (Inline layer) >>", 0, true, false); + case("/OC << /Type /OCMD /OCGs [8 0 R] /P /InvalidPolicy >>", 0, true, false); + case("/OC << /Type /OCMD /VE 8 0 R /OCGs [8 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [ /Not 7 0 R 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [ /Xor 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + let deep_ve = format!("{}8 0 R{}", "[ /Not ".repeat(17), " ]".repeat(17)); + case(&format!("/OC << /Type /OCMD /VE {deep_ve} /OCGs [8 0 R] /P /AllOn >>"), 0, true, false); + case("/OC << /Type /OCMD /VE [ /Or 14 0 R 14 0 R 14 0 R 14 0 R ] /OCGs [8 0 R] /P /AllOn >>", 0, true, false); +} + /// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must /// generate that appearance and bake it into the page, not delete a comment that shows an icon. #[test] @@ -11279,6 +11356,38 @@ fn q_inside_a_text_object_restores_the_text_matrices() { } } +#[test] +fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() { + let replacement = "1\u{FE0F}"; + let mut results = Vec::new(); + for kind in ["line", "block"] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + if kind == "line" { + text::replace_line(&mut doc, 0, 0, replacement).unwrap(); + } else { + text::replace_block(&mut doc, 0, 0, replacement).unwrap(); + } + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + results.push((kind, lines[0].text.clone())); + } + assert_eq!(results, vec![("line", replacement.to_owned()), ("block", replacement.to_owned())]); +} + +#[test] +fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() { + // қ requires the same bundled fallback for both drafts, without optional font inputs. + for spacing in [0.0, 2.0] { + let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let mut selected = plain.clone(); + let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; + text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); + text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); + let a = text::text_lines(&reopen(&plain), 0).unwrap(); + let b = text::text_lines(&reopen(&selected), 0).unwrap(); + assert_eq!(a[0].base_font, b[0].base_font); + assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + } +} /// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. /// Selected-row alignment for existing PDF content is withheld until it can persist safely. #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index a3ee880a64..2dbf302685 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -25,7 +25,7 @@ mod regions; #[path = "text_session.rs"] mod session; pub use fallback::strip_subset_tag; -use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block}; +use regions::{alignment_frame, group_blocks, is_centred, rewrite_changed_words, rewrite_styled_block}; pub use session::TextSession; mod blocks; @@ -42,7 +42,7 @@ pub use blocks::{ BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; -use blocks::{paragraph_separator, row_break}; +use blocks::{kept_words, paragraph_separator, row_break}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; #[cfg(test)] @@ -55,12 +55,12 @@ use fallback::{ use fallback::{add_type3_font, is_type3_fallback}; pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name}; use interp::{Origin, Paint, union}; -use lines::page_dict; pub(crate) use lines::{ ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call, joined, resource_dict, rewritten_contents, splice, }; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; +use lines::{page_dict, word_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; use rewrite::{coincident_ops, rewrite_block_from}; pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks}; diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index f2da128071..b714bcd592 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -44,6 +44,12 @@ impl TextBlock { (self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size) } + /// Whether `draft` says what the paragraph says, give or take space around it. Spacing inside + /// it has to match: a typed second space is a change. + pub fn same_words(&self, draft: &str) -> bool { + draft.trim() == self.editor_text.trim() + } + /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding /// source run. The texts are aligned word by word, so words left untouched between two edits /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. @@ -163,6 +169,23 @@ fn edit_script(a: &[T], b: &[T], max: usize) -> Option> None } +/// For each of `old`'s words, the index in `new` of the word it stays as in the shortest edit from +/// one to the other (`None` when it goes), or `None` overall past [`MAX_CHANGED_WORDS`] changes. +pub(crate) fn kept_words(old: &[T], new: &[T]) -> Option>> { + let mut kept = vec![None; old.len()]; + let (mut o, mut n) = (0usize, 0usize); + for step in edit_script(old, new, MAX_CHANGED_WORDS)? { + if step == Step::Keep + && let Some(slot) = kept.get_mut(o) + { + *slot = Some(n); + } + o = o.saturating_add(usize::from(step != Step::Insert)); + n = n.saturating_add(usize::from(step != Step::Delete)); + } + Some(kept) +} + /// Walk Myers' saved frontiers back from the end to the edit script. fn backtrack(trace: &[Vec], n: isize, m: isize, max: isize) -> Vec { let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); @@ -444,6 +467,13 @@ pub struct BlockStyle { /// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour /// (RGB 0–1) before the new text, hiding the scanned word beneath it. pub cover: Option<[f64; 3]>, + /// For an invisible paragraph whose formatting nobody asked to change: cover and redraw only + /// the words the draft changes, and leave the rest as the scan shows them (#3313). The session + /// sets this; a caller that sets it should also give `cover`. + pub changed_words_only: bool, + /// How far across [`BlockStyle::cover`] may reach, whatever its padding: the edges of the words + /// on either side that stay (user space). + pub cover_between: Option<[f64; 2]>, } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 80b20e0cda..789ffc5456 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -5,7 +5,27 @@ use super::{ }; /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. -pub(super) type Encoder = Box Option)>>>; +pub(super) type Encoder = Box Option>>; + +/// One run a rewrite shows: bytes drawn in one generated font. `spacing` set (a variation +/// selector's run) shows at that character spacing: its empty glyph advances nothing, and the +/// spacing in force must not add to it either. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Type3Run { + pub(super) name: String, + pub(super) bytes: Vec, + pub(super) spacing: Option, +} + +/// A Unicode variation selector (U+FE00..U+FE0F): it picks a variant of the character before it +/// and has no glyph of its own. The Type 3 fallback gives it an empty zero-width glyph instead of +/// failing the edit on it. +pub(super) fn is_variation_selector(ch: char) -> bool { + matches!(u32::from(ch), 0xFE00..=0xFE0F) +} + +/// A variation selector's glyph procedure: nothing painted, zero advance. +const SELECTOR_GLYPH_PROC: &[u8] = b"0 0 0 0 0 0 d1\n"; fn is_win_ansi_char(c: char) -> bool { c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some() @@ -377,7 +397,13 @@ pub(super) fn type3_font( // The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every // character. When none has them all, the best face reports the character it lacks. Hebrew // text keeps the Hebrew face and takes each character it lacks from the next face that has it. - let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) } + // A variation selector has no outline anywhere: it takes a glyph slot of its own below, so the + // face is chosen by the characters that have outlines. + let named: Vec = chars.iter().copied().filter(|ch| !is_variation_selector(*ch)).collect(); + if named.is_empty() { + return Err(EditError::Invalid("replacement text is empty".into())); + } + let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &named, |face, ch| japanese_glyph_from(face, ch).is_ok()) } .ok_or_else(|| no_fallback_font(text))?; let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] }; // Every outline is read before the document is touched, so a character that fails leaves it @@ -385,15 +411,21 @@ pub(super) fn type3_font( let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut glyphs = Vec::with_capacity(chars.len()); for (i, ch) in chars.into_iter().enumerate() { - // The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew - // face lacks from the next face that has it); a character no face draws is reported once. - let mut drawn = type3_glyph(face, ch)?; - for f in others.iter().copied() { - if drawn.is_some() { - break; + let drawn = if is_variation_selector(ch) { + Some((SELECTOR_GLYPH_PROC.to_vec(), 0.0)) + } else { + // The chosen face first, then the faces after it (Hebrew text takes a character the + // Hebrew face lacks from the next face that has it); a character no face draws is + // reported once. + let mut drawn = type3_glyph(face, ch)?; + for f in others.iter().copied() { + if drawn.is_some() { + break; + } + drawn = type3_glyph(f, ch)?; } - drawn = type3_glyph(f, ch)?; - } + drawn + }; let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) }; let code = u8::try_from((i % per_font).saturating_add(1)) .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; @@ -580,17 +612,22 @@ pub(crate) fn type3_font_dict(doc: &mut Document, glyphs: &[(char, Vec, f64) } /// The runs that show `text` in the fallback: consecutive characters in one font form one run, so -/// a font is switched only where the text moves to another font. `None` if a character has no code. -pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option)>> { - let mut runs: Vec<(usize, Vec)> = Vec::new(); +/// a font is switched only where the text moves to another font. A variation selector forms its +/// own run, drawn at character spacing zero (see [`Type3Run`]). `None` if a character has no code. +pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option> { + let mut runs: Vec<(usize, Vec, Option)> = Vec::new(); for ch in text.chars() { let (font, code) = *fallback.codes.get(&ch)?; + if is_variation_selector(ch) { + runs.push((font, vec![code], Some(0.0))); + continue; + } match runs.last_mut() { - Some((last, bytes)) if *last == font => bytes.push(code), - _ => runs.push((font, vec![code])), + Some((last, bytes, None)) if *last == font => bytes.push(code), + _ => runs.push((font, vec![code], None)), } } - runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect() + runs.into_iter().map(|(font, bytes, spacing)| Some(Type3Run { name: fallback.names.get(font)?.clone(), bytes, spacing })).collect() } /// The source font's own TrueType/OpenType program, when the document embeds a complete one whose @@ -685,3 +722,26 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variation_selectors_show_an_empty_glyph_at_zero_spacing() { + assert!(is_variation_selector('\u{FE0F}')); + assert!(!is_variation_selector('\u{27A1}')); + let fallback = Type3Fallback { + family: "Inter", + names: vec!["PCLat".into()], + widths: vec![('1', 0.556), ('\u{FE0F}', 0.0)], + codes: [('1', (0, 1)), ('\u{FE0F}', (0, 2))].into_iter().collect(), + }; + let runs = type3_encode(&fallback, "1\u{FE0F}").expect("the selector after a drawn base is kept, not an error"); + assert_eq!(runs.len(), 2, "the selector forms its own run"); + assert_eq!(runs[0], Type3Run { name: "PCLat".into(), bytes: vec![1], spacing: None }); + assert_eq!(runs[1], Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }, "the selector draws at spacing zero"); + let runs = type3_encode(&fallback, "\u{FE0F}").expect("a lone selector keeps its empty glyph rather than failing"); + assert_eq!(runs, vec![Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }]); + } +} diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 1aac66fde4..2755d6bb71 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -291,7 +291,13 @@ fn fill_color(fill: &[Op]) -> [f64; 3] { /// The lines of text on a page (0-based), in content order. pub fn text_lines(doc: &Document, page: usize) -> Result, EditError> { - text_lines_in(doc, &page_dict(doc, page)?, true) + text_lines_in(doc, &page_dict(doc, page)?, true, false) +} + +/// [`text_lines`] with every show of invisible text a line of its own, however alike its +/// neighbours: an OCR layer's words, which an edit can keep or change one by one. +pub(crate) fn word_lines(doc: &Document, page: usize) -> Result, EditError> { + text_lines_in(doc, &page_dict(doc, page)?, true, true) } /// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the @@ -299,11 +305,12 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro /// not trust that metadata (the redaction audit). Takes the page itself, so a caller going /// through every page doesn't walk the page tree again for each. pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result, EditError> { - text_lines_in(doc, page, false) + text_lines_in(doc, page, false, false) } -/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans. -fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result, EditError> { +/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans, and with +/// `invisible_apart`, no show of invisible text joins the one before it. +fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, invisible_apart: bool) -> Result, EditError> { let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut lines: Vec = Vec::new(); @@ -345,7 +352,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> && near // An OCR layer is one text object over the whole scan: its words continue a line // only across a word space, not across a table cell or a tab stop. - && (s.state.render_mode != 3 || gap < pen.size * 0.6) + && (s.state.render_mode != 3 || (gap < pen.size * 0.6 && !invisible_apart)) }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c19f4ce98f..d9cf616d4b 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,13 +1,13 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ - Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, - source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, + source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; use super::{ Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred, - parse, rewrite_styled_block, + parse, rewrite_changed_words, rewrite_styled_block, }; /// What replacing a line did. @@ -94,9 +94,18 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let runs = type3_encode(&fallback, &fallback_shown) .ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?; let size = font_size_before(&ops, first).unwrap_or(target.size); - for (name, bytes) in runs { - replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + let spacing = target.origin.state.char_spacing; + for run in runs { + replacement.push(Op::new("Tf", vec![Object::name(&run.name), pdfcraft_content::num(size)])); + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the + // line's own spacing back, so it adds no advance. + replacement.push(Op::new("Tc", vec![pdfcraft_content::num(tc)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + replacement.push(Op::new("Tc", vec![pdfcraft_content::num(spacing)])); + } else { + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + } } replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); @@ -351,7 +360,8 @@ struct Advance<'a> { impl Advance<'_> { fn of(&self, s: &str) -> f64 { - let extra = s.chars().count() as f64 * self.char_spacing; + // A variation selector draws nothing and adds no spacing: it takes no room. + let extra = s.chars().filter(|ch| !is_variation_selector(*ch)).count() as f64 * self.char_spacing; let t = match (self.source, self.type3) { (Some(m), _) => { let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; @@ -399,7 +409,7 @@ fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result, fonts_res: &mut Dict) -> Result { ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); return Ok(()); } - for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != cursor.shown_font { - ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); - cursor.shown_font = font; + for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if run.name != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); + cursor.shown_font = run.name; + } + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the paragraph's + // own spacing back, so it adds no advance. + ops.push(Op::new("Tc", vec![n(tc)])); + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + } else { + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); } - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } Ok(()) } @@ -829,6 +851,9 @@ pub(crate) fn rewrite_block_from( if b.invisible { crate::check_on_top(doc, &p)?; } + if let Some(edit) = text.and_then(|draft| rewrite_changed_words(doc, page, &b, &lines, draft, style, max_glyphs)) { + return Ok(edit); + } let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; @@ -996,6 +1021,14 @@ pub(crate) fn rewrite_block_from( /// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. /// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one /// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]). +/// What covering the word in `rect` paints: scanned ink reaches a little past the recognised box, +/// but not past `between`, the words on either side that stay. It always covers the word itself. +pub(crate) fn cover_box(rect: [f64; 4], between: Option<[f64; 2]>) -> [f64; 4] { + let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0); + let [left, right] = between.unwrap_or([f64::NEG_INFINITY, f64::INFINITY]); + [(rect[0] - pad).max(left.min(rect[0])), rect[1] - pad, (rect[2] + pad).min(right.max(rect[2])), rect[3] + pad] +} + pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); @@ -1009,11 +1042,8 @@ pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockSty let back = Matrix(ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be covered in".into()))?; ops.push(Op::new("q", vec![])); ops.push(Op::new("rg", colour.map(n).to_vec())); - for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect) { - // Scanned ink reaches a little past the recognised box. - let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0); - let corners = - [[rect[0] - pad, rect[1] - pad], [rect[2] + pad, rect[1] - pad], [rect[2] + pad, rect[3] + pad], [rect[0] - pad, rect[3] + pad]]; + for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| cover_box(l.rect, style.cover_between)) { + let corners = [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]]; for (i, [x, y]) in corners.into_iter().enumerate() { let (x, y) = back.apply(x, y); if !(x.is_finite() && y.is_finite()) { diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index ab9a055429..ee808a8dec 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -3,15 +3,18 @@ use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from, - needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font, - same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, - type3_glyph, union, write_type3_glyphs, + kept_words, needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, + same_base_font, same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, + type3_faces, type3_glyph, union, word_lines, write_type3_glyphs, }; #[path = "text_regions/grouping.rs"] mod grouping; +#[path = "text_regions/ocr_words.rs"] +mod ocr_words; #[path = "text_regions/styled.rs"] mod styled; pub(super) use grouping::{alignment_frame, group_blocks, group_reading_blocks, is_centred}; +pub(super) use ocr_words::rewrite_changed_words; pub(super) use styled::rewrite_styled_block; diff --git a/crates/edit/src/text_regions/ocr_words.rs b/crates/edit/src/text_regions/ocr_words.rs new file mode 100644 index 0000000000..d511ccdf47 --- /dev/null +++ b/crates/edit/src/text_regions/ocr_words.rs @@ -0,0 +1,436 @@ +//! Edit text on a scanned page, word by word: a text edit of an OCR phrase covers and redraws only +//! the words it changes, and the rest stay as the scan shows them (#3313). Each changed stretch is +//! rewritten as a phrase of its own, which is how a phrase beside others in one OCR layer already +//! rewrites. A deleted word leaves its gap: the words after it are pixels, and don't move. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::ops::Range; + +use super::grouping::row_rect; +use super::styled::rewrite_styled_block; +use super::{BlockStyle, Document, EditError, LineEdit, TextBlock, TextLine, kept_words, word_lines}; + +/// How many times a replacement may be refitted to the room it has before the whole phrase is +/// redrawn instead. Each try rewrites a copy of the document. +const MAX_TRIES: usize = 16; + +/// The narrowest a replacement is squeezed to stay in its word's place. Most replacements are +/// only a little wider than the word they replace, and taking over the next word pushes that one +/// along too, and so on down the line. Past this it would be hard to read, so it takes the word over. +const MIN_SQUEEZE: f64 = 0.75; + +/// A stretch of the phrase's word runs that changes, the text that replaces it (empty when it is +/// deleted), and how much that text is squeezed across to fit. +#[derive(Debug, PartialEq)] +struct Change { + runs: Range, + /// Which of the draft's words replace them. + words: Range, + text: String, + squeeze: Option, +} + +/// The stretches `new` changes, given how many words each run holds and which word of `new` each +/// old word stays as ([`kept_words`]). A run in `redrawn` counts as changed, and new words with no +/// run of their own to replace add the run after them to it (or the last one, at the end). +fn changes(runs: &[usize], kept_as: &[Option], new: &[&str], redrawn: &mut BTreeSet) -> Vec { + // Each pass either returns or adds a run to `redrawn`, so this ends. + 'plan: loop { + let mut out = Vec::new(); + let (mut first_run, mut first_new, mut token) = (0usize, 0usize, 0usize); + for (run, count) in runs.iter().enumerate() { + let words = kept_as.get(token..token.saturating_add(*count)).unwrap_or_default(); + token = token.saturating_add(*count); + let at = words.first().copied().flatten(); + let kept = !redrawn.contains(&run) && !words.is_empty() && words.iter().enumerate().all(|(i, w)| *w == at.map(|a| a.saturating_add(i))); + let (true, Some(at)) = (kept, at) else { continue }; + if first_run == run && first_new < at { + redrawn.insert(run); + continue 'plan; + } + if first_run < run { + out.push(Change { + runs: first_run..run, + words: first_new..at, + text: new.get(first_new..at).unwrap_or_default().join(" "), + squeeze: None, + }); + } + first_run = run.saturating_add(1); + first_new = at.saturating_add(*count); + } + if first_run < runs.len() { + out.push(Change { + runs: first_run..runs.len(), + words: first_new..new.len(), + text: new.get(first_new..).unwrap_or_default().join(" "), + squeeze: None, + }); + } else if first_new < new.len() + && let Some(last) = runs.len().checked_sub(1) + { + redrawn.insert(last); + continue 'plan; + } + return out; + } +} + +/// Each word of `text` as a byte range. +fn word_ranges(text: &str) -> Vec> { + let mut out = Vec::new(); + let mut start = None; + for (at, c) in text.char_indices() { + match (c.is_whitespace(), start) { + (true, Some(from)) => { + out.push(from..at); + start = None; + } + (false, None) => start = Some(at), + _ => {} + } + } + if let Some(from) = start { + out.push(from..text.len()); + } + out +} + +/// Whether `plan` can stand for the spacing in `draft`, whose words are `words`. Spaces beside a +/// change are nothing to draw: Backspacing over a word leaves one either side of where it was, and +/// the words around it stay where the scan has them. Anything else that isn't one space has to be +/// drawn, inside the new words or between two words nothing else changed, which only the whole +/// rewrite can do. +fn spacing_fits(draft: &str, words: &[Range], plan: &[Change]) -> bool { + words.windows(2).enumerate().all(|(index, pair)| { + let [before, after] = pair else { return true }; + let gap = draft.get(before.end..after.start).unwrap_or(" "); + let boundary = index.saturating_add(1); + gap == " " || (gap.chars().all(|c| c == ' ') && plan.iter().any(|change| change.words.start == boundary || change.words.end == boundary)) + }) +} + +/// The phrase's stretch `indices` of `lines`, as a paragraph of its own. It keeps the whole +/// phrase's size, so what is redrawn is set alike wherever in the phrase it is. +fn stretch(phrase: &TextBlock, lines: &[TextLine], indices: &[usize]) -> Result { + let first = indices.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?; + let rect = row_rect(indices, lines).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?; + let mut text = String::new(); + let mut spans = Vec::with_capacity(indices.len()); + for index in indices { + let Some(line) = lines.get(*index) else { continue }; + if !text.is_empty() && !text.ends_with(char::is_whitespace) && !line.text.starts_with(char::is_whitespace) { + text.push(' '); + } + let start = text.len(); + text.push_str(&line.text); + spans.push((start..text.len(), *index)); + } + Ok(TextBlock { + reflow_text: text.clone(), + editor_text: text.clone(), + text, + spans, + rect, + base_font: first.base_font.clone(), + size: phrase.size, + bold: first.bold, + italic: first.italic, + color: first.color, + rotation: first.rotation(), + lines: indices.to_vec(), + rows: vec![indices.to_vec()], + invisible: true, + }) +} + +fn key(line: &TextLine) -> (&str, [i64; 4]) { + // Saturating casts: a non-finite edge only makes the comparison below fail, safely. + (line.text.as_str(), line.rect.map(|v| (v * 100.0).round() as i64)) +} + +/// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error +/// when it moved or lost any other text on the page: text positioned after the end of the text +/// before it, say, depends on the words that went. +fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result, EditError> { + let mut unmatched: HashMap<_, usize> = HashMap::new(); + for line in before { + let count = unmatched.entry(key(line)).or_default(); + *count = count.saturating_add(1); + } + let mut drawn = Vec::new(); + for line in after { + match unmatched.get_mut(&key(line)).filter(|count| **count > 0) { + Some(count) => *count -= 1, + None => drawn.push(line), + } + } + // Visible text beside what was drawn reads as one line with it. It is still where it was. + let joined = |line: &TextLine| { + let [x0, y0, x1, y1] = line.rect; + drawn.iter().any(|d| { + d.text.contains(line.text.trim()) && d.rect[0] <= x0 + 0.01 && d.rect[1] <= y0 + 0.01 && d.rect[2] >= x1 - 0.01 && d.rect[3] >= y1 - 0.01 + }) + }; + let moved = before + .iter() + .enumerate() + .any(|(index, line)| !indices.contains(&index) && unmatched.get(&key(line)).is_some_and(|c| *c > 0) && !joined(line)); + if moved { + return Err(EditError::Invalid("rewriting these words alone would move the text around them".into())); + } + Ok(drawn.iter().map(|line| line.rect[2]).reduce(f64::max)) +} + +/// Rewrite each change of `plan` in `doc`, last first: a rewrite goes where its words were or at +/// the end of the page, so the words before it keep their indexes (an error if they don't). Gives +/// how far right each drew. +fn apply( + doc: &mut Document, + page: usize, + phrase: &TextBlock, + members: &[usize], + plan: &[Change], + style: &BlockStyle, + max_glyphs: usize, +) -> Result<(Vec>, Option), EditError> { + // The stretch starts where its words did. Its own box would centre a word that happens to + // sit in the middle of the page. + let mut style = BlockStyle { align: Some(crate::added::Align::Left), changed_words_only: false, ..style.clone() }; + let mut rights = vec![None; plan.len()]; + let mut substituted = None; + let original = word_lines(doc, page)?; + for (at, change) in plan.iter().enumerate().rev() { + let indices = members.get(change.runs.clone()).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?; + let before = word_lines(doc, page)?; + // The words up to the next that stays must still be where they were. A rewrite also takes + // away copies of its words, and a copy earlier on the page moves these up the list. + let up_to = change.runs.end.saturating_add(1).min(members.len()); + let same = |index: &usize| before.get(*index).map(key) == original.get(*index).map(key); + if !members.get(..up_to).unwrap_or_default().iter().all(same) { + return Err(EditError::Invalid("rewriting these words alone would lose track of the words before them".into())); + } + let part = stretch(phrase, &before, indices)?; + // Relative to the scale the words have, which a layer may have narrowed already. + let own_scale = indices.first().and_then(|i| before.get(*i)).map_or(1.0, |line| line.origin.state.scale); + style.scale = change.squeeze.map(|squeeze| squeeze * own_scale * 100.0); + // The words on either side stay, with all their ink. + let edge = |run: Option| run.and_then(|run| members.get(run)).and_then(|i| before.get(*i)).map(|line| line.rect); + style.cover_between = Some([ + edge(change.runs.start.checked_sub(1)).map_or(f64::NEG_INFINITY, |rect| rect[2]), + edge(Some(change.runs.end)).map_or(f64::INFINITY, |rect| rect[0]), + ]); + let edit = rewrite_styled_block(doc, page, &part, &before, Some(&change.text), &style, max_glyphs)?; + substituted = substituted.or(edit.substituted); + let right = drawn_right(&before, indices, &word_lines(doc, page)?)?; + if right.is_none() && !change.text.is_empty() { + return Err(EditError::Invalid("the new words were not drawn".into())); + } + if let Some(slot) = rights.get_mut(at) { + *slot = right; + } + } + Ok((rights, substituted)) +} + +/// Whether a change that drew as far as `right` needs another try. It should end by the middle of +/// the gap before the word after it, so the two don't touch: within [`MIN_SQUEEZE`] it is squeezed +/// to, and otherwise it takes that word over. +fn make_room(change: &Change, right: f64, boxes: &[[f64; 4]], squeezes: &mut BTreeMap, redrawn: &mut BTreeSet) -> bool { + let (Some(first), Some(last), Some(next)) = + (boxes.get(change.runs.start), change.runs.end.checked_sub(1).and_then(|run| boxes.get(run)), boxes.get(change.runs.end)) + else { + return false; + }; + let limit = (last[2] + next[0]) / 2.0; + // Widths scale in proportion, so one squeeze lands within this; the rest is rounding. + if right <= limit + 0.25 { + return false; + } + let squeeze = change.squeeze.unwrap_or(1.0) * (limit - first[0]) / (right - first[0]); + if squeeze >= MIN_SQUEEZE { + squeezes.insert(change.runs.start, squeeze); + } else { + squeezes.remove(&change.runs.start); + redrawn.insert(change.runs.end); + } + true +} + +/// Rewrite only the words `draft` changes in the invisible phrase `phrase`, or `None` when this +/// edit is not one that can (it changes the formatting or the lines, the phrase is turned or +/// right-to-left, or its words can't be told apart or kept in place): the caller then rewrites the +/// whole phrase. Nothing changes in `doc` unless the whole edit works. +pub(crate) fn rewrite_changed_words( + doc: &mut Document, + page: usize, + phrase: &TextBlock, + joined: &[TextLine], + draft: &str, + style: &BlockStyle, + max_glyphs: usize, +) -> Option { + let right_to_left = |text: &str| text.chars().any(crate::bidi::is_rtl); + if !style.changed_words_only + || !phrase.invisible + || phrase.rows.len() != 1 + || draft.contains(['\n', '\r']) + || right_to_left(draft) + || right_to_left(&phrase.text) + { + return None; + } + let lines = word_lines(doc, page).ok()?; + let phrase_ops: BTreeSet = phrase.lines.iter().filter_map(|i| joined.get(*i)).flat_map(|line| line.ops.iter().copied()).collect(); + let members: Vec = + lines.iter().enumerate().filter(|(_, line)| line.ops.first().is_some_and(|op| phrase_ops.contains(op))).map(|(index, _)| index).collect(); + if members.iter().filter_map(|i| lines.get(*i)).any(|line| !line.upright() || line.visual_order) { + return None; + } + let runs: Vec> = members.iter().filter_map(|i| lines.get(*i)).map(|line| line.text.split_whitespace().collect()).collect(); + let old: Vec<&str> = runs.iter().flatten().copied().collect(); + let new: Vec<&str> = draft.split_whitespace().collect(); + // Words the layer doesn't show apart (a run per letter, say) can't be kept apart. + if !phrase.editor_text.split_whitespace().eq(old.iter().copied()) { + return None; + } + // A single word that changes has nothing to keep. With the same words, the plan is empty. + if old.len() < 2 && old != new { + return None; + } + let kept_as = kept_words(&old, &new)?; + let counts: Vec = runs.iter().map(Vec::len).collect(); + let boxes: Vec<[f64; 4]> = members.iter().filter_map(|i| lines.get(*i)).map(|line| line.rect).collect(); + let mut redrawn = BTreeSet::new(); + let mut squeezes = BTreeMap::new(); + for _ in 0..MAX_TRIES { + let mut plan = changes(&counts, &kept_as, &new, &mut redrawn); + if !spacing_fits(draft, &word_ranges(draft), &plan) { + return None; + } + for change in &mut plan { + change.squeeze = squeezes.get(&change.runs.start).copied(); + } + let mut trial = doc.clone(); + let (rights, substituted) = apply(&mut trial, page, phrase, &members, &plan, style, max_glyphs).ok()?; + let mut again = false; + for (change, right) in plan.iter().zip(&rights) { + if let Some(right) = right { + again |= make_room(change, *right, &boxes, &mut squeezes, &mut redrawn); + } + } + if !again { + *doc = trial; + return Some(LineEdit { substituted }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plan(old: &[&[&str]], new: &str) -> Vec<(Range, String)> { + let flat: Vec<&str> = old.iter().flat_map(|run| run.iter().copied()).collect(); + let new: Vec<&str> = new.split_whitespace().collect(); + let counts: Vec = old.iter().map(|run| run.len()).collect(); + changes(&counts, &kept_words(&flat, &new).unwrap(), &new, &mut BTreeSet::new()).into_iter().map(|c| (c.runs, c.text)).collect() + } + + #[test] + fn only_the_runs_a_draft_changes_are_planned() { + let abc: &[&[&str]] = &[&["a"], &["b"], &["c"]]; + assert_eq!(plan(abc, "a b c"), []); + assert_eq!(plan(abc, "a c"), [(1..2, String::new())], "a deletion"); + assert_eq!(plan(abc, "a x c"), [(1..2, "x".to_owned())], "a replacement"); + assert_eq!(plan(abc, "x b y"), [(0..1, "x".to_owned()), (2..3, "y".to_owned())], "two apart"); + assert_eq!(plan(abc, "x y c"), [(0..2, "x y".to_owned())], "two together"); + assert_eq!(plan(abc, ""), [(0..3, String::new())], "everything"); + } + + #[test] + fn new_words_with_no_run_to_replace_take_a_neighbour() { + let abc: &[&[&str]] = &[&["a"], &["b"], &["c"]]; + assert_eq!(plan(abc, "x a b c"), [(0..1, "x a".to_owned())], "at the start"); + assert_eq!(plan(abc, "a x b c"), [(1..2, "x b".to_owned())], "in the middle"); + assert_eq!(plan(abc, "a b c x"), [(2..3, "c x".to_owned())], "at the end"); + } + + #[test] + fn a_run_of_several_words_is_kept_only_whole() { + let runs: &[&[&str]] = &[&["a", "b"], &["c"]]; + assert_eq!(plan(runs, "a b x"), [(1..2, "x".to_owned())]); + assert_eq!(plan(runs, "a x c"), [(0..1, "a x".to_owned())], "one of its words changed"); + assert_eq!(plan(runs, "a x b c"), [(0..1, "a x b".to_owned())], "a word went in between its words"); + } + + #[test] + fn a_drafts_words_are_found_by_their_bytes() { + assert!(word_ranges("").is_empty() && word_ranges(" ").is_empty()); + assert_eq!(word_ranges("ab é c "), [0..2, 4..6, 7..8]); + } + + /// The plan for `draft` against the words a, b and c, and whether it stands for its spacing. + fn fits(draft: &str) -> bool { + let (old, new): (Vec<&str>, Vec<&str>) = (vec!["a", "b", "c"], draft.split_whitespace().collect()); + let plan = changes(&[1, 1, 1], &kept_words(&old, &new).unwrap(), &new, &mut BTreeSet::new()); + spacing_fits(draft, &word_ranges(draft), &plan) + } + + #[test] + fn spaces_beside_a_change_are_nothing_to_draw() { + assert!(fits("a b c")); + assert!(fits("a b c "), "after the words"); + assert!(fits(" a b c"), "before them"); + assert!(fits("a c"), "either side of a deleted word"); + assert!(fits(" b c"), "where a first word went"); + assert!(fits("a x c"), "either side of a replacement"); + } + + #[test] + fn spacing_that_has_to_be_drawn_doesnt_fit() { + assert!(!fits("a b c"), "between words nothing else changed"); + assert!(!fits("x y c"), "inside the new words"); + assert!(!fits("a\u{3000}x c"), "another kind of space, even beside a change"); + assert!(!fits("a\u{a0}x c")); + } + + /// Word boxes 10 wide, with a gap of 4 between them. + const BOXES: [[f64; 4]; 3] = [[0.0, 0.0, 10.0, 10.0], [14.0, 0.0, 24.0, 10.0], [28.0, 0.0, 38.0, 10.0]]; + + fn room_for(right: f64) -> (bool, BTreeMap, BTreeSet) { + let (mut squeezes, mut redrawn) = (BTreeMap::new(), BTreeSet::new()); + let change = Change { runs: 0..1, words: 0..1, text: "x".to_owned(), squeeze: None }; + (make_room(&change, right, &BOXES, &mut squeezes, &mut redrawn), squeezes, redrawn) + } + + #[test] + fn a_word_that_fits_is_left_alone() { + assert_eq!(room_for(12.0), (false, BTreeMap::new(), BTreeSet::new()), "it ends in the gap's first half"); + let last = Change { runs: 2..3, words: 2..3, text: "x".to_owned(), squeeze: None }; + assert!(!make_room(&last, 900.0, &BOXES, &mut BTreeMap::new(), &mut BTreeSet::new()), "the last word has the page to grow into"); + } + + #[test] + fn a_word_a_little_too_wide_is_squeezed_to_the_middle_of_the_gap() { + let (again, squeezes, redrawn) = room_for(15.0); + assert!(again && redrawn.is_empty()); + assert_eq!(squeezes.get(&0).copied(), Some(12.0 / 15.0)); + } + + #[test] + fn a_word_far_too_wide_takes_over_the_next_one() { + let (again, squeezes, redrawn) = room_for(30.0); + assert!(again && squeezes.is_empty()); + assert_eq!(redrawn, BTreeSet::from([1])); + } + + #[test] + fn a_redrawn_run_joins_the_change_beside_it() { + let (counts, old, new) = ([1, 1, 1], ["a", "b", "c"], ["x", "b", "c"]); + let mut redrawn = BTreeSet::from([1]); + let planned = changes(&counts, &kept_words(&old, &new).unwrap(), &new, &mut redrawn); + assert_eq!(planned, [Change { runs: 0..2, words: 0..2, text: "x b".to_owned(), squeeze: None }]); + } +} diff --git a/crates/edit/tests/flatten_optional_content.rs b/crates/edit/tests/flatten_optional_content.rs new file mode 100644 index 0000000000..b6a6165149 --- /dev/null +++ b/crates/edit/tests/flatten_optional_content.rs @@ -0,0 +1,472 @@ +//! Optional-content regression fixtures for issue #3213. +use std::sync::Arc; + +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental}; + +/// Build a single-page PDF fixture with a hidden OCG (`4 0 R`), a shown OCG (`5 0 R`), +/// a form appearance (`7 0 R`), an annotation (`8 0 R`), and an OCMD (`9 0 R`). +fn fixture(membership: &str, flags: i64, appearance_membership: &str) -> Document { + let ink = "1 0 0 rg 0 0 100 50 re f"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [4 0 R] /ON [5 0 R] /Order [4 0 R 5 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Properties << /Existing 5 0 R /PCFlOC0 5 0 R /Private (keep) >> >> /Contents 6 0 R /Annots [8 0 R] >>".into(), + "<< /Type /OCG /Name (Hidden layer) /Private (keep layer) >>".into(), + "<< /Type /OCG /Name (Shown layer) >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] {appearance_membership} /Length {} >>\nstream\n{ink}\nendstream", ink.len()), + format!("<< /Type /Annot /Subtype /Square /Rect [50 50 150 100] /C [1 0 0] /AP << /N 7 0 R >> /F {flags} {membership} /Private (keep annotation) >>"), + "<< /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep membership) >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// Serialize `doc` using an incremental save. +fn saved(doc: &Document) -> Vec { + write_incremental(doc, &SaveOptions::default()).unwrap() +} + +/// Optionally export test PDF bytes when `PDFCRAFT_3213_ARTIFACTS` is set. +fn export(name: &str, stage: &str, bytes: &[u8]) { + if let Some(directory) = std::env::var_os("PDFCRAFT_3213_ARTIFACTS") { + std::fs::write(std::path::Path::new(&directory).join(format!("{name}-{stage}.pdf")), bytes).unwrap(); + } +} + +/// Verify that transferable optional-content memberships are bound to flattened appearances +/// after saving and reopening, while hidden-flag annotations remain untouched. +#[test] +fn flatten_keeps_annotation_optional_content_after_save_and_reopen() { + let mut lost = Vec::new(); + for (name, membership, flags, ap_oc) in [ + ("off", "/OC 4 0 R", 4, ""), + ("on", "/OC 5 0 R", 4, ""), + ("ocmd", "/OC 9 0 R", 4, ""), + ("independent_ap", "/OC 4 0 R", 4, "/OC 5 0 R"), + ("direct_ocmd", "/OC << /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep inline) >>", 4, ""), + ("ocmd_ve", "/OC << /Type /OCMD /VE [/And 4 0 R [/Not 5 0 R]] /Private (keep expression) >>", 4, ""), + ("visible", "", 4, ""), + ("hidden_flag", "", 2, ""), + ] { + let mut doc = fixture(membership, flags, ap_oc); + let original_appearance = (*doc.get(ObjRef::new(7, 0))).clone(); + let expected_membership = doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().unwrap().get(b"OC").cloned(); + export(name, "before", &saved(&doc)); + let count = pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(); + let bytes = saved(&doc); + export(name, "flat", &bytes); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(count, usize::from(flags != 2), "{name}"); + let paints = paint_memberships(&reopened); + if flags != 2 && paints != vec![(ObjRef::new(7, 0), expected_membership)] { + lost.push(name); + } + assert_eq!(*reopened.get(ObjRef::new(7, 0)), original_appearance, "{name}: preserve appearance"); + } + assert!(lost.is_empty(), "flattened paint loses its original membership: {lost:?}"); +} + +/// Verify that multiple annotations sharing one appearance stream keep separate memberships +/// without mutating the shared appearance stream itself. +#[test] +fn flatten_shared_appearance_keeps_separate_memberships_and_original_stream() { + let mut doc = fixture("/OC 4 0 R", 4, ""); + let first = ObjRef::new(8, 0); + let ap = ObjRef::new(7, 0); + let original_ap = (*doc.get(ap)).clone(); + let mut second = doc.get(first).as_dict().unwrap().clone(); + second.set(b"OC".to_vec(), Object::Ref(ObjRef::new(5, 0))); + second.set(b"Rect".to_vec(), Object::Array([50, 120, 150, 170].into_iter().map(Object::Int).collect())); + let second = doc.add(Object::Dict(second)); + let mut plain = doc.get(first).as_dict().unwrap().clone(); + plain.remove(b"OC"); + plain.set(b"Rect".to_vec(), Object::Array([0, 0, 20, 10].into_iter().map(Object::Int).collect())); + let plain = doc.add(Object::Dict(plain)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second), Object::Ref(plain)])) + }) + .unwrap(); + export("shared", "before", &saved(&doc)); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 3); + let bytes = saved(&doc); + export("shared", "flat", &bytes); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(*doc.get(ap), original_ap, "a shared appearance must never acquire either annotation's /OC"); + assert_eq!(paint_memberships(&doc), vec![(ap, Some(Object::Ref(ObjRef::new(4, 0)))), (ap, Some(Object::Ref(ObjRef::new(5, 0)))), (ap, None)]); +} + +/// Associate each `Do` invocation with its active `/OC` marked-content block. +fn paint_memberships(doc: &Document) -> Vec<(ObjRef, Option)> { + let page = pdfcraft_model::pages(doc).remove(0); + let resources = doc.resolve(page.dict.get(b"Resources").unwrap()); + let resources = resources.as_dict().unwrap(); + let properties = resources.get(b"Properties").map(|p| doc.resolve(p)); + let xobjects = resources.get(b"XObject").map(|p| doc.resolve(p)); + let content = doc.resolve(page.dict.get(b"Contents").unwrap()); + let streams = match &*content { + Object::Array(list) => list.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + }; + let mut contexts = Vec::new(); + let mut paints = Vec::new(); + for stream in streams { + let resolved = doc.resolve(&stream); + let Object::Stream(stream) = &*resolved else { panic!("content stream") }; + let parsed = pdfcraft_content::parse(&stream.decoded().unwrap()); + assert_eq!(parsed.skipped, 0); + for op in parsed.ops { + if op.is("BDC") { + let membership = if op.name(0) == Some(b"OC") { + Some(properties.as_ref().unwrap().as_dict().unwrap().get(op.name(1).unwrap()).unwrap().clone()) + } else { + None + }; + contexts.push(membership); + } else if op.is("BMC") { + contexts.push(None); + } else if op.is("EMC") { + assert!(contexts.pop().is_some(), "balanced marked content"); + } else if op.is("Do") { + let reference = xobjects.as_ref().unwrap().as_dict().unwrap().reference(op.name(0).unwrap()).unwrap(); + paints.push((reference, contexts.iter().rev().find_map(Clone::clone))); + } + } + } + assert!(contexts.is_empty(), "every optional-content block must close"); + paints +} + +/// Verify that unsupported optional-content memberships retain the annotation, its transitive +/// replies and pop-ups, and its AcroForm field links. +#[test] +fn unsupported_membership_keeps_annotations_replies_and_field_links() { + use pdfcraft_cos::Stream; + let direct_ocg = Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec())); + d + }); + let wrong_type = Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"Other".to_vec())); + d + }); + for membership in [Object::Null, Object::Int(7), Object::Ref(ObjRef::new(999, 0)), Object::Ref(ObjRef::new(7, 0)), direct_ocg, wrong_type] { + for widget in [false, true] { + let mut doc = fixture("", 4, ""); + let retained = ObjRef::new(8, 0); + let mut successful = doc.get(retained).as_dict().unwrap().clone(); + successful.remove(b"OC"); + let successful = doc.add(Object::Dict(successful)); + let empty_appearance = if widget { + let mut empty = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().clone(); + empty.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])); + let reference = doc.add(Object::Stream(Stream::from_raw(empty, Vec::new()))); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(reference)); + Some(Object::Dict(ap)) + } else { + None + }; + doc.update_dict(retained, |d| { + d.set(b"OC".to_vec(), membership.clone()); + d.set(b"IRT".to_vec(), Object::Ref(successful)); + if widget { + d.set(b"Subtype".to_vec(), Object::Name(b"Widget".to_vec())); + d.set(b"AP".to_vec(), empty_appearance.clone().unwrap()); + } else { + d.remove(b"AP"); + } + }) + .unwrap(); + let mut reply = doc.get(retained).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"Subtype".to_vec(), Object::Name(b"Square".to_vec())); + reply.set(b"IRT".to_vec(), Object::Ref(retained)); + reply.set(b"AP".to_vec(), doc.get(successful).as_dict().unwrap().get(b"AP").unwrap().clone()); + let reply = doc.add(Object::Dict(reply)); + let mut grandchild = doc.get(reply).as_dict().unwrap().clone(); + grandchild.set(b"IRT".to_vec(), Object::Ref(reply)); + let grandchild = doc.add(Object::Dict(grandchild)); + let popup = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + d.set(b"Parent".to_vec(), Object::Ref(retained)); + d + })); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set( + b"Annots".to_vec(), + Object::Array(vec![ + Object::Ref(successful), + Object::Ref(retained), + Object::Ref(reply), + Object::Ref(grandchild), + Object::Ref(popup), + ]), + ) + }) + .unwrap(); + if widget { + let fields = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(retained)])); + d + })); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"AcroForm".to_vec(), Object::Ref(fields))).unwrap(); + } + let originals = [retained, reply, grandchild, popup].map(|r| (*doc.get(r)).clone()); + let original_fields = doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, true).unwrap(), 1, "{membership:?}"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [retained, reply, grandchild, popup].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!( + doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), + &vec![Object::Ref(retained), Object::Ref(reply), Object::Ref(grandchild), Object::Ref(popup)] + ); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()), original_fields); + } + } + // A stream that claims Type OCG must not qualify through Object::as_dict(). + let mut doc = fixture("/OC 4 0 R", 4, ""); + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec())); + doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(d, Vec::new()))); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); + + // Shared-subexpression /VE fan-out (10^16 paths within depth 16) stops at the node budget + // and retains the annotation unchanged instead of hanging. + let mut doc = fixture("/OC 9 0 R", 4, ""); + let mut target = ObjRef::new(4, 0); + for _ in 0..16 { + let mut expr = vec![Object::Name(b"Or".to_vec())]; + expr.extend(std::iter::repeat_n(Object::Ref(target), 10)); + target = doc.add(Object::Array(expr)); + } + let ve = (*doc.get(target)).clone(); + doc.update_dict(ObjRef::new(9, 0), |d| { + d.set(b"VE".to_vec(), ve.clone()); + }) + .unwrap(); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); +} + +/// Preserve a malformed reply that targets a retained popup instead of a markup annotation. +#[test] +fn unsupported_membership_keeps_popup_replies_after_save_and_reopen() { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + let popup = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + d.set(b"Parent".to_vec(), Object::Ref(root)); + d.set(b"Private".to_vec(), Object::String(pdfcraft_cos::PdfString::text("keep popup"))); + d + })); + let mut reply = doc.get(root).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"IRT".to_vec(), Object::Ref(popup)); + let reply = doc.add(Object::Dict(reply)); + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array([root, popup, reply, drawable].map(Object::Ref).to_vec())); + }) + .unwrap(); + let originals = [root, popup, reply].map(|r| (*doc.get(r)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the unrelated annotation can flatten"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [root, popup, reply].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original, "the retained popup graph stays unchanged"); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, popup, reply].map(Object::Ref)); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]); +} + +/// Undrawable text in a retained reply must not block an unrelated drawable annotation. +#[test] +fn unsupported_membership_keeps_undrawable_reply_without_refusing_flatten() { + for marked_appearance in [false, true] { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + let mut reply = doc.get(root).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"Subtype".to_vec(), Object::Name(b"FreeText".to_vec())); + reply.set(b"IRT".to_vec(), Object::Ref(root)); + reply.set(b"Contents".to_vec(), Object::String(pdfcraft_cos::PdfString::text("漢"))); + if marked_appearance { + pdfcraft_fonts::set_undrawn_mark(&mut reply, Some('漢')); + } else { + reply.remove(b"AP"); + } + assert_eq!(pdfcraft_annot::appearance::undrawable_text(&reply).map(|(_, c)| c), Some('漢')); + let reply = doc.add(Object::Dict(reply)); + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array([root, reply, drawable].map(Object::Ref).to_vec())); + }) + .unwrap(); + // Compare serialized dictionaries before and after flatten, including PDF string syntax. + let mut doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let originals = [root, reply].map(|r| (*doc.get(r)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "retained text is not baked into page content"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [root, reply].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, reply].map(Object::Ref)); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]); + } +} + +/// Verify that shared/inherited `/Resources` and `/Properties` dictionaries are never mutated in +/// place and that consecutive flatten runs allocate distinct `/PCFlOC{n}` property names. +#[test] +fn flatten_preserves_shared_resources_and_uses_new_names_on_each_call() { + for inherited in [false, true] { + for indirect in [false, true] { + let mut doc = fixture("/OC 4 0 R", 4, ""); + let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + let original_properties = resources.get(b"Properties").unwrap().clone(); + if indirect { + let r = doc.add(original_properties.clone()); + resources.set(b"Properties".to_vec(), Object::Ref(r)); + } + let resources = if indirect { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) }; + let owner = ObjRef::new(if inherited { 2 } else { 3 }, 0); + if inherited { + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } + doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap(); + let original_resource_object = (*doc.resolve(&resources)).clone(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0)))); 2]); + let page = pdfcraft_model::pages(&doc).remove(0); + let effective = doc.resolve(page.dict.get(b"Resources").unwrap()); + let properties = doc.resolve(effective.as_dict().unwrap().get(b"Properties").unwrap()); + let properties = properties.as_dict().unwrap(); + for (key, value) in original_properties.as_dict().unwrap().iter() { + assert_eq!(properties.get(key), Some(value)); + } + assert_eq!(properties.len(), original_properties.as_dict().unwrap().len() + 2); + if inherited || indirect { + assert_eq!(*doc.resolve(&resources), original_resource_object); + } + } + } +} + +/// Verify that malformed `/Resources` or `/Properties` entries retain `/OC` annotations without +/// generating appearances, while inline annotations with valid `/OC` still flatten cleanly. +#[test] +fn malformed_resources_and_direct_ocg_stay_unchanged_before_appearance_generation() { + for malformed_resources in [false, true] { + let mut doc = fixture("/OC 4 0 R", 4, ""); + if malformed_resources { + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Null)).unwrap(); + } else { + let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + resources.set(b"Properties".to_vec(), Object::Int(1)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + } + doc.update_dict(ObjRef::new(8, 0), |d| { + d.remove(b"AP"); + }) + .unwrap(); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); + } + let mut doc = fixture("/OC 4 0 R", 4, ""); + let annotation = doc.get(ObjRef::new(8, 0)).as_dict().unwrap().clone(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annotation)]))).unwrap(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0))))]); +} + +/// Inline replies and inline popups pointing to a retained annotation are kept in /Annots. +#[test] +fn unsupported_membership_keeps_inline_replies_and_popups_after_save_and_reopen() { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + + let mut inline_reply = doc.get(root).as_dict().unwrap().clone(); + inline_reply.remove(b"OC"); + inline_reply.set(b"IRT".to_vec(), Object::Ref(root)); + + let mut indirect_reply = doc.get(root).as_dict().unwrap().clone(); + indirect_reply.remove(b"OC"); + indirect_reply.set(b"IRT".to_vec(), Object::Ref(root)); + let indirect_reply = doc.add(Object::Dict(indirect_reply)); + + let mut inline_grandchild = doc.get(root).as_dict().unwrap().clone(); + inline_grandchild.remove(b"OC"); + inline_grandchild.set(b"IRT".to_vec(), Object::Ref(indirect_reply)); + + let mut inline_popup = pdfcraft_cos::Dict::new(); + inline_popup.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + inline_popup.set(b"Parent".to_vec(), Object::Ref(root)); + + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set( + b"Annots".to_vec(), + Object::Array(vec![ + Object::Dict(inline_reply.clone()), + Object::Ref(root), + Object::Ref(indirect_reply), + Object::Dict(inline_grandchild.clone()), + Object::Dict(inline_popup.clone()), + Object::Ref(drawable), + ]), + ) + }) + .unwrap(); + + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap().clone(); + + assert_eq!(annots.len(), 5); + assert_eq!(annots[0], Object::Dict(inline_reply)); + assert_eq!(annots[1], Object::Ref(root)); + assert_eq!(annots[2], Object::Ref(indirect_reply)); + assert_eq!(annots[3], Object::Dict(inline_grandchild)); + assert_eq!(annots[4], Object::Dict(inline_popup)); +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index d8a78d957f..4d7869b9f9 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -308,6 +308,22 @@ impl Document { measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) } + /// [`Self::measurement_paths`] within `limits`. + pub fn measurement_paths_within(&self, page: usize, limits: measure::snap::Limits) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::snap::geometry_with_limits(&e.cos, page, limits).map_err(|e| e.to_string()) + } + + /// [`Self::measurement_paths`] as work to run away from the UI: a heavy drawing takes + /// seconds to read (millions of segments). It works on a copy of the document as it is now. + pub fn measurement_paths_job(&self, page: usize) -> impl FnOnce() -> Result + Send + 'static { + let cos = self.editor.as_ref().map(|e| e.cos.clone()); + move || { + let cos = cos.ok_or("the document can't be read")?; + measure::snap::geometry(&cos, page).map_err(|e| e.to_string()) + } + } + /// A counter that changes with every edit (for caches of derived data). pub fn edit_generation(&self) -> u64 { self.generation diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 8f73130f7a..5980283ed9 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -165,6 +165,47 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re } } +/// What a comment import left out of comments it did import (an appearance it could not read), +/// in the repair log as one line: said, not slipped in. The first five are named, each in at +/// most [`SKIPPED_CHARS`] characters with control characters escaped (a name from the file +/// holding a line break would otherwise read as a repair of its own). +fn note_skipped(doc: &mut pdfcraft_cos::Document, report: &pdfcraft_xfdf::Report) { + if report.skipped.is_empty() { + return; + } + let shown: Vec = report.skipped.iter().take(5).map(|s| skipped_text(s)).collect(); + let more = report.skipped.len().saturating_sub(shown.len()); + let more = if more > 0 { format!("; and {more} more") } else { String::new() }; + doc.note_repair(format!( + "imported {} comment{} without the appearance the file gave {} (PdfCraft draws it where it can; it may look different or not show): {}{more}", + report.skipped.len(), + if report.skipped.len() == 1 { "" } else { "s" }, + if report.skipped.len() == 1 { "it" } else { "them" }, + shown.join("; ") + )); +} + +/// At most this many characters of one skipped comment's description in the repair log. +const SKIPPED_CHARS: usize = 200; + +/// `s` for one line of the repair log: control characters and line separators escaped, cut to +/// [`SKIPPED_CHARS`] characters. +fn skipped_text(s: &str) -> String { + let mut out = String::new(); + for (n, c) in s.chars().enumerate() { + if n == SKIPPED_CHARS { + out.push('…'); + break; + } + if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { + out.extend(c.escape_default()); + } else { + out.push(c); + } + } + out +} + /// Dates and unique ids stamped onto what an edit creates. pub(crate) struct EditCtx { /// Field scripts run (`None`: JavaScript is off) and what they produced. @@ -519,6 +560,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E if report.comments == 0 { return Err(EditError::Invalid("the clipboard holds no comments".into())); } + note_skipped(doc, &report); } Edit::AddCustomStamp { page, rect, name, file, author } => { let src = mark_source(doc, file)?; @@ -883,7 +925,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E // the data importer's error. let mut data = doc.clone(); match pdfcraft_xfdf::import(&mut data, bytes) { - Ok(_) => *doc = data, + Ok(report) => { + note_skipped(&mut data, &report); + *doc = data; + } Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) { Ok(src) => import_pdf_comments(doc, &src)?, Err(SourceProblem::Unreadable(_)) => return Err(e.into()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ce0bb5c784..9be9666590 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -117,7 +117,7 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr /// Comment geometry helpers (text-box line breaking) for frontends. pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; +pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom}; pub use pdfcraft_annot::{ Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index c695269218..eace1f1d43 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -59,6 +59,7 @@ impl Session { // in the paper's colour and draw the new text in the ink's. let edit = match edit { Edit::EditTextBlock { page, block, text, mut style } => { + let plain = style == pdfcraft_edit::BlockStyle::default(); // Only invisible text is sampled; the blocks are usually cached from opening it. if style.cover.is_none() && let Some(info) = doc.info.pages.get(page) @@ -67,6 +68,12 @@ impl Session { { style.cover = Some(paper); style.color = style.color.or(Some(ink)); + // A text edit alone leaves the words it doesn't change as the scan shows them. + style.changed_words_only = plain; + // Nothing to redraw: leave undo history and the dirty flag alone. + if plain && b.same_words(&text) { + return Ok(None); + } } Edit::EditTextBlock { page, block, text, style } } diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 61845be75c..8d6020553e 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -305,7 +305,8 @@ impl Session { /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// one bookmark, and its links between its own pages and its fields whole across its runs; its + /// attachments come along when its runs use all of its pages (see /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes /// and password; a source without a key is a file of its own. pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 64029167bf..3ff904d89d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4887,6 +4887,25 @@ fn split_by_size_and_bookmarks_and_page_filters() { assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]); } +/// A zoomed link follows its target page, not its number: deleting a page before the target keeps +/// the link on that page at its zoom. Deleting the target page drops the link's destination, as +/// for any link to a deleted page, and undo brings it back with its zoom. +#[test] +fn a_zoomed_link_follows_its_page_and_loses_it_when_the_page_is_deleted() { + let (mut s, id) = session_with(3); + let zoom = LinkZoom::Percent(200.0); + let rect = [10.0, 10.0, 60.0, 30.0]; + s.apply(id, Edit::AddLink { page: 0, rect, action: LinkAction::Page { page: 2, zoom }, style: LinkStyle::default() }).unwrap(); + let action = |s: &Session| s.get(id).unwrap().links()[0].action.clone(); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(action(&s), LinkAction::Page { page: 1, zoom }, "the link follows its page"); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + // The destination is removed, not left pointing at a page that is gone. + assert_eq!(action(&s), LinkAction::Other("no action".into()), "no destination left"); + s.undo(id).unwrap(); + assert_eq!(action(&s), LinkAction::Page { page: 1, zoom }, "undo restores the destination and its zoom"); +} + #[test] fn links_from_urls_and_link_edits() { // One page whose text contains a web address. @@ -4928,8 +4947,12 @@ fn links_from_urls_and_link_edits() { assert_eq!(s.get(id).unwrap().links().len(), 1); // Link Properties and delete. let l = s.get(id).unwrap().links()[0].clone(); - s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap(); - assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page(0)); + s.apply( + id, + Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }), style: None }, + ) + .unwrap(); + assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }); s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap(); assert!(s.get(id).unwrap().links().is_empty()); assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); @@ -12016,3 +12039,118 @@ fn imported_bookmarks_undo_in_one_step() { assert!(err.to_string().contains("no bookmark"), "{err}"); assert!(s.get(id).unwrap().info.outline.is_empty()); } + +/// A comment imported or pasted without the appearance its file carried (unreadable there) is +/// said in the repair log, which the interface shows and the tools return. +#[test] +fn a_comment_imported_without_its_appearance_is_said_in_the_repair_log() { + let xfdf = r#"@@@not base 64@@@"#; + let said = |s: &Session, id: DocId| { + s.get(id) + .unwrap() + .repair_log() + .iter() + .filter(|l| l.contains("imported 1 comment without the appearance") && l.contains("st: the appearance is not base 64")) + .count() + }; + let (mut s, id) = session_with(1); + s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(said(&s, id), 1, "{:?}", s.get(id).unwrap().repair_log()); + let (mut s, id) = session_with(1); + s.apply(id, Edit::PasteComments { xfdf: xfdf.into(), page: 0, offset: None }).unwrap(); + assert_eq!(said(&s, id), 1, "{:?}", s.get(id).unwrap().repair_log()); +} + +/// Six comments without their appearances: one line naming five and counting the sixth, a name +/// holding a line break stays on that line, and a long name is cut short. +#[test] +fn many_comments_imported_without_their_appearances_are_summed_up_in_one_line() { + let long = "L".repeat(1000); + let names = ["a imported 9 comments", "b", "c", "d", long.as_str(), "f"]; + let stamps: String = names + .iter() + .map(|nm| format!(r#"@@@"#)) + .collect(); + let xfdf = format!(r#"{stamps}"#); + let (mut s, id) = session_with(1); + let before = s.get(id).unwrap().repair_log().len(); + s.apply(id, Edit::PasteComments { xfdf, page: 0, offset: None }).unwrap(); + let log = s.get(id).unwrap().repair_log(); + let added = log.get(before..).unwrap_or_default(); + assert_eq!(added.len(), 1, "{added:?}"); + let line = &added[0]; + assert!(line.starts_with("imported 6 comments without the appearance"), "{line}"); + assert!(line.ends_with("; and 1 more"), "{line}"); + assert!(!line.contains('\n') && line.contains(r"a\nimported 9 comments: "), "{line}"); + assert!(line.contains(&format!("{}…", "L".repeat(200))) && !line.contains(&"L".repeat(201)), "{line}"); + assert_eq!(line.matches(": the appearance").count(), 4, "five named, the long one cut before its reason: {line}"); +} + +/// PDF/A-2 6.1.2 through the ordinary save: Save as PDF/A on a file without a binary comment, or +/// with a 2.0 header, saves a file whose header verifies. +#[test] +fn save_as_pdfa_saves_a_compliant_file_header() { + let plain = fixture(1); + assert!(plain.starts_with(b"%PDF-1.7\n1 0 obj"), "the fixture has no binary comment"); + let mut newer = plain.clone(); + newer.splice(5..8, *b"2.0"); + for bytes in [plain, newer] { + let mut s = Session::new(); + let id = s.open("x.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::ConvertPdfA { level: pdfcraft_preflight::Level::A2b }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"), "{:?}", String::from_utf8_lossy(&saved[..20])); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!(pdfcraft_preflight::verify(&reopened, pdfcraft_preflight::Level::A2b).iter().all(|i| i.clause != "6.1.2")); + } +} + +/// ISO 32000-2 §7.6.6, Table 25: a document whose only encryption is for its embedded files, +/// authorised when one is opened (`/AuthEvent /EFOpen`), opens without a password, lists its +/// attachment, and saves with the attachment still encrypted. +#[test] +fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password() { + // The password values of an AES-256 protection, as a save writes them. + let protected = protected("pw", "owner", -1); + let source = pdfcraft_cos::Document::open_with_password(protected, Some("pw")).unwrap(); + let encrypt = source.resolve(source.trailer().get(b"Encrypt").unwrap()); + let values: String = ["O", "U", "OE", "UE", "Perms"] + .iter() + .map(|k| { + let value = source.resolve(encrypt.as_dict().unwrap().get(k.as_bytes()).unwrap()); + format!(" /{k} <{}>", value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect::()) + }) + .collect(); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 4 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>".to_string(), + "<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(), + "<< /Type /EmbeddedFile /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream".to_string(), + format!( + "<< /Filter /Standard /V 5 /R 6{values} /P -4 /CF << /StdCF << /AuthEvent /EFOpen /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity /EFF /StdCF >>" + ), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt 6 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes(), + ); + let mut s = Session::new(); + let id = s.open("efopen.pdf", None, Arc::new(pdf), None).expect("opens without a password"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 1); + assert_eq!(doc.info.attachments.len(), 1); + let saved = s.save_bytes(id).unwrap(); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); +} diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 91fe8c2f6e..078815e172 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -128,7 +128,7 @@ fn ocr_words_group_into_phrases_that_edit_in_place() { assert!((blocks[1].rect[0] - john[0]).abs() < 1.0, "the new word starts where the old one did: {:?} vs {john:?}", blocks[1].rect); } -/// Several words of one OCR phrase rewrite as one line, without wrapping onto the line below. +/// Words added to an OCR phrase grow rightwards as one line, without wrapping onto the line below. #[test] fn a_longer_ocr_phrase_grows_rightwards() { let (mut s, id) = scanned_with(vec![ @@ -138,8 +138,8 @@ fn a_longer_ocr_phrase_grows_rightwards() { ]); s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Project Coordinator Lead".into(), style: Default::default() }).unwrap(); let blocks = s.get(id).unwrap().text_blocks(0); - assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["Project Coordinator Lead", "Testing"]); - assert_eq!(blocks[0].rows.len(), 1, "one line"); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["Project", "Coordinator Lead", "Testing"]); + assert_eq!(blocks[1].rows.len(), 1, "one line"); } /// OCR words differ in size and are stretched to fit the scan: a rewritten phrase keeps each @@ -151,12 +151,15 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() { scanned_word("Smith", 49.0, 78.0, 70.0, 11.0), scanned_word("Example", 82.0, 124.0, 70.0, 11.5), ]); - s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap(); + // Every word changes, so every word is redrawn. "Jahn" is as wide as "John" in Helvetica. + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jahn Smyth Exam".into(), style: Default::default() }).unwrap(); let lines = s.get(id).unwrap().text_lines(0); - assert_eq!(lines.iter().map(|l| l.text.trim()).collect::>(), ["John", "Smith", "Exam"]); - assert!((lines[0].rect[2] - 45.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect); - for pair in lines.windows(2) { - assert!(pair[1].rect[0] >= pair[0].rect[2] - 0.5, "{:?} runs into {:?}", pair[1].text, pair[0].text); + assert_eq!(lines.iter().map(|l| l.text.trim()).collect::>(), ["Jahn Smyth Exam"]); + let ink = &lines[0].ink; + assert!(ink.len() == 3, "three words: {ink:?}"); + assert!((ink[0][2] - 45.0).abs() < 0.5, "Jahn keeps John's width: {ink:?}"); + for pair in ink.windows(2) { + assert!(pair[1][0] >= pair[0][2] - 0.5, "{pair:?} run into each other"); } } @@ -179,16 +182,15 @@ fn an_edited_ocr_phrase_is_drawn_at_one_size() { let block = s.get(id).unwrap().text_blocks(0).remove(0); assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size); - s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap(); + // Every word changes, so every word is redrawn. "Jahn" is as wide as "John" in Helvetica. + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jahn Smyth Exbmple".into(), style: Default::default() }).unwrap(); let lines = s.get(id).unwrap().text_lines(0); - assert_eq!(lines.iter().map(|l| l.text.trim()).collect::>(), ["John", "Example"]); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::().replace(' ', ""), "JahnSmythExbmple"); for line in &lines { assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size); } - // Each word keeps its width; the rest of the phrase closes up where Smith was. - assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect); - assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect); - assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John"); + let ink = &lines[0].ink; + assert!((ink[0][2] - ink[0][0] - 25.0).abs() < 0.5, "Jahn keeps John's width: {ink:?}"); } /// Only invisible OCR text is set in one phrase size: visible text keeps each run's own size, and @@ -243,10 +245,12 @@ fn a_word_far_larger_than_its_phrase_keeps_its_width() { scanned_word("Smith", 49.0, 78.0, 70.0, 10.0), scanned_word("Example", 82.0, 124.0, 70.0, 10.5), ]); - s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap(); + // Every word changes, so every word is redrawn. "Jahn" is as wide as "John" in Helvetica. + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jahn Smyth Exam".into(), style: Default::default() }).unwrap(); let lines = s.get(id).unwrap().text_lines(0); - assert_eq!(lines[0].text.trim(), "John"); - assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect); + let ink = &lines[0].ink; + assert!(ink.len() == 3, "three words: {ink:?}"); + assert!((ink[0][2] - ink[0][0] - 25.0).abs() < 0.5, "Jahn keeps John's width: {ink:?}"); } /// With a new font the phrase is one run in its first word's text state. How wide it is must not @@ -270,6 +274,295 @@ fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() { assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}"); } +/// The lines drawn invisibly (still the scan's) and visibly (redrawn), by their text. +fn scan_and_drawn(s: &Session, id: pdfcraft_engine::DocId) -> (Vec, Vec) { + let blocks = s.get(id).unwrap().text_blocks(0); + let of = |invisible: bool| blocks.iter().filter(|b| b.invisible == invisible).map(|b| b.text.clone()).collect::>(); + (of(true), of(false)) +} + +/// A phrase over the scan's block of ink ([`WORD`]): two words side by side. +fn two_words_on_the_ink() -> (Session, pdfcraft_engine::DocId) { + scanned_with(vec![scanned_word("John", 42.0, 66.0, 42.0, 18.0), scanned_word("Smith", 70.0, 98.0, 42.0, 18.0)]) +} + +/// The share of paper among the page's pixels from x0 to x1 inside the scanned word's height. +fn paper_between(s: &Session, id: pdfcraft_engine::DocId, x0: u32, x1: u32) -> f64 { + let doc = s.get(id).unwrap(); + let out = PageRenderer::new(doc.bytes.clone(), Default::default()).render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let (mut paper, mut all) = (0, 0); + for y in WORD[1] + 4..WORD[3] - 4 { + for x in x0..x1 { + let i = ((y * out.width + x) * 4) as usize; + paper += usize::from(near([out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]], PAPER)); + all += 1; + } + } + paper as f64 / all as f64 +} + +/// Deleting a word covers that word and nothing else: the rest of the line is still the scan, and +/// the words after it stay where the scan shows them (#3313). +#[test] +fn deleting_an_ocr_word_leaves_the_rest_of_the_line_as_the_scan() { + let (mut s, id) = uneven_phrase(); + let before = s.get(id).unwrap().text_lines(0); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], Vec::new()), "nothing is redrawn"); + let after = s.get(id).unwrap().text_lines(0); + assert_eq!(after.len(), 2); + assert_eq!(after[0].rect, before[0].rect, "John hasn't moved"); + assert_eq!(after[1].rect, before[2].rect, "Example hasn't closed up the gap"); + assert_eq!(after[0].size, before[0].size, "and kept words keep the layer's own sizes"); +} + +/// On the page: the deleted word's pixels are covered, its neighbour's aren't. +#[test] +fn a_deleted_ocr_word_is_covered_and_its_neighbour_is_not() { + let (mut s, id) = two_words_on_the_ink(); + assert!(paper_between(&s, id, 44, 64) < 0.02 && paper_between(&s, id, 72, 96) < 0.02, "both words are ink to begin with"); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John".into(), style: Default::default() }).unwrap(); + assert!(paper_between(&s, id, 44, 64) < 0.02, "John is still the scan"); + assert!(paper_between(&s, id, 72, 96) > 0.98, "Smith is covered"); + assert_eq!(s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.as_str()).collect::>(), ["John"], "and gone from the text"); +} + +/// Replacing a word redraws that word, at the phrase's size and in its place. +#[test] +fn replacing_an_ocr_word_redraws_only_that_word() { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], vec!["Smyth".to_owned()])); + let lines = s.get(id).unwrap().text_lines(0); + let smyth = lines.iter().find(|l| l.text == "Smyth").unwrap(); + assert!((smyth.size - 11.0).abs() < 0.05, "at the phrase's size: {}", smyth.size); + assert!((smyth.rect[0] - 49.0).abs() < 0.5, "where Smith was: {:?}", smyth.rect); +} + +/// Changes apart from each other are each their own, with the word between them kept, as one step. +#[test] +fn two_changed_ocr_words_keep_the_word_between_them() { + let (mut s, id) = uneven_phrase(); + let original = s.get(id).unwrap().bytes.clone(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jon Smith Exam".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], vec!["Jon".to_owned(), "Exam".to_owned()])); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().bytes, original, "one undo brings the phrase back"); + s.redo(id).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], vec!["Jon".to_owned(), "Exam".to_owned()])); +} + +/// A replacement a little wider than its word is squeezed into its place, and the next word stays. +#[test] +fn a_slightly_longer_ocr_word_is_squeezed_into_its_place() { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smithy Example".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], vec!["Smithy".to_owned()])); + let lines = s.get(id).unwrap().text_lines(0); + let smithy = lines.iter().find(|l| l.text == "Smithy").unwrap(); + let example = lines.iter().find(|l| l.text == "Example").unwrap(); + assert!(smithy.rect[2] <= 80.5, "it ends before the middle of the gap: {:?}", smithy.rect); + assert_eq!(example.rect[0].round(), 82.0, "and Example hasn't moved"); + assert!((smithy.size - 11.0).abs() < 0.05, "squeezed across, not smaller: {}", smithy.size); +} + +/// A replacement too wide to squeeze takes over the word it would run into, and no more. +#[test] +fn a_longer_ocr_word_takes_over_the_word_it_would_run_into() { + let (mut s, id) = scanned_with(vec![ + scanned_word("John", 20.0, 45.0, 70.0, 11.0), + scanned_word("Smith", 49.0, 78.0, 70.0, 11.5), + scanned_word("Jones", 82.0, 112.0, 70.0, 12.0), + scanned_word("Brown", 118.5, 150.0, 70.0, 12.5), + ]); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smithsons Jones Brown".into(), style: Default::default() }).unwrap(); + let (scan, drawn) = scan_and_drawn(&s, id); + assert_eq!(scan, ["John", "Brown"], "the words it doesn't reach stay: {drawn:?}"); + assert_eq!(drawn, ["Smithsons Jones"]); + let lines = s.get(id).unwrap().text_lines(0); + let brown = lines.iter().find(|l| l.text == "Brown").unwrap(); + let jones = lines.iter().find(|l| l.text.ends_with("Jones")).unwrap(); + assert!(jones.rect[2] <= brown.rect[0], "{:?} runs into {:?}", jones.rect, brown.rect); +} + +/// A word added with nothing to replace redraws a neighbour; the rest stay. +#[test] +fn an_added_ocr_word_redraws_only_a_neighbour() { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example again".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Example again".to_owned()])); +} + +/// Words OCR sized alike share a run in the text lines; they are still kept one by one. +#[test] +fn ocr_words_of_one_size_are_kept_one_by_one() { + let (mut s, id) = scanned_with(vec![ + scanned_word("JOHN", 20.0, 45.0, 70.0, 11.0), + scanned_word("SMITH", 49.0, 78.0, 70.0, 11.0), + scanned_word("JONES", 82.0, 112.0, 70.0, 11.0), + ]); + assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1, "one run to begin with"); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "JOHN SMYTH JONES".into(), style: Default::default() }).unwrap(); + let (scan, drawn) = scan_and_drawn(&s, id); + assert_eq!((scan, drawn), (vec!["JOHN".to_owned(), "JONES".to_owned()], vec!["SMYTH".to_owned()])); +} + +/// Changing the formatting is about the whole phrase, so all of it is redrawn. +#[test] +fn a_formatting_change_redraws_the_whole_ocr_phrase() { + let (mut s, id) = uneven_phrase(); + let style = pdfcraft_engine::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() }; + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style }).unwrap(); + let (scan, drawn) = scan_and_drawn(&s, id); + assert!(scan.is_empty(), "nothing is left as the scan: {scan:?}"); + assert_eq!(drawn.join(" "), "John Smyth Example"); +} + +/// A layer whose words follow one another with no position of their own: a word that went would +/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead. +#[test] +fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() { + let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET"); + let mut s = Session::new(); + let id = s.open("relative.pdf", None, bytes, None).unwrap(); + assert_eq!(texts(&s, id), ["John Smith Example"]); + edit_block(&mut s, id, 0, "John Example"); + let (scan, drawn) = scan_and_drawn(&s, id); + assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}"); + assert_eq!(drawn, ["John Example"]); +} + +/// The edit survives a save and reopen, and each piece can be edited again. +#[test] +fn a_kept_ocr_word_can_be_edited_after_a_save() { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style: Default::default() }).unwrap(); + let saved = s.get(id).unwrap().bytes.clone(); + let id = s.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], vec!["Smyth".to_owned()])); + let john = s.get(id).unwrap().text_blocks(0).iter().position(|b| b.text == "John").unwrap(); + s.apply(id, Edit::EditTextBlock { page: 0, block: john, text: "Jon".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["Example".to_owned()], vec!["Jon".to_owned(), "Smyth".to_owned()])); +} + +/// A draft with the same words, a space typed after the last of them, changes nothing: not the +/// page, and not the undo history. It used to redraw the whole line. +#[test] +fn a_draft_with_the_same_words_changes_nothing() { + let (mut s, id) = uneven_phrase(); + let (bytes, generation) = (s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().edit_generation()); + for draft in ["John Smith Example ", " John Smith Example"] { + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap(); + assert_eq!(s.get(id).unwrap().bytes, bytes, "{draft:?} wrote nothing"); + assert_eq!(s.get(id).unwrap().edit_generation(), generation, "{draft:?} is no undo step"); + assert_eq!(scan_and_drawn(&s, id).1, Vec::::new(), "{draft:?} drew nothing"); + } +} + +/// Visible text positioned after the end of an OCR word depends on that word. An edit may not +/// leave the word gone and the visible text somewhere else while calling the rest kept. +#[test] +fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() { + let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET"); + let mut s = Session::new(); + let id = s.open("label.pdf", None, bytes, None).unwrap(); + let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]); + assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]); + let before = label(&s).unwrap(); + edit_block(&mut s, id, 0, "John"); + let (scan, _) = scan_and_drawn(&s, id); + assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s)); +} + +/// A large word right before a small one, as OCR's sizes can make them: the large word's cover, +/// padded in proportion to its height, stops at the small word, or that word would lose ink while +/// staying in the text. +#[test] +fn a_cover_stops_at_the_word_beside_it() { + let (mut s, id) = scanned_with(vec![scanned_word("John", 42.0, 66.0, 42.0, 30.0), scanned_word("Smith", 67.2, 98.0, 42.0, 8.0)]); + assert_eq!(s.get(id).unwrap().text_blocks(0)[0].text, "John Smith"); + assert!(paper_between(&s, id, 67, 70) < 0.02, "Smith's start is ink to begin with"); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Smith".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], Vec::new()), "only John goes"); + assert!(paper_between(&s, id, 44, 64) > 0.98, "John is covered"); + // John's padding would reach to x = 68, over this column of Smith's ink. + let smith = paper_between(&s, id, 67, 68); + assert!(smith < 0.02, "Smith keeps its ink: {smith}"); +} + +/// Deleting a word with Backspace leaves the spaces either side of it. That isn't spacing anyone +/// asked for: the words around it stay where the scan has them, with the gap already between them. +#[test] +fn the_spaces_a_deleted_word_leaves_keep_the_scan() { + for (draft, scan, drawn) in [ + ("John Example", vec!["John", "Example"], vec![]), + (" John Example", vec!["John", "Example"], vec![]), + ("John Smyth Example", vec!["John", "Example"], vec!["Smyth"]), + ] { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap(); + let (kept, redrawn) = scan_and_drawn(&s, id); + assert_eq!( + (kept.iter().map(String::as_str).collect::>(), redrawn.iter().map(String::as_str).collect::>()), + (scan, drawn), + "{draft:?}" + ); + } +} + +/// Spacing that has to be drawn goes to the rewrite that can draw it: inside new words, or typed +/// between words nothing else changed. It isn't quietly dropped. +#[test] +fn spacing_that_has_to_be_drawn_redraws_the_phrase() { + for draft in ["Jon Smyth Example", "John Smith Example"] { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap(); + let (scan, _) = scan_and_drawn(&s, id); + assert!(scan.is_empty(), "{draft:?} keeps nothing as the scan: {scan:?}"); + } + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jon Smyth Example".into(), style: Default::default() }).unwrap(); + let (_, drawn) = scan_and_drawn(&s, id); + assert!(drawn.join("").contains("Jon Smy"), "the typed spaces are there: {drawn:?}"); +} + +/// A word drawn twice, its copy earlier in the layer: rewriting the word takes the copy with it, and +/// the words after the copy move up the list. The next change must not land on the wrong word. +#[test] +fn a_change_lands_on_its_own_word_when_a_rewrite_removes_an_earlier_copy() { + let bytes = under_scan( + "BT /F1 12 Tf 3 Tr 1 0 0 1 84 44 Tm (Jones) Tj 1 0 0 1 20 44 Tm (John) Tj 1 0 0 1 50 44 Tm (Smith) Tj 1 0 0 1 84 44 Tm (Jones) Tj ET", + ); + let mut s = Session::new(); + let id = s.open("copy.pdf", None, bytes, None).unwrap(); + let block = texts(&s, id).iter().position(|t| t == "John Smith Jones").expect("the phrase, after its copy"); + // John goes. A deletion draws nothing, so nothing else would notice it landing on Smith. + edit_block(&mut s, id, block, "Smith Joness"); + let text = s.get(id).unwrap().text_lines(0).iter().map(|l| l.text.as_str()).collect::>().join(" "); + let words: Vec<&str> = text.split_whitespace().collect(); + assert!(words.contains(&"Smith"), "Smith wasn't changed: {words:?}"); + assert!(!words.contains(&"John"), "John is the word that goes: {words:?}"); + assert!(words.contains(&"Joness"), "{words:?}"); +} + +/// A layer whose words are already narrowed (50 Tz): squeezing a replacement narrows it further, +/// rather than setting a scale wider than the one it had and running it into the next word. +#[test] +fn a_squeeze_is_relative_to_the_words_own_scale() { + let bytes = under_scan("BT /F1 12 Tf 3 Tr 50 Tz 1 0 0 1 20 44 Tm (John) Tj 1 0 0 1 36 44 Tm (Smith) Tj 1 0 0 1 56 44 Tm (Jones) Tj ET"); + let mut s = Session::new(); + let id = s.open("narrow.pdf", None, bytes, None).unwrap(); + assert_eq!(scan_and_drawn(&s, id).0, ["John Smith Jones"]); + edit_block(&mut s, id, 0, "John Smithy Jones"); + let (scan, drawn) = scan_and_drawn(&s, id); + assert_eq!(drawn, ["Smithy"], "Jones isn't taken over; the scan has {scan:?}"); + let lines = s.get(id).unwrap().text_lines(0); + let smithy = lines.iter().find(|l| l.text == "Smithy").unwrap(); + let jones = lines.iter().find(|l| l.text == "Jones").unwrap(); + assert!(smithy.rect[2] <= jones.rect[0], "{:?} runs into {:?}", smithy.rect, jones.rect); +} + /// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write. fn stretched_visible() -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 8e004398c2..9342213577 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -189,7 +189,7 @@ pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> { /// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than /// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { - run(chain, data, max_output, None, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e) + run(chain, data, max_output, None, true, false, None).map(|(v, _, _)| v).map_err(|(e, _)| e) } /// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages @@ -197,19 +197,19 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res /// bytes, on success and on failure. A stage's output counts even when a later stage or its /// predictor shrinks it. pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, usize), (FilterError, usize)> { - run(chain, data, max_output, Some(max_output), true, false).map(|(v, _, spent)| (v, spent)) + run(chain, data, max_output, Some(max_output), true, false, None).map(|(v, _, spent)| (v, spent)) } /// Like [`decode`], but on corrupt data returns what was decoded before the error and /// reports `partial = true` (the behaviour viewers expect). The remaining filters of the /// chain still run on the partial data. Limit and unsupported errors are still errors. pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> { - run(chain, data, max_output, None, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) + run(chain, data, max_output, None, false, false, None).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) } /// [`decode_tolerant`] bounded and counted as [`decode_counted`] is. pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool, usize), (FilterError, usize)> { - run(chain, data, max_output, Some(max_output), false, false) + run(chain, data, max_output, Some(max_output), false, false, None) } /// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images: @@ -222,23 +222,46 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp /// /// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`. pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { - spending(chain, data, max_output, work, false) + spending(chain, data, max_output, work, false, None) +} + +/// [`decode_tolerant_within`] for the samples of an image `rows` high (its `/Height`), which the +/// last stage gives. When that stage is Flate with a PNG predictor and gives exactly `rows` rows +/// without the filter-type byte each predicted row starts with (ISO 32000-2 §7.4.4.4), its writer +/// declared the predictor without applying it: the output is taken as the samples, as other +/// readers take it, and reported as the damage it is, like any other corruption tolerated here. +/// Any other output has its predictor undone as [`decode_tolerant_within`] does. +pub fn decode_image_tolerant_within( + chain: &[(Filter, Params)], + data: &[u8], + max_output: usize, + rows: u32, + work: &mut u64, +) -> Result, FilterError> { + spending(chain, data, max_output, work, false, usize::try_from(rows).ok()) } /// [`decode`], spending from `work` as [`decode_tolerant_within`] does: corrupt data is an error, /// having paid for what was decoded before it. pub fn decode_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { - spending(chain, data, max_output, work, true) + spending(chain, data, max_output, work, true, None) } -fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64, strict: bool) -> Result, FilterError> { +fn spending( + chain: &[(Filter, Params)], + data: &[u8], + max_output: usize, + work: &mut u64, + strict: bool, + rows: Option, +) -> Result, FilterError> { decodable(chain)?; pay(work, data.len() as u64)?; if chain.is_empty() { return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) }; } let left = usize::try_from(*work).unwrap_or(usize::MAX); - match run(chain, data, max_output, Some(left), strict, true) { + match run(chain, data, max_output, Some(left), strict, true, rows) { Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out), // The stage stopped at the lower of the two, which took as much to find out. Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => { @@ -284,10 +307,11 @@ fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> { } /// The decoded data, whether it is partial and the bytes all stages decoded, or the error and -/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together. +/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together. With /// `priced`, the count is steps: each Flate stage also pays [`FLATE_READ_WORK`] for each byte it /// reads, before it runs, so a stage `total` can't pay for doesn't run -/// ([`FilterError::Spent`]). +/// ([`FilterError::Spent`]). `rows` is the height of the image whose samples the last stage +/// produces, if it does ([`decode_image_tolerant_within`]). fn run( chain: &[(Filter, Params)], data: &[u8], @@ -295,13 +319,14 @@ fn run( total: Option, strict: bool, priced: bool, + rows: Option, ) -> Result<(Vec, bool, usize), (FilterError, usize)> { decodable(chain).map_err(|e| (e, 0))?; let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; let mut spent = 0usize; let price = usize::try_from(FLATE_READ_WORK).unwrap_or(usize::MAX); - for (filter, params) in chain { + for (i, (filter, params)) in chain.iter().enumerate() { let read = if priced && matches!(filter, Filter::Flate) { price.saturating_mul(cur.len()) } else { 0 }; let room = total.map_or(usize::MAX, |total| total.saturating_sub(spent)); if read > room { @@ -313,8 +338,9 @@ fn run( let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent))); // A second reading of the same bytes (Flate's raw retry) is paid for as the first was, and // makes no more than is left once it has been. + let rows = rows.filter(|_| i + 1 == chain.len()); let again = room.checked_sub(read.saturating_mul(2)).map(|left| max.min(left)); - let (step, work, again) = decode_one(filter, params, &cur, limit, again); + let (step, work, again) = decode_one(filter, params, &cur, limit, again, rows); if again { spent = spent.saturating_add(read); } @@ -333,16 +359,18 @@ fn run( } /// One stage's result, the bytes its decoder produced (before any predictor), and whether it -/// read its input twice, which it does only within `again` ([`flate::decode`]). -fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option) -> (Step, usize, bool) { +/// read its input twice, which it does only within `again` ([`flate::decode`]); `rows` as for +/// [`run`]. +fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize, again: Option, rows: Option) -> (Step, usize, bool) { let once = |(step, n): (Step, usize)| (step, n, false); match filter { Filter::Flate => { let (step, twice) = flate::decode(data, max, again); - let (step, n) = with_predictor(step, params, "FlateDecode"); + let (step, n) = with_predictor(step, params, "FlateDecode", rows); (step, n, twice) } - Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode")), + // LZW rows are predicted whatever their length, as the renderer reads them. + Filter::Lzw => once(with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode", None)), Filter::AsciiHex => once(counted(asciihex::decode(data, max))), Filter::Ascii85 => once(counted(ascii85::decode(data, max))), Filter::RunLength => once(counted(runlength::decode(data, max))), @@ -361,13 +389,14 @@ fn counted(step: Step) -> (Step, usize) { /// Applies the predictor to a decoder's result, including to partial output on failure. The /// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder -/// produced it. -fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) { +/// produced it. `rows` (an image's height, for its last stage) applies only to data that decoded +/// whole: data cut short by damage has its predictor undone as it always had. +fn with_predictor(step: Step, params: &Params, name: &'static str, rows: Option) -> (Step, usize) { let (step, n) = counted(step); let step = match step { - Ok(v) => predictor::decode(params, v, name), + Ok(v) => predictor::decode(params, v, name, rows), Err(Failure { error, partial, discarded }) => { - let partial = match predictor::decode(params, partial, name) { + let partial = match predictor::decode(params, partial, name, None) { Ok(v) => v, Err(f) => f.partial, }; @@ -791,4 +820,103 @@ mod tests { assert!(refused(&decode_tolerant_within(&[], b"data", 4, &mut work).unwrap_err())); assert_eq!(work, 0); } + + /// Four rows of three RGB pixels, every sample above 4 (none reads as a PNG filter type). + fn rgb_rows() -> (Params, Vec) { + let p = Params { predictor: 15, colors: 3, columns: 3, ..Params::default() }; + (p, (0..36u8).map(|i| 200 - i).collect()) + } + + #[test] + fn an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples_and_reported() { + let (p, samples) = rgb_rows(); + let chain = [(Filter::Flate, p.clone())]; + let data = encode_flate(&samples); + let mut work = u64::MAX; + assert_eq!(decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut work).unwrap(), samples); + // The repair is reported as the damage it is: tolerant decoding says so, and strict refuses. + let (out, partial, _) = run(&chain, &data, 1 << 20, None, false, false, Some(4)).map_err(|(e, _)| e).unwrap(); + assert_eq!((out, partial), (samples.clone(), true)); + let strict = run(&chain, &data, 1 << 20, None, true, false, Some(4)).map(|_| ()).map_err(|(e, _)| e.to_string()); + assert!(strict.as_ref().is_err_and(|e| e.contains("carry no filter-type bytes")), "{strict:?}"); + // The same data that isn't known to be an image's rows still goes through the predictor. + let mut work = u64::MAX; + assert_ne!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), samples); + // LZW is read as before, as the renderer reads it. + let lzw = [(Filter::Lzw, p)]; + let data = encode(&Filter::Lzw, &Params::default(), &samples).unwrap(); + let mut plain = u64::MAX; + assert_eq!( + decode_image_tolerant_within(&lzw, &data, 1 << 20, 4, &mut work).ok(), + decode_tolerant_within(&lzw, &data, 1 << 20, &mut plain).ok() + ); + } + + #[test] + fn an_image_whose_rows_carry_png_tags_has_its_predictor_undone() { + let (p, samples) = rgb_rows(); + let chain = [(Filter::Flate, p.clone())]; + let data = encode(&Filter::Flate, &p, &samples).unwrap(); + let (mut a, mut b) = (u64::MAX, u64::MAX); + assert_eq!(decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut a).unwrap(), samples); + assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut b).unwrap(), samples); + assert_eq!(a, b, "the same work"); + let (_, partial, _) = run(&chain, &data, 1 << 20, None, false, false, Some(4)).map_err(|(e, _)| e).unwrap(); + assert!(!partial); + } + + #[test] + fn image_data_of_neither_length_decodes_as_it_did() { + let (p, _) = rgb_rows(); + let chain = [(Filter::Flate, p.clone())]; + for len in [0, 1, 9, 27, 35, 37, 39, 40, 41, 72] { + let data = encode_flate(&(0..len).map(|i| (i * 7 % 5) as u8).collect::>()); + let (mut a, mut b) = (u64::MAX, u64::MAX); + let image = decode_image_tolerant_within(&chain, &data, 1 << 20, 4, &mut a).map_err(|e| e.to_string()); + let plain = decode_tolerant_within(&chain, &data, 1 << 20, &mut b).map_err(|e| e.to_string()); + assert_eq!(image, plain, "{len} bytes"); + } + // Rows of the untagged length for another height, or no height. + let samples = encode_flate(&[9; 36]); + for rows in [0, 3, 5, u32::MAX] { + let (mut a, mut b) = (u64::MAX, u64::MAX); + assert_eq!( + decode_image_tolerant_within(&chain, &samples, 1 << 20, rows, &mut a).ok(), + decode_tolerant_within(&chain, &samples, 1 << 20, &mut b).ok(), + "{rows} rows" + ); + } + // Only the last stage produces the rows: an earlier one is predicted as before. + let hex: Vec = [9u8; 36].iter().flat_map(|b| format!("{b:02x}").into_bytes()).collect(); + let earlier = [(Filter::Flate, p.clone()), (Filter::AsciiHex, Params::default())]; + let data = encode_flate(&hex[..36]); + let (mut a, mut b) = (u64::MAX, u64::MAX); + assert_eq!( + decode_image_tolerant_within(&earlier, &data, 1 << 20, 4, &mut a).ok(), + decode_tolerant_within(&earlier, &data, 1 << 20, &mut b).ok() + ); + // TIFF predictor 2 and no predictor are left alone. + for predictor in [1, 2] { + let chain = [(Filter::Flate, Params { predictor, ..p.clone() })]; + let (mut a, mut b) = (u64::MAX, u64::MAX); + assert_eq!( + decode_image_tolerant_within(&chain, &samples, 1 << 20, 4, &mut a).ok(), + decode_tolerant_within(&chain, &samples, 1 << 20, &mut b).ok() + ); + } + } + + #[test] + fn image_rows_of_no_bytes_or_past_memory_do_not_panic() { + for (colors, bits_per_component, columns) in [(0, 8, 3), (3, 8, 0), (3, 8, -1), (255, 16, i64::MAX), (1, 1, i64::MAX), (3, 3, 3)] { + let chain = [(Filter::Flate, Params { predictor: 15, colors, bits_per_component, columns, early_change: 1 })]; + for len in [0, 1, 36] { + let data = encode_flate(&vec![7; len]); + for rows in [0, 1, 4, u32::MAX] { + let mut work = u64::MAX; + let _ = decode_image_tolerant_within(&chain, &data, 1 << 20, rows, &mut work); + } + } + } + } } diff --git a/crates/filters/src/predictor.rs b/crates/filters/src/predictor.rs index f9348941b7..bdf3c8c5c5 100644 --- a/crates/filters/src/predictor.rs +++ b/crates/filters/src/predictor.rs @@ -6,6 +6,9 @@ //! byte (0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth), whatever the predictor value is. //! //! A final partial row is decoded as far as its data goes. +//! +//! The samples of an image whose rows carry no filter-type byte, although a PNG predictor is +//! declared, are taken as they are and reported as damage ([`decode`]). use crate::{Failure, FilterError, Params, Step}; @@ -37,18 +40,39 @@ fn layout(p: &Params) -> Result { Ok(Layout { bpc, colors, bpp: (colors * bpc).div_ceil(8), row: bits.div_ceil(8), samples }) } -/// Undoes the predictor named by `p.predictor` (1 = identity). -pub(crate) fn decode(p: &Params, data: Vec, name: &'static str) -> Step { +/// Undoes the predictor named by `p.predictor` (1 = identity). `rows` is the height of the image +/// `data` is the samples of, if it is: with a PNG predictor, data of exactly `rows` rows without +/// their filter-type bytes ([`untagged`]) is given back as it is, as a [`Failure`] whose partial +/// output is all of it, so that tolerant decoding takes the samples and reports the repair, and +/// strict decoding refuses them. +pub(crate) fn decode(p: &Params, data: Vec, name: &'static str, rows: Option) -> Step { match p.predictor { 1 => Ok(data), 2 | 10..=15 => { let l = layout(p).map_err(|e| Failure::corrupt(name, e, Vec::new()))?; - if p.predictor == 2 { Ok(tiff_decode(&l, data)) } else { png_decode(&l, &data, name) } + if p.predictor == 2 { + return Ok(tiff_decode(&l, data)); + } + if let Some(rows) = rows.filter(|&rows| untagged(&l, data.len(), rows)) { + let detail = + format!("PNG predictor {} declared, but its {rows} rows carry no filter-type bytes: read as unpredicted samples", p.predictor); + return Err(Failure::corrupt(name, detail, data)); + } + png_decode(&l, &data, name) } other => Err(Failure::corrupt(name, format!("invalid Predictor {other}"), Vec::new())), } } +/// Whether `len` bytes are exactly `rows` rows of `l` without the filter-type byte that starts each +/// PNG-predicted row (ISO 32000-2 §7.4.4.4), where predicted data would be `rows` × (row + 1): a +/// writer that declared the predictor and didn't apply it. Read with the predictor, every row's +/// first sample was taken for a filter type and the image came out a row short and shifted. +/// PDFium, MuPDF and Poppler read such data as the samples it is. +fn untagged(l: &Layout, len: usize, rows: usize) -> bool { + rows > 0 && l.row > 0 && rows.checked_mul(l.row) == Some(len) +} + /// Applies the predictor named by `p.predictor` (1 = identity; 15 = per-row optimum). pub(crate) fn encode(p: &Params, data: &[u8]) -> Result, FilterError> { match p.predictor { @@ -213,7 +237,7 @@ mod tests { } fn dec(params: &Params, data: &[u8]) -> Result, FilterError> { - decode(params, data.to_vec(), "FlateDecode").map_err(|f| f.error) + decode(params, data.to_vec(), "FlateDecode", None).map_err(|f| f.error) } #[test] @@ -262,7 +286,7 @@ mod tests { #[test] fn png_invalid_tag() { - let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode"); + let r = decode(&p(10, 1, 8, 2), vec![0, 1, 2, 5, 1, 1], "FlateDecode", None); let f = r.err().unwrap(); assert!(matches!(f.error, FilterError::Corrupt { .. })); assert_eq!(f.partial, [1, 2]); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 6373d6427f..978b9d11ae 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1402,8 +1402,9 @@ fn glyph_component(part: &str) -> Option { if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)) { return char::from_u32(crate::encodings::NAMES[i].1).map(String::from); } - // `from_str_radix` accepts a leading `+`, which is not a glyph name, so the digits are checked. - let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit()); + // `from_str_radix` accepts a leading `+` and lowercase digits, and the glyph list + // specification's digits are uppercase only (`uni20ac` names nothing), so they are checked. + let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')); if let Some(hex) = part.strip_prefix("uni").filter(|h| h.len() >= 4 && h.len() % 4 == 0 && hexish(h)) { let mut s = String::new(); for i in (0..hex.len()).step_by(4) { @@ -1464,6 +1465,9 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { out.encoding.insert(code as u32, c.to_string()); } } + if base_name.is_none() && !symbolic_builtin { + out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + } if let Some(Object::Dict(d)) = enc.as_deref() && let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) { @@ -1527,6 +1531,74 @@ fn identity_to_unicode() -> Vec { cmap.into_bytes() } +/// What each code of an embedded TrueType font that isn't flagged non-symbolic, names no base +/// encoding and doesn't name its glyphs shows when two things say the code is Mac OS Roman: its +/// Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4: +/// single bytes "look up the associated glyph descriptions" there, and a (3, 0) subtable maps "the +/// single-byte codes in the (1, 0) subtable" prepended with 0xF0), and the glyph sits where the +/// standard Macintosh glyph order (the 258 names of a version 1.0 `post` table) puts that Mac OS +/// Roman character. So code 0x88 drawn with glyph 106, `agrave`, is `à`, where Standard has +/// nothing and WinAnsi `ˆ`, while a Windows subset in the same order whose codes are WinAnsi (0x92 +/// drawn with glyph 183, `quoteright`) keeps its reading. The glyph drawn is found the way the +/// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at +/// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text +/// layer reads the same codes the same way (vendored hayro-interpret patch (101)). +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { + use skrifa::GlyphId; + use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; + // Flag bit 6, Nonsymbolic (ISO 32000-2 Table 121). + if descriptor_flags(doc, &descriptor) & 32 != 0 { + return Vec::new(); + } + let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; + let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; + // A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post` + // table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it) + // names the glyphs, and a font that names them needn't keep the order: a Wingdings subset + // draws code 0x83 with glyph 101, `Eacute` in the order, which it names `threesans`. + let named = face + .table_data(Tag::new(b"post")) + .is_some_and(|data| matches!(data.as_bytes().get(..4), Some([0, 1, 0, 0] | [0, 2, 0, 0] | [0, 0, 0, 1] | [0, 0, 0, 2]))); + if face.cff().is_ok() || named { + return Vec::new(); + } + let Ok(cmap) = face.cmap() else { return Vec::new() }; + let records = cmap.encoding_records(); + let lookup = |code: u32, windows: bool| { + records.iter().find_map(|record| { + let offsets: &[u32] = match (record.platform_id(), record.encoding_id()) { + (PlatformId::Windows, 0 | 1) if windows => &[0, 0xF000, 0xF100, 0xF200], + (PlatformId::Macintosh | PlatformId::Unicode, 0) if !windows => &[0], + _ => return None, + }; + let subtable = record.subtable(cmap.offset_data()).ok()?; + offsets.iter().find_map(|o| subtable.map_codepoint(code + o).filter(|g| *g != GlyphId::NOTDEF)) + }) + }; + (0..=255u32) + .filter_map(|code| { + let drawn = lookup(code, true).or_else(|| lookup(code, false))?; + let mac = records.iter().find_map(|record| { + (record.platform_id() == PlatformId::Macintosh && record.encoding_id() == 0) + .then(|| record.subtable(cmap.offset_data()).ok()?.map_codepoint(code)) + .flatten() + })?; + if mac != drawn { + return None; + } + let c = char::from_u32(*crate::encodings::MAC_ROMAN.get(usize::try_from(code).ok()?)?).filter(|c| *c != '\0')?; + let standard = *DEFAULT_GLYPH_NAMES.get(usize::try_from(drawn.to_u32()).ok()?)?; + // Mac OS Roman 0xCA reads as a space, for the reason WinAnsi 0xA0 does (`MAC_ROMAN`). + let at_its_place = glyph_unicode(standard) == Some(c) || (code == 0xCA && standard == "nonbreakingspace"); + at_its_place.then(|| (code, c.to_string())) + }) + .collect() +} + #[cfg(test)] mod tests; @@ -1560,6 +1632,21 @@ mod encoding_tests { Object::Dict(enc) } + /// The glyph list specification's hex forms: `uni` and groups of exactly four uppercase + /// digits, `u` and four to six. + #[test] + fn glyph_names_take_uppercase_hex_in_whole_groups() { + assert_eq!(glyph_text("uni20ac"), None, "the specification's hex digits are uppercase"); + assert_eq!(glyph_text("u1f600"), None); + assert_eq!(glyph_text("uni65"), None, "`uni` takes groups of exactly four digits"); + assert_eq!(glyph_text("u65"), None, "`u` takes four to six digits"); + assert_eq!(glyph_text("u0010FFFF"), None); + assert_eq!(glyph_text("uni0065").as_deref(), Some("e")); + assert_eq!(glyph_text("u0065").as_deref(), Some("e"), "`u` and four digits"); + assert_eq!(glyph_text("u1F600").as_deref(), Some("\u{1f600}"), "`u` and five"); + assert_eq!(glyph_text("u10FFFF").as_deref(), Some("\u{10ffff}"), "`u` and six, the last code point"); + } + #[test] fn glyph_names_follow_the_naming_algorithm() { // Annex D names and the two hex forms, as before. diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index d1b96fe3d0..9a0a32be6a 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -1185,6 +1185,145 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +/// [`cmap_only_sfnt_named`] without a `post` table. +fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec { + cmap_only_sfnt_named(mac, windows, false) +} + +/// An sfnt holding a `cmap` table with these subtables: (1, 0) format 0 mapping codes to glyphs, +/// and (3, 0) format 4 mapping each code point to its glyph, one segment each; and, if `named`, a +/// version 2.0 `post` table. +fn cmap_only_sfnt_named(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)], named: bool) -> Vec { + let be16 = |out: &mut Vec, n: u16| out.extend_from_slice(&n.to_be_bytes()); + let mut subtables: Vec<((u16, u16), Vec)> = Vec::new(); + if let Some(map) = mac { + let mut t = Vec::new(); + for n in [0u16, 262, 0] { + be16(&mut t, n); + } + let mut ids = [0u8; 256]; + for &(code, glyph) in map { + ids[usize::from(code)] = glyph; + } + t.extend(ids); + subtables.push(((1, 0), t)); + } + let segments: Vec<(u16, u16)> = windows.iter().copied().chain([(0xFFFF, 0)]).collect(); + let count = segments.len() as u16; + let mut t = Vec::new(); + for n in [4u16, 16 + 8 * count, 0, 2 * count, 2, 0, 0] { + be16(&mut t, n); + } + segments.iter().for_each(|&(c, _)| be16(&mut t, c)); + be16(&mut t, 0); + segments.iter().for_each(|&(c, _)| be16(&mut t, c)); + segments.iter().for_each(|&(c, g)| be16(&mut t, if c == 0xFFFF { 1 } else { g.wrapping_sub(c) })); + segments.iter().for_each(|_| be16(&mut t, 0)); + subtables.push(((3, 0), t)); + + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, subtables.len() as u16); + let mut offset = 4 + 8 * subtables.len() as u32; + for ((platform, encoding), bytes) in &subtables { + be16(&mut cmap, *platform); + be16(&mut cmap, *encoding); + cmap.extend_from_slice(&offset.to_be_bytes()); + offset += bytes.len() as u32; + } + subtables.iter().for_each(|(_, bytes)| cmap.extend(bytes)); + let mut tables = vec![(*b"cmap", cmap)]; + if named { + // Version 2.0 with no glyphs to name. + let mut post = vec![0, 2, 0, 0]; + post.resize(34, 0); + tables.push((*b"post", post)); + } + let mut out = vec![0, 1, 0, 0]; + out.extend_from_slice(&(tables.len() as u16).to_be_bytes()); + out.extend([0; 6]); + let mut offset = 12 + 16 * tables.len() as u32; + for (tag, table) in &tables { + out.extend(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&offset.to_be_bytes()); + out.extend_from_slice(&(table.len() as u32).to_be_bytes()); + offset += table.len() as u32; + } + tables.into_iter().for_each(|(_, table)| out.extend(table)); + out +} + +/// A symbolic TrueType font with no `/Encoding` reads a code as Mac OS Roman when its (1, 0) `cmap` +/// subtable selects the glyph the code draws and the glyph sits where the standard Macintosh glyph +/// order puts that character, as the renderer's text layer reads it (vendored hayro-interpret +/// patch (101), ISO 32000-2 §9.6.5.4). Otherwise the codes read as before (Standard): WinAnsi codes +/// in that order (0x92 drawn with `quoteright`), no (1, 0) subtable, one selecting other glyphs +/// than the (3, 0) one, a font flagged non-symbolic, or one that names its glyphs. +#[test] +fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman() { + let read = |program: Vec, flags: i64, codes: [u32; 2]| { + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(flags)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let u = unicode_map(&doc, &f, false); + codes.map(|c| u.text(c).unwrap_or_default().into_owned()) + }; + // `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and + // `quoteright` 178 and 183. + let mac_order = cmap_only_sfnt(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(mac_order.clone(), 4, [0x88, 0x8E]), ["à", "é"]); + assert_eq!(read(mac_order, 32, [0x88, 0x8E]), ["", ""], "flagged non-symbolic"); + let winansi = cmap_only_sfnt(Some(&[(0x92, 183), (0x96, 178)]), &[(0xF092, 183), (0xF096, 178)]); + assert_eq!(read(winansi, 4, [0x92, 0x96]), ["", ""], "WinAnsi codes in the Macintosh order"); + let windows_only = cmap_only_sfnt(None, &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(windows_only, 4, [0x88, 0x8E]), ["", ""], "no (1, 0) subtable"); + let disagreeing = cmap_only_sfnt(Some(&[(0x88, 112), (0x8E, 106)]), &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(disagreeing, 4, [0x88, 0x8E]), ["", ""], "a (1, 0) subtable selecting other glyphs"); + // A `post` table of version 2.0 names the glyphs; what it calls them doesn't matter here. + let named = cmap_only_sfnt_named(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)], true); + assert_eq!(read(named, 4, [0x88, 0x8E]), ["", ""], "a font that names its glyphs"); +} + +/// The Mac OS Roman reading applies only to a font that names no base encoding and isn't Symbol or +/// ZapfDingbats (ISO 32000-2 §9.6.5.4, §9.6.6.1): a named `/BaseEncoding` or `/Encoding` keeps its +/// own reading, a `/Differences` without a base encoding still gets it, and a Symbol font keeps no +/// implicit mapping. +#[test] +fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { + let read = |entries: Vec<(&str, Object)>| { + let mut doc = Document::new_empty(); + let program = cmap_only_sfnt(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)]); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; + all.extend(entries); + let f = font(&mut doc, all); + let u = unicode_map(&doc, &f, false); + [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) + }; + assert_eq!(read(vec![]), ["à", "é"]); + assert_eq!(read(vec![("Encoding", Object::name("WinAnsiEncoding"))]), ["ˆ", "Ž"], "a named base encoding"); + let mut based = Dict::new(); + based.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + assert_eq!(read(vec![("Encoding", Object::Dict(based))]), ["ˆ", "Ž"], "a /BaseEncoding"); + let mut diffs = Dict::new(); + diffs.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x8E), Object::name("ecircumflex")])); + assert_eq!(read(vec![("Encoding", Object::Dict(diffs))]), ["à", "ê"], "/Differences without a base encoding"); + assert_eq!(read(vec![("BaseFont", Object::name("Symbol"))]), ["", ""], "Symbol"); + assert_eq!(read(vec![("BaseFont", Object::name("ZapfDingbats"))]), ["", ""], "ZapfDingbats"); + // A name it can't read clears the Mac OS Roman reading for its code, as it clears a base + // encoding's. + let mut notdef = Dict::new(); + notdef.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x8E), Object::name(".notdef")])); + assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); +} + #[test] fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5ebe3269cc..6a91220713 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -97,21 +97,13 @@ impl FieldAction { } } -fn texts(doc: &Document, o: &Object) -> Vec { - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(), - Object::String(s) => vec![s.to_text()], - _ => Vec::new(), - } -} - fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), - b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), + b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| crate::read::action_fields(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), b"GoTo" => { let dest = d.get(b"D").map(|x| doc.resolve(x))?; @@ -119,8 +111,8 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu FieldAction::GoTo(pages.iter().position(|p| *p == target)?) } b"Hide" => FieldAction::ShowHide { - fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(), - hide: !matches!(d.get(b"H"), Some(Object::Bool(false))), + fields: d.get(b"T").map(|t| crate::read::action_fields(doc, t)).unwrap_or_default(), + hide: !d.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))), }, b"SubmitForm" => FieldAction::Submit( d.get(b"F") @@ -344,3 +336,99 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reset_and_hide_actions_resolve_hierarchical_widget_and_indirect_field_targets() { + let mut doc = Document::new_empty(); + let rect = || Object::Array(vec![0.into(), 0.into(), 50.into(), 20.into()]); + + // 1. Hierarchical child field `address.city` (merged field + widget). + let address = doc.add(Object::Null); + let mut city = Dict::new(); + city.set(b"Parent".to_vec(), Object::Ref(address)); + city.set(b"FT".to_vec(), Object::name("Tx")); + city.set(b"T".to_vec(), PdfString::text("city")); + city.set(b"Subtype".to_vec(), Object::name("Widget")); + city.set(b"Rect".to_vec(), rect()); + let city = doc.add(Object::Dict(city)); + let mut addr_dict = Dict::new(); + addr_dict.set(b"T".to_vec(), PdfString::text("address")); + addr_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(city)])); + doc.set(address, Object::Dict(addr_dict)); + + // 2. Separated field `phone` whose widget dictionary has `/Parent` instead of `/T`. + let phone = doc.add(Object::Null); + let mut phone_widget = Dict::new(); + phone_widget.set(b"Subtype".to_vec(), Object::name("Widget")); + phone_widget.set(b"Parent".to_vec(), Object::Ref(phone)); + phone_widget.set(b"Rect".to_vec(), rect()); + let phone_widget = doc.add(Object::Dict(phone_widget)); + let mut phone_dict = Dict::new(); + phone_dict.set(b"FT".to_vec(), Object::name("Tx")); + phone_dict.set(b"T".to_vec(), PdfString::text("phone")); + phone_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(phone_widget)])); + doc.set(phone, Object::Dict(phone_dict)); + + // 3. Indirect string target `"email"`. + let email_str = doc.add(Object::String(PdfString::text("email"))); + let targets = Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(email_str)]); + + let mut reset_action = Dict::new(); + reset_action.set(b"S".to_vec(), Object::name("ResetForm")); + reset_action.set(b"Fields".to_vec(), targets.clone()); + let reset_action = doc.add(Object::Dict(reset_action)); + + let indirect_false = doc.add(Object::Bool(false)); + let mut hide_action = Dict::new(); + hide_action.set(b"S".to_vec(), Object::name("Hide")); + hide_action.set(b"T".to_vec(), targets); + hide_action.set(b"H".to_vec(), Object::Ref(indirect_false)); + let hide_action = doc.add(Object::Dict(hide_action)); + + let mut aa = Dict::new(); + aa.set(b"D".to_vec(), Object::Ref(hide_action)); + let mut btn = Dict::new(); + btn.set(b"FT".to_vec(), Object::name("Btn")); + btn.set(b"Ff".to_vec(), Object::Int(1 << 16)); + btn.set(b"T".to_vec(), PdfString::text("btn")); + btn.set(b"Subtype".to_vec(), Object::name("Widget")); + btn.set(b"Rect".to_vec(), rect()); + btn.set(b"A".to_vec(), Object::Ref(reset_action)); + btn.set(b"AA".to_vec(), Object::Dict(aa)); + let btn = doc.add(Object::Dict(btn)); + + let root = doc.root().unwrap(); + let pages_ref = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).unwrap(); + let mut page_dict = Dict::new(); + page_dict.set(b"Type".to_vec(), Object::name("Page")); + page_dict.set(b"Parent".to_vec(), Object::Ref(pages_ref)); + page_dict.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page_dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(btn)])); + let page = doc.add(Object::Dict(page_dict)); + doc.update_dict(pages_ref, |p| { + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(address), Object::Ref(phone), Object::Ref(btn)])); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + + let expected = vec!["address.city".to_string(), "phone".to_string(), "email".to_string()]; + let btn_field = fields(&doc).into_iter().find(|f| f.name == "btn").unwrap(); + assert_eq!(btn_field.button, Some(crate::af::ButtonAction::Reset { fields: expected.clone(), exclude: false })); + + let acts = field_actions(&doc, "btn").unwrap(); + assert_eq!( + acts, + vec![ + (Trigger::MouseUp, FieldAction::Reset(expected.clone())), + (Trigger::MouseDown, FieldAction::ShowHide { fields: expected, hide: false }), + ] + ); + } +} diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 0db605be6b..5b938f09e4 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -10,12 +10,12 @@ pub fn need_appearances(doc: &Document) -> bool { /// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for /// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from /// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the -/// writer left the appearances to the viewer), and otherwise each widget without a normal -/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons -/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own -/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is -/// not changed: saving writes the file as it was. +/// writer left the appearances to the viewer), and otherwise each widget whose normal appearance +/// (`/AP /N`) gives no stream to draw, as typing the value would draw it (#3470). Check boxes and +/// radio buttons with no normal appearance stream for their state (`/AS`; an Off state may have +/// none) are drawn from their `/MK` background, border and caption, and with `/NeedAppearances` +/// every one that has an `/MK` is, over its own appearance; push buttons with none are drawn from +/// their `/MK` and `/BS` (Table 192). `doc` is not changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -48,13 +48,14 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); let missing = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, - FieldKind::PushButton => normal.is_none(), + FieldKind::Text | FieldKind::Combo | FieldKind::List => all || !shows_stream(doc, normal.as_deref(), w.state.as_deref()), + FieldKind::PushButton => !shows_stream(doc, normal.as_deref(), w.state.as_deref()), FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { _ if all && described() => true, - (None, _) => true, - (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), - _ => false, + (Some(Object::Stream(_)), _) => false, + // An Off state may go without an appearance; an on state needs a stream. + (Some(Object::Dict(_)), Some("Off")) => false, + (n, s) => !shows_stream(doc, n, s), }, FieldKind::Signature => false, }; @@ -104,6 +105,19 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance copy } +/// Whether a widget's normal appearance `normal` (`/AP /N`, resolved) gives a stream to draw: the +/// entry itself when it is a stream, or the stream it names for the appearance state `state` +/// (`/AS`) when it is a subdictionary of states (ISO 32000-2 12.5.5, Table 170), as the renderer +/// picks it. Anything else (no entry, null, a subdictionary without `/AS` or without a stream for +/// that state, a dictionary that only looks like a form XObject) draws nothing. +fn shows_stream(doc: &Document, normal: Option<&Object>, state: Option<&str>) -> bool { + match (normal, state) { + (Some(Object::Stream(_)), _) => true, + (Some(Object::Dict(states)), Some(s)) => states.get(&name_bytes(s)).is_some_and(|a| matches!(doc.resolve(a).as_ref(), Object::Stream(_))), + _ => false, + } +} + /// The objects a widget's drawn appearance is built from, as the document holds them: the widget /// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the /// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 16bdae314d..27dbc2d224 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -20,17 +20,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); Some(match a.name(b"S")? { b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); + let fields = a.get(b"Fields").map(|f| action_fields(doc, f)).unwrap_or_default(); let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; af::ButtonAction::Reset { fields, exclude } } @@ -48,18 +38,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) } b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; + let fields = a.get(b"T").map(|t| action_fields(doc, t)).unwrap_or_default(); let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); af::ButtonAction::ShowHide { fields, hide } } @@ -91,6 +70,19 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec }) } +/// The field names targeted by a `ResetForm` (`/Fields`) or `Hide` (`/T`) action entry: a field +/// name string, an indirect field or widget dictionary reference, or an array of them. +pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec { + let target = |item: &Object| match item { + Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(item))), + other => text_of(other), + }; + match &*doc.resolve(o) { + Object::Array(items) => items.iter().filter_map(target).collect(), + _ => target(o).into_iter().collect(), + } +} + /// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, /// from the partial names (`/T`) up its `/Parent` chain. fn qualified_name(doc: &Document, r: ObjRef) -> Option { @@ -101,7 +93,8 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { if seen.len() > 64 || !seen.insert(c) { break; } - let obj = doc.get(c); + // An object that is only a reference to another is followed, as `Document::resolve` does. + let obj = doc.resolve(&Object::Ref(c)); let d = obj.as_dict()?; if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { parts.push(t); @@ -119,6 +112,38 @@ fn parent_of(doc: &Document, d: &Dict) -> Option { d.reference(b"Parent").filter(|p| doc.get(*p).as_dict().is_some()) } +/// The fields of the form's tree whose `/Kids` list one of `kids` and whose own fully qualified +/// name is `name`: a field listing kids that don't name it as their `/Parent`, and so share its +/// name rather than extend it. Each node is visited once, at most 64 levels down. +pub(crate) fn listing_fields_named(doc: &Document, name: &str, kids: &[ObjRef]) -> Vec { + let mut out = Vec::new(); + let Some(af) = acroform(doc) else { return out }; + let top = af.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut stack: Vec<(ObjRef, usize)> = top.iter().filter_map(Object::as_ref).map(|r| (r, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((r, depth)) = stack.pop() { + if depth > 64 || !seen.insert(r) { + continue; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let list: Vec = d + .get(b"Kids") + .map(|k| doc.resolve(k)) + .and_then(|k| k.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(Object::as_ref) + .collect(); + if list.iter().any(|k| kids.contains(k)) && qualified_name(doc, r).as_deref() == Some(name) { + out.push(r); + } + let next = depth.saturating_add(1); + stack.extend(list.into_iter().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).map(|k| (k, next))); + } + out +} + /// JavaScript streams one reading of the form (its fields, or its document scripts) may decode /// in all, however many actions name the same stream. pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20; @@ -381,6 +406,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { values: DecodeBudget::new(MAX_VALUE_BYTES, 0), }; for r in listed_roots { + // A `/Fields` entry that is a nameless kid of a field `/Fields` doesn't list is read + // through that field, which names it (ISO 32000-2 §12.7.4.2). A kid a field already read + // has claimed as its widget is not read again as a nameless field; one its parent's + // `/Kids` omits is still read on its own, as before. + let nameless_kid = doc.get(r).as_dict().is_some_and(|d| !d.contains(b"T") && d.contains(b"Parent")); + if nameless_kid { + if let Some(root) = unlisted_root(doc, r, &seen.fields) { + walk(doc, root, &base, &page_of, &mut seen, &mut out, 0); + } + if seen.widgets.contains(&r) { + continue; + } + } walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); } let listed = out.len(); @@ -528,15 +566,34 @@ fn walk( let kids: Vec = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); // Kids with /T are fields; kids without are this field's widgets. - let (field_kids, kids): (Vec, Vec) = kids.into_iter().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))); + let (field_kids, mut kids): (Vec, Vec) = + kids.into_iter().partition(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))); let field_kids: Vec = field_kids.into_iter().filter(|k| !not_a_child(doc, seen, r, *k)).collect(); if !field_kids.is_empty() { seen.path.push(r); for k in field_kids { - walk(doc, k, &inh, page_of, seen, out, depth + 1); + // A fully qualified name is built up the field's own `/Parent` chain (ISO 32000-2 + // §12.7.4.2). A kid that doesn't name this field as its `/Parent` is named from its + // own chain, as other readers name it; it still inherits this field's attributes. + // The `/Parent` as written: `qualified_name` follows an object that only refers on, + // and names a missing one as no parent. + let parent = doc.get(k).as_dict().and_then(|kd| kd.reference(b"Parent")); + if parent == Some(r) { + walk(doc, k, &inh, page_of, seen, out, depth + 1); + } else { + let mut own = inh.clone(); + own.name = parent.and_then(|p| qualified_name(doc, p)).unwrap_or_default(); + walk(doc, k, &own, page_of, seen, out, depth + 1); + } } seen.path.pop(); - return; + // "A field's children in the hierarchy may also include widget annotations" (ISO 32000-2 + // §12.7.4): widget annotations listed beside named kids are still this field's widgets, + // read as a field of this name next to the named kids. + kids.retain(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.name(b"Subtype") == Some(b"Widget"))); + if kids.is_empty() { + return; + } } // A field of no kind this reads is skipped before it claims widgets or scripts, which a later // field listing them then reads. @@ -553,8 +610,16 @@ fn walk( }; // A child names its parent (ISO 32000-2 §12.7.4.1, Table 226): a page widget naming this field // is one of its widgets even when its `/Kids` leave it out, as MuPDF and PDFium read it. - let widget_refs: Vec = - if d.contains(b"Rect") { vec![r] } else { kids.into_iter().chain(seen.by_parent.get(&r).into_iter().flatten().copied()).collect() }; + // A field's widgets are its widget kids. The field dictionary is its own widget only when it + // has none (no kid whose `/Subtype` is `/Widget`): field and widget are merged "when a field has only a single associated widget + // annotation" (ISO 32000-2 §12.7.4), so a merged dictionary that also lists widget kids is a + // field whose buttons are those kids, not one more (often zero-area) button. + let has_widget_kids = kids.iter().any(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.name(b"Subtype") == Some(b"Widget"))); + let widget_refs: Vec = if d.contains(b"Rect") && !has_widget_kids { + vec![r] + } else { + kids.into_iter().chain(seen.by_parent.get(&r).into_iter().flatten().copied()).collect() + }; let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 93ed2d7c7a..78dcdcff28 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -198,6 +198,9 @@ pub fn document_scripts(doc: &Document) -> Vec { document_scripts_named(doc).into_iter().map(|(_, js)| js).collect() } +/// Most `/Kids` entries the document JavaScript name tree walk reads in all. +const MAX_QUEUED_KIDS: usize = 1 << 20; + /// [`document_scripts`] with their names. pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(root) = doc.root() else { return Vec::new() }; @@ -206,43 +209,72 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); let mut scripts = crate::script_budget(); - // Kids stay unresolved on the stack, and each node is read once: a node listed many times, - // or among its own kids, must not multiply the work. + // Kids are indirect references (ISO 32000-2 §7.9.6), queued unresolved, each the first time + // it is listed: a node listed many times, or among its own kids, must not multiply the work, + // and the stack holds only references (a direct object there would be copied whole, once + // for every node listing it). Only the root may be direct. Nodes are told apart by object + // number, as `Document::get` finds them, so `5 0 R` and `5 1 R` are one node. + let mut seen: HashSet = tree.as_ref().map(|r| r.num).into_iter().collect(); + // The `/Names` arrays read, by object number: nodes sharing one would otherwise each list + // all its scripts again. + let mut names_read: HashSet = HashSet::new(); let mut stack = vec![tree]; - let mut seen: HashSet = HashSet::new(); let mut visited = 0; + // Every `/Kids` entry read, over the whole walk. Nodes sharing one long `/Kids` array would + // otherwise read all of it once per node. + let mut queued = 0usize; while let Some(raw) = stack.pop() { - if let Object::Ref(r) = raw - && !seen.insert(r) - { - continue; - } visited += 1; if visited > 10_000 { break; } let node = doc.resolve(&raw); let Some(d) = node.as_dict() else { continue }; - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - stack.extend(kids); + if let Some(kids) = d.get(b"Kids") { + in_array(doc, kids, |kids| { + // Past the limit nothing more is read, but the nodes already queued still are. + // Pushed in reverse, so the kids are read in the order listed. + let room = MAX_QUEUED_KIDS.saturating_sub(queued); + queued = queued.saturating_add(kids.len().min(room)); + let new: Vec = kids.iter().take(room).filter_map(Object::as_ref).filter(|r| seen.insert(r.num)).collect(); + stack.extend(new.into_iter().rev().map(Object::Ref)); + }); } - if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { - for pair in pairs.chunks(2) { - let [k, v] = pair else { continue }; - let key = match &*doc.resolve(k) { - Object::String(s) => s.to_text(), - _ => continue, - }; - if let Some(js) = crate::script(doc, v, &mut scripts) { - out.push((key, js)); + if let Some(pairs) = d.get(b"Names") + && pairs.as_ref().is_none_or(|r| names_read.insert(r.num)) + { + in_array(doc, pairs, |pairs| { + for pair in pairs.chunks(2) { + let [k, v] = pair else { continue }; + let key = match &*doc.resolve(k) { + Object::String(s) => s.to_text(), + _ => continue, + }; + if let Some(js) = crate::script(doc, v, &mut scripts) { + out.push((key, js)); + } } - } + }); } } out.sort(); out } +/// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would +/// copy it. +fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { + match o { + Object::Ref(r) => { + if let Some(a) = doc.get(*r).as_array() { + f(a); + } + } + Object::Array(a) => f(a), + _ => {} + } +} + /// Add (or replace) a document-level JavaScript named `name`. pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> { let root = doc.root().ok_or(FormError::NoForm)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 09564c2dee..e26fcc0fd3 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -2493,6 +2493,332 @@ fn fields_sharing_a_name_take_the_same_value() { } } +/// A radio group `Group1` whose two kids are fields of their own: each has `/T` (`kid_t`) and, +/// with `parent`, a `/Parent` naming the group. +fn radio_kids_with_names(kid_t: [&str; 2], parent: bool) -> Document { + let parent = if parent { " /Parent 4 0 R" } else { "" }; + let kid = |t: &str, state: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 49152 /T ({t}){parent} /V /Off /AS /Off /Rect [{x} 700 {} 715] /P 3 0 R /AP << /N << /Off 7 0 R /{state} 7 0 R >> >> >>", + x + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(), // 3 + "<< /FT /Btn /Ff 49152 /T (Group1) /V /Choice2 /Kids [6 0 R 5 0 R] >>".into(), // 4 + kid(kid_t[0], "Choice1", 50), // 5 + kid(kid_t[1], "Choice2", 100), // 6 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 7 + ]; + document(&objs) +} + +/// A field's fully qualified name is built from its own `/Parent` chain (ISO 32000-2 §12.7.4.2, +/// Table 226): a kid with `/T` that a field's `/Kids` lists but that has no `/Parent` naming +/// that field is named on its own, not under the field that lists it. +#[test] +fn a_named_kid_without_a_parent_keeps_its_own_name() { + let names = |doc: &Document| fields(doc).iter().map(|f| (f.name.clone(), f.obj.num)).collect::>(); + let same = radio_kids_with_names(["Group1", "Group1"], false); + assert_eq!(names(&same), [("Group1".to_string(), 6), ("Group1".to_string(), 5)]); + let other = radio_kids_with_names(["A", "B"], false); + assert_eq!(names(&other), [("B".to_string(), 6), ("A".to_string(), 5)]); + // With /Parent the kids are the group's children, as before. + let nested = radio_kids_with_names(["A", "B"], true); + assert_eq!(names(&nested), [("Group1.B".to_string(), 6), ("Group1.A".to_string(), 5)]); +} + +/// Fields with the same fully qualified name are one field (ISO 32000-2 §12.7.4.2): a radio +/// option that only a later one of them draws can be chosen by the name, and the others turn off. +#[test] +fn a_radio_option_of_any_field_sharing_the_name_can_be_chosen() { + for parent in [false, true] { + let name = if parent { "Group1.Group1" } else { "Group1" }; + let mut doc = radio_kids_with_names(["Group1", "Group1"], parent); + for (choice, on, off) in [("Choice1", 5, 6), ("Choice2", 6, 5), ("Choice1", 5, 6)] { + set_value(&mut doc, name, &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("{name} = {choice}: {e}")); + doc = reopen(&doc); + let st = |n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec)); + assert_eq!((st(on), st(off)), (Some(choice.as_bytes().to_vec()), Some(b"Off".to_vec())), "{name} = {choice}"); + } + let err = set_value(&mut doc, name, &FieldValue::Radio(Some("Choice3".into()))).unwrap_err(); + assert!(err.to_string().contains("Choice3"), "{err}"); + } +} + +/// The same group as a broken save leaves it: the kids carry no `/P` (their page is found through +/// the page's `/Annots`), and both are drawn on with values that disagree (`/V /Off` with +/// `/AS /Choice1`, `/V /Choice2` with `/AS /Choice2`). Choosing by the name leaves exactly one +/// button on, across a save, whichever option it is, and gives every field of the name the +/// chosen value: both kids and the group listing them. +#[test] +fn a_radio_choice_by_the_name_turns_one_button_on_in_kids_without_a_page_that_disagree() { + let kid = |state: &str, v: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 49152 /T (Group1) /V /{v} /AS /{state} /Rect [{x} 700 {} 715] /AP << /N << /Off 7 0 R /{state} 7 0 R >> >> >>", + x + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(), + "<< /FT /Btn /Ff 49152 /T (Group1) /V /Choice2 /Kids [6 0 R 5 0 R] >>".into(), + kid("Choice1", "Off", 50), + kid("Choice2", "Choice2", 100), + "<< /Length 0 >>\nstream\n\nendstream".into(), + ]; + let mut doc = document(&objs); + let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec)); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["Group1", "Group1"]); + assert!(all.iter().flat_map(|f| &f.widgets).all(|w| w.page == Some(0)), "found on the page without /P"); + assert_eq!((st(&doc, 5), st(&doc, 6)), (Some(b"Choice1".to_vec()), Some(b"Choice2".to_vec())), "both drawn on as read"); + for (choice, on, off) in [("Choice1", 5, 6), ("Choice2", 6, 5), ("Choice1", 5, 6)] { + set_value(&mut doc, "Group1", &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("Group1 = {choice}: {e}")); + doc = reopen(&doc); + assert_eq!((st(&doc, on), st(&doc, off)), (Some(choice.as_bytes().to_vec()), Some(b"Off".to_vec())), "Group1 = {choice}"); + // Every field named Group1 has the same value (ISO 32000-2 §12.7.4.2): both kids and the + // field listing them, whose /V was a stale Choice2. + let v = |n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"V").map(<[u8]>::to_vec)); + assert_eq!( + [v(4), v(5), v(6)], + [Some(choice.as_bytes().to_vec()), Some(choice.as_bytes().to_vec()), Some(choice.as_bytes().to_vec())], + "Group1 = {choice}" + ); + let values: Vec> = fields(&doc).into_iter().map(|f| f.value).collect(); + assert_eq!(values, [vec![choice.to_string()], vec![choice.to_string()]], "Group1 = {choice}"); + } +} + +/// A radio group whose field dictionary is merged with a zero-area widget and also lists four +/// widget kids, as some form exporters write it: field and widget may be merged only "when a field +/// has only a single associated widget annotation" (ISO 32000-2 §12.7.4), so the kids are the +/// buttons. Two such groups: each lists its four options, and choosing one by the name turns on +/// exactly that button, across a save, and leaves the other group alone. +#[test] +fn a_field_merged_with_a_widget_that_lists_widget_kids_has_the_kids_as_buttons() { + const OPTIONS: [&str; 4] = ["Not#20Satisfied", "Somewhat", "Satisfied", "Very#20Satisfied"]; + let group = |t: &str, first: u32| { + let kids: Vec = (first..first + 4).map(|n| format!("{n} 0 R")).collect(); + format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 0 0] /FT /Btn /Ff 49152 /Kids [{}] /V /Off /T ({t}) >>", kids.join(" ")) + }; + let kid = |parent: u32, i: usize, y: u32| { + let state = OPTIONS.get(i).copied().unwrap_or("X"); + let x = 100 + 100 * u32::try_from(i).unwrap_or(0); + format!( + "<< /Type /Annot /Subtype /Widget /F 4 /FT /Btn /Ff 49152 /Parent {parent} 0 R /Rect [{x} {y} {} {}] /P 3 0 R /AS /Off /AP << /N << /{state} 14 0 R /Off 14 0 R >> >> >>", + x + 12, + y + 12 + ) + }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3 + group("typeA15[0]", 6), // 4 + group("typeA15[1]", 10), // 5 + ]; + objs.extend((0..4).map(|i| kid(4, i, 600))); // 6-9 + objs.extend((0..4).map(|i| kid(5, i, 560))); // 10-13 + objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 14 + let mut doc = document(&objs); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["typeA15[0]", "typeA15[1]"]); + let want: Vec = ["Not Satisfied", "Somewhat", "Satisfied", "Very Satisfied"].map(String::from).to_vec(); + for f in &all { + assert_eq!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), want, "{}", f.name); + assert!(f.widgets.iter().all(|w| w.page == Some(0) && w.rect[2] > w.rect[0]), "{}: the kids, on the page", f.name); + } + let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec)); + for (name, choice, on) in [("typeA15[0]", "Satisfied", 8), ("typeA15[1]", "Very Satisfied", 13), ("typeA15[0]", "Not Satisfied", 6)] { + set_value(&mut doc, name, &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("{name} = {choice}: {e}")); + doc = reopen(&doc); + let first = if name == "typeA15[0]" { 6 } else { 10 }; + for n in first..first + 4 { + let want = if n == on { choice.as_bytes().to_vec() } else { b"Off".to_vec() }; + assert_eq!(st(&doc, n), Some(want), "{name} = {choice}: widget {n}"); + } + } + assert_eq!(st(&doc, 13), Some(b"Very Satisfied".to_vec()), "the other group keeps its choice"); + let values: Vec> = fields(&doc).into_iter().map(|f| f.value).collect(); + assert_eq!(values, [vec!["Not Satisfied".to_string()], vec!["Very Satisfied".to_string()]]); +} + +/// Radio buttons named `2` listed straight in `/Fields`, some fields of their own (`/T (2)`, no +/// `/Parent`) and some nameless widgets of an unlisted field `2` whose `/Kids` mix both: "A +/// field's children in the hierarchy may also include widget annotations" (ISO 32000-2 §12.7.4). +/// Every option is listed under the name `2`, and each, including the nameless widgets', can be +/// chosen by the name, leaving exactly that button on across a save. +#[test] +fn nameless_widgets_of_an_unlisted_field_are_options_of_its_name() { + let named = |state: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 49152 /T (2) /V /Off /AS /Off /Rect [{x} 700 {} 712] /P 3 0 R /AP << /N << /{state} 9 0 R /Off 9 0 R >> >> >>", + x + 12 + ) + }; + let nameless = |state: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent 4 0 R /AS /Off /Rect [{x} 700 {} 712] /P 3 0 R /AP << /N << /{state} 9 0 R /Off 9 0 R >> >> >>", + x + 12 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), // 3 + "<< /FT /Btn /Ff 49152 /T (2) /Kids [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), // 4 unlisted + named("0", 50), // 5 + named("1", 100), // 6 + nameless("2", 150), // 7 + nameless("6", 200), // 8 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 9 + ]; + let mut doc = document(&objs); + let all = fields(&doc); + assert!(all.iter().all(|f| f.name == "2"), "{:?}", all.iter().map(|f| &f.name).collect::>()); + let mut options: Vec = all.iter().flat_map(|f| &f.widgets).filter_map(|w| w.on_state.clone()).collect(); + options.sort(); + assert_eq!(options, ["0", "1", "2", "6"]); + let st = |doc: &Document, n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.name(b"AS").map(<[u8]>::to_vec)); + for (choice, on) in [("6", 8), ("1", 6), ("2", 7), ("0", 5)] { + set_value(&mut doc, "2", &FieldValue::Radio(Some(choice.into()))).unwrap_or_else(|e| panic!("2 = {choice}: {e}")); + doc = reopen(&doc); + for n in 5..=8 { + let want = if n == on { choice.as_bytes().to_vec() } else { b"Off".to_vec() }; + assert_eq!(st(&doc, n), Some(want), "2 = {choice}: widget {n}"); + } + } +} + +/// A kid with `/T` that a field's `/Kids` lists but whose `/Parent` names a different field is +/// named up that field's chain (ISO 32000-2 §12.7.4.2), not under the field listing it. +#[test] +fn a_named_kid_whose_parent_is_another_field_is_named_under_it() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (Listing) /Kids [5 0 R] >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /T (k) /Parent 6 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + "<< /T (Other) /Kids [5 0 R] >>".into(), // 6 unlisted + ]; + let names: Vec = fields(&document(&objs)).into_iter().map(|f| f.name).collect(); + assert_eq!(names, ["Other.k"]); +} + +/// A `/Parent` naming an object that is only a reference to the parent field is followed to it, +/// as document inspection follows it, so both name the kid the same. +#[test] +fn a_parent_given_through_an_object_that_is_a_reference_names_the_kid() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (Listing) /Kids [5 0 R] >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /T (k) /Parent 6 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + "7 0 R".into(), // 6 + "<< /T (Other) /Kids [5 0 R] >>".into(), // 7 + ]; + let names: Vec = fields(&document(&objs)).into_iter().map(|f| f.name).collect(); + assert_eq!(names, ["Other.k"]); +} + +/// The two kids of [`radio_kids_with_names`] (`Group1`, no `/Parent`), with the one drawing +/// `Choice2` (6, read first) or the one drawing `Choice1` (5) read-only. +fn radio_kids_one_read_only(read_only: u32) -> Document { + let kid = |n: u32, state: &str, x: u32| { + let ff = if n == read_only { 49153 } else { 49152 }; + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff {ff} /T (Group1) /V /Off /AS /Off /Rect [{x} 700 {} 715] /P 3 0 R /AP << /N << /Off 7 0 R /{state} 7 0 R >> >> >>", + x + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>".into(), + "<< /FT /Btn /Ff 49152 /T (Group1) /Kids [6 0 R 5 0 R] >>".into(), + kid(5, "Choice1", 50), + kid(6, "Choice2", 100), + "<< /Length 0 >> +stream + +endstream" + .into(), + ]; + document(&objs) +} + +/// A radio choice by the name reaches every radio field of that name (ISO 32000-2 §12.7.4.2), so +/// it is refused when any of them is read-only: the first one, even when a later one draws the +/// choice, or a later one, even when the first draws it. Nothing is written. +#[test] +fn a_radio_choice_by_the_name_is_refused_when_a_field_of_the_name_is_read_only() { + for (read_only, choice) in [(6, "Choice1"), (5, "Choice2")] { + let mut doc = radio_kids_one_read_only(read_only); + let state = |doc: &Document| { + [4, 5, 6].map(|n| { + doc.get(ObjRef { num: n, generation: 0 }).as_dict().map(|d| (d.name(b"V").map(<[u8]>::to_vec), d.name(b"AS").map(<[u8]>::to_vec))) + }) + }; + let before = state(&doc); + let err = set_value(&mut doc, "Group1", &FieldValue::Radio(Some(choice.into()))).unwrap_err(); + assert!(matches!(err, FormError::ReadOnly(_)), "{read_only} read-only, {choice}: {err}"); + assert_eq!(state(&doc), before, "{read_only} read-only, {choice}: nothing written"); + } +} + +/// A nameless widget listed in `/Fields` that the field before it already claimed is not read a +/// second time as a nameless field with no buttons. +#[test] +fn a_listed_widget_its_parent_claimed_is_read_once() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (p) /Kids [5 0 R] >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /Parent 4 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + ]; + let all = fields(&document(&objs)); + assert_eq!(all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect::>(), [("p", 1)]); +} + +/// A nameless widget listed in `/Fields` whose `/Parent` doesn't list it in its `/Kids` is still +/// read, as it was before such widgets were read through their parent. +#[test] +fn a_listed_widget_its_parent_omits_is_still_read() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), // 3 + "<< /FT /Tx /T (p) /Kids [] >>".into(), // 4 unlisted + "<< /Type /Annot /Subtype /Widget /FT /Tx /Parent 4 0 R /Rect [50 700 150 715] /P 3 0 R >>".into(), // 5 + ]; + let all = fields(&document(&objs)); + let widgets: Vec = all.iter().flat_map(|f| &f.widgets).map(|w| w.obj.num).collect(); + assert_eq!(widgets, [5]); +} + +/// A field merged with its widget whose `/Kids` holds a dictionary that isn't a widget keeps +/// itself as its one widget: only widget kids replace it (ISO 32000-2 §12.7.4). +#[test] +fn a_merged_field_with_a_kid_that_is_not_a_widget_keeps_itself_as_widget() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (t) /Rect [50 700 150 715] /P 3 0 R /Kids [5 0 R] >>".into(), // 4 + "<< /Foo 1 >>".into(), // 5 + ]; + let all = fields(&document(&objs)); + let widgets: Vec<(u32, [f64; 4])> = all.iter().flat_map(|f| &f.widgets).map(|w| (w.obj.num, w.rect)).collect(); + assert_eq!(widgets, [(4, [50.0, 700.0, 150.0, 715.0])]); +} + #[test] fn locked_fields_only_take_unlocking() { let mut doc = one_page(); @@ -3609,6 +3935,97 @@ fn document_scripts_read_each_name_tree_node_once() { assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); } +#[test] +fn name_tree_nodes_sharing_one_long_kids_array_queue_it_a_bounded_number_of_times() { + // 10,000 nodes share one /Kids array of 210,001 entries: the leaf, every node, and the leaf + // 200,000 times more. Queuing that array once per node would hold two billion entries. + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let action = doc.add(dict(vec![ + (b"S", Object::Name(b"JavaScript".to_vec())), + (b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))), + ])); + let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))])); + let kids = doc.add(Object::Null); + let nodes: Vec = (0..10_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))).collect(); + let mut shared = vec![Object::Ref(leaf); 200_000]; + shared.extend(nodes); + shared.push(Object::Ref(leaf)); + doc.set(kids, Object::Array(shared)); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); +} + +#[test] +fn a_direct_object_in_a_shared_kids_array_is_not_copied_for_every_node() { + // 1,000 nodes share one /Kids array that also holds a direct dictionary with a 2 MiB + // string. Queued with every node's kids, it would be copied 1,000 times (2 GiB) while the + // entry count stayed under its limit. Kids are references, so it is skipped. + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let action = doc.add(dict(vec![ + (b"S", Object::Name(b"JavaScript".to_vec())), + (b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))), + ])); + let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))])); + let kids = doc.add(Object::Null); + let big = dict(vec![(b"Pad", Object::String(pdfcraft_cos::PdfString::literal("x".repeat(2 << 20))))]); + let mut shared = vec![big]; + shared.extend((0..1_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))]))))); + shared.push(Object::Ref(leaf)); + doc.set(kids, Object::Array(shared)); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); +} + +#[test] +fn a_name_tree_node_listed_under_many_generations_is_read_once() { + // `Document::get` finds a node by object number alone, so `4 0 R` and `4 1 R` are one node. + // The root lists one under 10,000 generations: read for each, it would list its script + // 10,000 times and use up the walk's 10,000 visits before the node listed after it. + let kids: Vec = (0..10_000).map(|g| format!("4 {g} R")).collect(); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Kids [{} 5 0 R] >>", kids.join(" ")), + "<< /Names [(a) 6 0 R] >>".into(), + "<< /Names [(b) 6 0 R] >>".into(), + "<< /S /JavaScript /JS (var ready = true;) >>".into(), + ]); + let scripts = document_scripts_named(&doc); + assert_eq!(scripts.len(), 2, "{} scripts", scripts.len()); + let js = "var ready = true;".to_string(); + assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]); +} + +#[test] +fn name_tree_nodes_sharing_one_names_array_list_its_scripts_once() { + // 9,999 nodes share one /Names array. Read for every node, its scripts would be listed 9,999 + // times: ten million scripts and 1.3 GB when it names 1,000. + let nodes: Vec = (0..9_999).map(|i| format!("{} 0 R", i + 6)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".to_string(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Kids [{}] >>", nodes.join(" ")), + "[(a) 5 0 R (b) 5 0 R]".into(), + "<< /S /JavaScript /JS (var ready = true;) >>".into(), + ]; + objs.extend(nodes.iter().map(|_| "<< /Names 4 0 R >>".to_string())); + let doc = document(&objs); + let scripts = document_scripts_named(&doc); + assert_eq!(scripts.len(), 2, "{} scripts", scripts.len()); + let js = "var ready = true;".to_string(); + assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]); +} + /// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one, /// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list /// box without one, and G only a down appearance (`/D`, no `/N`). @@ -3670,6 +4087,55 @@ fn without_need_appearances_only_missing_appearances_are_drawn() { assert!(with_field_appearances(©).is_none(), "every widget has one now"); } +/// ISO 32000-2 12.5.5 and Table 170: a normal appearance is a stream or a subdictionary of +/// streams chosen by `/AS`. A form XObject's dictionary without its stream (`x`: object 6 says +/// it is one, but 7.5.7 keeps streams out of object streams, so a writer that packed it there +/// lost the data), a subdictionary with no `/AS` (`s`), or a state whose entry is no stream +/// (`u`, the push button `p`, the check box `k`) draws nothing, so these are drawn like widgets +/// without one: `x` with its `/MK /BC` border at its `/BS /W` of 3, given by reference. A +/// subdictionary whose `/AS` names a stream (`t`) is the file's own. +fn appearances_without_a_stream() -> Document { + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let tx = "/FT /Tx /V (v) /DA (/Helv 12 Tf 0 g)"; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + "<< /Fields [9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /XObject /Subtype /Form /FormType 1 /BBox [0 0 106 14] >>".into(), // 6 + "<< /W 3 >>".into(), + "<< /Length 18 >>\nstream\nBT (old T) Tj ET\nendstream".into(), // 8 + widget("x", &format!("{tx} /Rect [54 705 160 719] /MK << /BC [0 0 0] /BG [0.88 0.88 0.88] >> /BS 7 0 R /AP << /N 6 0 R >>")), + widget("s", &format!("{tx} /Rect [54 680 160 694] /AP << /N << /On 8 0 R >> >>")), // 10 + widget("t", &format!("{tx} /Rect [54 655 160 669] /AS /On /AP << /N << /On 8 0 R >> >>")), + widget("u", &format!("{tx} /Rect [54 630 160 644] /AS /On /AP << /N << /On 6 0 R >> >>")), + widget("p", "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Go) /BC [0 0 0] >> /Rect [54 600 160 620] /AP << /N 6 0 R >>"), + widget( + "k", + "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [200 700 224 724] /AP << /N << /Yes 6 0 R /Off 8 0 R >> >>", + ), + ]) +} + +#[test] +fn normal_appearances_that_give_no_stream_are_drawn() { + let doc = appearances_without_a_stream(); + let copy = with_field_appearances(&doc).expect("x, s, u, p and k draw nothing of their own"); + let all = fields(©); + let x = ap(©, &field(&all, "x").widgets[0]); + assert!(x.contains("0.88 0.88 0.88 rg\n0 0 106 14 re f") && x.contains("0 0 0 RG\n3 w\n1.5 1.5 103 11 re S"), "{x}"); + assert!(x.contains("(v) Tj"), "{x}"); + for name in ["s", "u"] { + assert!(ap(©, &field(&all, name).widgets[0]).contains("(v) Tj"), "{name}"); + } + assert!(ap(©, &field(&all, "p").widgets[0]).contains("(Go) Tj")); + assert!(state_ap(©, &field(&all, "k").widgets[0], "Yes").contains("(4) Tj")); + let t = |d: &Document| d.get(field(&fields(d), "t").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(t(©), t(&doc), "a state with a stream is the file's own"); + assert!(with_field_appearances(©).is_none(), "every widget has a stream now"); +} + /// #3470: refreshing the display copy redraws only the widgets whose inputs changed. Typing in /// one field of a form that leaves every appearance to the viewer otherwise redrew all of them. #[test] diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 3ac8efab91..356a8f8b75 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,5 +1,5 @@ use super::appearances::{declares_pdfa, need_appearances}; -use super::read::{acroform, field_flags, fields, page_refs}; +use super::read::{acroform, field_flags, fields, listing_fields_named, page_refs}; use super::{ Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance, author, calc_order, flags, invalid, name_obj, scripting, @@ -89,8 +89,23 @@ impl NamedFields { /// Write `value` into the field `name`, without recalculating: `named` are the positions in /// `all` (the form as read before this write) of the fields with that name, in field order. fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.read_only() { + let mut same = named.iter().filter_map(|&i| all.get(i)); + let first = same.clone().next().ok_or_else(|| FormError::NoSuchField(name.into()))?; + // Fields with the same fully qualified name are one field (ISO 32000-2 §12.7.4.2), so a radio + // option any of them draws is an option of the name: it is written through the first one + // that draws it, and the others turn off below. + let choice = match value { + FieldValue::Radio(Some(c)) | FieldValue::Text(c) => Some(c.as_str()), + _ => None, + }; + let f = match choice { + Some(c) if first.kind == FieldKind::Radio => same.find(|t| t.kind == FieldKind::Radio && t.state_for(c).is_some()).unwrap_or(first), + _ => first, + }; + // The name is read-only when the first field of it is, whichever field draws the choice; and + // a radio choice reaches every radio field of the name, so none of them may be read-only. + let radio_locked = f.kind == FieldKind::Radio && named.iter().filter_map(|&i| all.get(i)).any(|t| t.kind == FieldKind::Radio && t.read_only()); + if f.read_only() || first.read_only() || radio_locked { return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; @@ -105,6 +120,16 @@ fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: & share_value(doc, f.obj, t, &taken, scripts)?; } } + // A field whose `/Kids` lists radio fields that don't name it as their `/Parent` has their + // name too, so it takes the same value (§12.7.4.2) rather than keep a stale choice. + if f.kind == FieldKind::Radio { + let objs: Vec = named.iter().filter_map(|&i| all.get(i)).map(|t| t.obj).collect(); + if let Some(v) = doc.get(f.obj).as_dict().and_then(|d| d.get(b"V").cloned()) { + for p in listing_fields_named(doc, name, &objs) { + doc.update_dict(p, |d| d.set(b"V".to_vec(), v.clone()))?; + } + } + } Ok(()) } @@ -118,7 +143,13 @@ fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String } FieldKind::Radio => { let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str)) + set_states(doc, twin, choice.map(String::as_str))?; + // The same value (§12.7.4.2), even when none of the twin's buttons draws it: they all + // stay off, and only the field that draws the choice shows it. + match value.first() { + Some(v) if choice.is_none() => Ok(doc.update_dict(twin.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?), + _ => Ok(()), + } } FieldKind::Text | FieldKind::Combo | FieldKind::List => { let src = doc.get(source).as_dict().cloned().unwrap_or_default(); diff --git a/crates/measure/README.md b/crates/measure/README.md index 95a04a2b64..2e438d7a1e 100644 --- a/crates/measure/README.md +++ b/crates/measure/README.md @@ -8,7 +8,7 @@ Scale::calibrate derives a conversion from two points and a known real-world dis The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. The panel's drawing scale and measure_scale's `page_distance`/`page_unit` give the page side in a unit, by default the real-world one. Tool coordinates and explicit units-per-point scales use physical display points. -Snapping interprets painted paths and nested Form XObjects, including graphics-state transforms. Cubic paths are flattened to a maximum deviation of 0.2 user units. Raster edges, glyph outlines, clipping and optional-content visibility are not interpreted. Extraction stops at 20,000 segments, 40,000 endpoint and midpoint targets, 16 MiB of decoded content, 4,096 streams, 32 nested forms or 256 nested `q`; intersection searches use at most 256 nearby segments. Extraction is lenient: a content stream that can't be decoded is skipped and counted, and the rest of the page still snaps. The UI and automation report these limits. +Snapping interprets painted paths and nested Form XObjects, including graphics-state transforms. Cubic paths are flattened to a maximum deviation of 0.2 user units. Raster edges, glyph outlines, clipping and optional-content visibility are not interpreted. Extraction stops at 8,000,000 segments, 24,000,000 endpoint and midpoint targets, 512 MiB of decoded content, 4,096 streams, 32 nested forms or 256 nested `q`; intersection searches use at most 256 nearby segments. Extraction is lenient: a content stream that can't be decoded is skipped and counted, and the rest of the page still snaps. The UI and automation report these limits. Imported single-unit decimal rectilinear scales support differing X and Y conversion factors when CYX is present. Unit labels are text strings and may be non-ASCII ("m²"). Compound units (such as feet and inches), fraction formats and non-rectilinear scales are not supported: `list` reports such measurements, and ones with invalid geometry, under `unsupported` with a reason instead of failing, and leaves their PDF data untouched. A polygon whose last vertex repeats the first is treated as closed. Interoperability with Acrobat-authored measurements is untested. Geospatial and 3D measuring are outside this component. diff --git a/crates/measure/examples/snap_timing.rs b/crates/measure/examples/snap_timing.rs new file mode 100644 index 0000000000..319434f8cd --- /dev/null +++ b/crates/measure/examples/snap_timing.rs @@ -0,0 +1,32 @@ +//! Times extracting a page's snapping geometry and snapping at points on it. +//! +//! Usage: `snap_timing FILE PAGE` (PAGE is 1-based). + +use std::time::Instant; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let (Some(path), Some(page)) = (args.get(1), args.get(2)) else { + return Err("usage: snap_timing FILE PAGE".into()); + }; + let page: usize = page.parse::()?.checked_sub(1).ok_or("pages are numbered from 1")?; + let bytes = std::sync::Arc::new(std::fs::read(path)?); + let doc = pdfcraft_cos::Document::open(bytes).map_err(|e| format!("{e:?}"))?; + let start = Instant::now(); + let g = pdfcraft_measure::snap::geometry(&doc, page)?; + let extracted = start.elapsed(); + println!("extracted {} segments in {} ms: truncated {} unreadable {}", g.segment_count(), extracted.as_millis(), g.truncated, g.unreadable); + // Snaps at points spread over the drawing, with a 6-unit tolerance (a few pixels on screen). + let points: Vec<[f64; 2]> = + g.segments().step_by((g.segment_count() / 2000).max(1)).map(|[a, b]| [(a[0] + b[0]) / 2.0 + 0.7, (a[1] + b[1]) / 2.0 - 0.4]).collect(); + let start = Instant::now(); + let mut hits = 0; + for &p in &points { + if g.snap(p, 6.0, pdfcraft_measure::snap::SnapOptions::default())?.is_some() { + hits += 1; + } + } + let per = start.elapsed().as_secs_f64() * 1e6 / points.len().max(1) as f64; + println!("{} snaps, {hits} hits, {per:.0} us per snap", points.len()); + Ok(()) +} diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index c39d811d9f..0554a1f017 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -5,19 +5,50 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use serde::Serialize; use std::collections::HashSet; -const MAX_SEGMENTS: usize = 20_000; -/// Endpoints and midpoints together, across the whole page. -const MAX_TARGETS: usize = 2 * MAX_SEGMENTS; -const MAX_BYTES: usize = 16 * 1024 * 1024; +/// Open subpaths remembered for a fill, which closes each of them. +const MAX_OPEN_SUBPATHS: usize = Limits::DEFAULT.segments; +/// How much of a page extraction reads and keeps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Straight segments (curves are split into several). + pub segments: usize, + /// Endpoints and midpoints together, across the whole page. + pub targets: usize, + /// Decoded content bytes, across every stream read. + pub bytes: usize, +} +impl Limits { + /// Enough for heavy CAD sheets (several million segments), and + /// the renderer's own content limit. At about 40 bytes a segment, the cap holds ~320 MB, plus + /// up to [`MAX_INDEX_ENTRIES`] in the index. + pub const DEFAULT: Self = Self { segments: 8_000_000, targets: 24_000_000, bytes: 512 << 20 }; + /// What a window can read without missing frames that matter. + pub const WITHIN_A_FRAME: Self = Self { segments: 20_000, targets: 40_000, bytes: 16 << 20 }; + /// Within what the index can number. + fn indexable(self) -> Self { + let most = u32::MAX as usize / 2; + Self { segments: self.segments.min(most), targets: self.targets.min(most), ..self } + } +} +impl Default for Limits { + fn default() -> Self { + Self::DEFAULT + } +} +/// The page's drawn geometry in user space, compactly (`f32`: about 0.0003 units of precision at +/// A0 sizes), with a grid index so that a snap looks only near the pointer. #[derive(Clone, Debug, Default)] pub struct Geometry { - pub segments: Vec<[Point; 2]>, - pub endpoints: Vec, - pub midpoints: Vec, + /// Segments as `[ax, ay, bx, by]`. + segs: Vec<[f32; 4]>, + /// Snap targets: a straight edge's or curve's two ends, then its midpoint. + tgts: Vec<[f32; 6]>, /// A limit stopped extraction early (segments, targets, bytes, streams, nesting). pub truncated: bool, /// Content streams that couldn't be decoded and were skipped. pub unreadable: usize, + limits: Limits, + grid: Grid, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] @@ -80,7 +111,7 @@ fn remember_close(closures: &mut Vec<[Point; 2]>, current: Option, start: if let (Some(a), Some(b)) = (current, start) && distance(a, b) > 1e-9 { - if closures.len() >= MAX_SEGMENTS { + if closures.len() >= MAX_OPEN_SUBPATHS { return false; } closures.push([a, b]); @@ -88,10 +119,45 @@ fn remember_close(closures: &mut Vec<[Point; 2]>, current: Option, start: true } +fn seg(s: &[f32; 4]) -> [Point; 2] { + [[f64::from(s[0]), f64::from(s[1])], [f64::from(s[2]), f64::from(s[3])]] +} +fn pt(x: f32, y: f32) -> Point { + [f64::from(x), f64::from(y)] +} impl Geometry { + fn with_limits(limits: Limits) -> Self { + Self { limits, ..Self::default() } + } + /// How many segments were kept. + pub fn segment_count(&self) -> usize { + self.segs.len() + } + /// The segments, in drawing order. + pub fn segments(&self) -> impl ExactSizeIterator + '_ { + self.segs.iter().map(seg) + } + /// Snap endpoints: each edge's or curve's two ends, in drawing order. + pub fn endpoints(&self) -> impl Iterator + '_ { + self.tgts.iter().flat_map(|t| [pt(t[0], t[1]), pt(t[2], t[3])]) + } + /// Snap midpoints: each edge's or curve's middle, in drawing order. + pub fn midpoints(&self) -> impl Iterator + '_ { + self.tgts.iter().map(|t| pt(t[4], t[5])) + } /// Dense intersection searches are capped independently from path extraction. pub fn intersection_limited(&self, at: Point, tolerance: f64) -> bool { - self.segments.iter().filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance).take(257).count() > 256 + if tolerance.is_nan() || tolerance < 0.0 { + return false; + } + let (segs, _) = self.grid.near(self, at, tolerance); + segs.iter() + .filter_map(|&i| self.segs.get(i as usize)) + .map(seg) + .filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance) + .take(257) + .count() + > 256 } pub fn snap(&self, at: Point, tolerance: f64, options: SnapOptions) -> Result> { check_points(&[at])?; @@ -106,19 +172,25 @@ impl Geometry { *best = Some(Snap { point: p, kind, distance }); } }; + // Only what the grid finds near the pointer: everything within `tolerance` is there, in + // drawing order, so the result is the one a scan of the whole page would give. + let (segs, tgts) = self.grid.near(self, at, tolerance); + let targets: Vec<&[f32; 6]> = tgts.iter().filter_map(|&i| self.tgts.get(i as usize)).collect(); if options.endpoints { - for &p in &self.endpoints { - consider(&mut best, p, SnapKind::Endpoint); + for t in &targets { + consider(&mut best, pt(t[0], t[1]), SnapKind::Endpoint); + consider(&mut best, pt(t[2], t[3]), SnapKind::Endpoint); } } if options.midpoints { - for &p in &self.midpoints { - consider(&mut best, p, SnapKind::Midpoint); + for t in &targets { + consider(&mut best, pt(t[4], t[5]), SnapKind::Midpoint); } } + let near: Vec<[Point; 2]> = segs.iter().filter_map(|&i| self.segs.get(i as usize)).map(seg).collect(); // Only segments in the pointer's neighbourhood participate in intersection tests. // Cap the local quadratic work even on a malicious densely overlapping drawing. - let local: Vec<_> = self.segments.iter().filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance).take(256).collect(); + let local: Vec<_> = near.iter().filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance).take(256).collect(); if options.intersections { for (i, s) in local.iter().enumerate() { for t in local.iter().skip(i + 1) { @@ -129,35 +201,36 @@ impl Geometry { } } if options.paths && best.is_none() { - for [a, b] in &self.segments { + for [a, b] in &near { consider(&mut best, nearest(at, *a, *b), SnapKind::Path); } } Ok(best) } + /// Endpoints and midpoints together. fn targets(&self) -> usize { - self.endpoints.len().saturating_add(self.midpoints.len()) + self.tgts.len().saturating_mul(3) } /// Whether the segment was accepted (finite, non-degenerate and within the cap). fn segment(&mut self, a: Point, b: Point) -> bool { - if self.segments.len() >= MAX_SEGMENTS { + if self.segs.len() >= self.limits.segments { self.truncated = true; return false; } if check_points(&[a, b]).is_ok() && distance(a, b) > 1e-9 { - self.segments.push([a, b]); + self.segs.push([a[0] as f32, a[1] as f32, b[0] as f32, b[1] as f32]); return true; } false } /// Snap targets are recorded only for accepted geometry, and are capped. fn target(&mut self, ends: [Point; 2], mid: Point) { - if self.targets() >= MAX_TARGETS { + if self.targets().saturating_add(3) > self.limits.targets { self.truncated = true; return; } - self.endpoints.extend(ends); - self.midpoints.push(mid); + let [a, b] = ends; + self.tgts.push([a[0] as f32, a[1] as f32, b[0] as f32, b[1] as f32, mid[0] as f32, mid[1] as f32]); } fn edge(&mut self, a: Point, b: Point) { if self.segment(a, b) { @@ -166,7 +239,7 @@ impl Geometry { } fn curve(&mut self, points: [Point; 4], depth: usize) { let [a, b, c, d] = points; - if self.segments.len() >= MAX_SEGMENTS { + if self.segs.len() >= self.limits.segments { self.truncated = true; return; } @@ -203,6 +276,7 @@ struct Walker<'a> { /// expands and then shrinks pays for the expansion each time it is drawn. budget: DecodeBudget, streams: usize, + limits: Limits, } impl Walker<'_> { fn walk(&mut self, object: &Object, resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { @@ -261,7 +335,7 @@ impl Walker<'_> { let mut start = None; let mut current = None; let mut closures = Vec::new(); - let mut path = Geometry::default(); + let mut path = Geometry::with_limits(self.limits); // Read on the first `Do` and kept: resolving a direct dictionary copies it. let mut xobjects = None; let point = |m: Matrix, x: f64, y: f64| { @@ -270,8 +344,8 @@ impl Walker<'_> { }; // One operator at a time: a stream of millions of short operators is never held whole. for op in ops(data) { - if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS - || self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS + if self.geometry.segs.len().saturating_add(path.segs.len()) >= self.limits.segments + || self.geometry.targets().saturating_add(path.targets()) >= self.limits.targets { self.geometry.truncated = true; break; @@ -327,9 +401,9 @@ impl Walker<'_> { if let Some([a, b, c, d]) = curve && check_points(&[a, b, c, d]).is_ok() { - let before = path.segments.len(); + let before = path.segs.len(); path.curve([a, b, c, d], 0); - if path.segments.len() > before { + if path.segs.len() > before { path.target([a, d], [(a[0] + 3.0 * b[0] + 3.0 * c[0] + d[0]) / 8.0, (a[1] + 3.0 * b[1] + 3.0 * c[1] + d[1]) / 8.0]); } current = Some(d); @@ -369,8 +443,8 @@ impl Walker<'_> { // Filling implicitly closes every open subpath, not just the last one. // Stroke-only S leaves its open subpaths unchanged. for [a, b] in closures.drain(..) { - if self.geometry.segments.len().saturating_add(path.segments.len()) >= MAX_SEGMENTS - || self.geometry.targets().saturating_add(path.targets()) >= MAX_TARGETS + if self.geometry.segs.len().saturating_add(path.segs.len()) >= self.limits.segments + || self.geometry.targets().saturating_add(path.targets()) >= self.limits.targets { path.truncated = true; break; @@ -382,9 +456,8 @@ impl Walker<'_> { { path.edge(a, b); } - self.geometry.segments.append(&mut path.segments); - self.geometry.endpoints.append(&mut path.endpoints); - self.geometry.midpoints.append(&mut path.midpoints); + self.geometry.segs.append(&mut path.segs); + self.geometry.tgts.append(&mut path.tgts); self.geometry.truncated |= path.truncated; closures.clear(); current = None; @@ -392,7 +465,7 @@ impl Walker<'_> { } "n" => { self.geometry.truncated |= path.truncated; - path = Geometry::default(); + path = Geometry::with_limits(self.limits); closures.clear(); current = None; start = None; @@ -450,16 +523,296 @@ thread_local! { pub(crate) static XOBJECT_DICT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// The page's geometry for snapping, within [`Limits::DEFAULT`]. pub fn geometry(doc: &Document, page_index: usize) -> Result { + geometry_with_limits(doc, page_index, Limits::DEFAULT) +} + +/// The page's geometry for snapping, within `limits`. +pub fn geometry_with_limits(doc: &Document, page_index: usize, limits: Limits) -> Result { + let limits = limits.indexable(); let p = crate::page(doc, page_index)?; let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o)); let empty = Dict::new(); let resources = resources.as_ref().and_then(|o| o.as_dict()).unwrap_or(&empty); - let mut walker = - Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), budget: DecodeBudget::new(MAX_BYTES, usize::MAX), streams: 0 }; + let mut walker = Walker { + doc, + geometry: Geometry::with_limits(limits), + seen: HashSet::new(), + budget: DecodeBudget::new(limits.bytes, usize::MAX), + streams: 0, + limits, + }; // Extraction is lenient: limits and unreadable streams are reported in the geometry. if let Some(contents) = p.dict.get(b"Contents") { walker.walk(contents, resources, Matrix::IDENTITY, 0)?; } - Ok(walker.geometry) + let mut geometry = walker.geometry; + geometry.segs.shrink_to_fit(); + geometry.tgts.shrink_to_fit(); + geometry.grid = Grid::build(&geometry, MAX_INDEX_ENTRIES); + Ok(geometry) +} + +/// The most cells a side of the grid has. +const GRID_SIDE: usize = 1024; +/// A segment whose box covers more cells than this is kept in one list checked at every snap +/// (a long line would otherwise fill a whole row of cells). +const WIDE_CELLS: usize = 64; +/// The most cells one snap reads; a larger search area (a huge tolerance) scans everything. +const MAX_QUERY_CELLS: usize = 4096; +/// The most entries the segment lists of the cells hold (4 bytes each). Past it, shorter segments +/// go in the list checked at every snap: snaps get slower, and find the same. +const MAX_INDEX_ENTRIES: u64 = 64_000_000; + +/// A uniform grid over the geometry's box: per cell, the segments and targets touching it. +#[derive(Clone, Debug, Default)] +struct Grid { + x0: f32, + y0: f32, + cell: f32, + cols: usize, + rows: usize, + /// Per cell, where its segment indices start in `seg_items` (one more entry than cells). + seg_start: Vec, + seg_items: Vec, + tgt_start: Vec, + tgt_items: Vec, + /// Segments too long for the cells. + wide: Vec, +} + +impl Grid { + fn build(g: &Geometry, max_entries: u64) -> Self { + let mut bounds: Option<[f32; 4]> = None; + let mut grow = |x: f32, y: f32| { + let b = bounds.get_or_insert([x, y, x, y]); + *b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + }; + for s in &g.segs { + grow(s[0], s[1]); + grow(s[2], s[3]); + } + for t in &g.tgts { + grow(t[0], t[1]); + grow(t[2], t[3]); + grow(t[4], t[5]); + } + let Some([x0, y0, x1, y1]) = bounds else { return Self::default() }; + let items = g.segs.len().max(g.tgts.len()).max(1); + // About eight items a cell. + let side = ((items as f64 / 8.0).sqrt().ceil() as usize).clamp(1, GRID_SIDE); + let extent = (x1 - x0).max(y1 - y0).max(1e-3); + let cell = extent / side as f32; + let cols = (((x1 - x0) / cell) as usize + 1).min(GRID_SIDE); + let rows = (((y1 - y0) / cell) as usize + 1).min(GRID_SIDE); + let mut grid = Self { x0, y0, cell, cols, rows, ..Self::default() }; + let cells = cols * rows; + // Segments: count, then fill (compressed rows). + let seg_cells = |s: &[f32; 4]| grid.range(s[0].min(s[2]), s[1].min(s[3]), s[0].max(s[2]), s[1].max(s[3])); + let covered = |s: &[f32; 4]| { + let (c0, r0, c1, r1) = seg_cells(s); + (c1 - c0 + 1) * (r1 - r0 + 1) + }; + // A segment of one cell adds one entry, so the last step fits whatever the sheet. + let most_cells = [WIDE_CELLS, 16, 4] + .into_iter() + .find(|&most| g.segs.iter().map(covered).filter(|&n| n <= most).map(|n| n as u64).sum::() <= max_entries) + .unwrap_or(1); + let mut counts = vec![0u32; cells]; + let mut wide = Vec::new(); + for (i, s) in g.segs.iter().enumerate() { + let (c0, r0, c1, r1) = seg_cells(s); + if (c1 - c0 + 1) * (r1 - r0 + 1) > most_cells { + wide.push(i as u32); + continue; + } + for r in r0..=r1 { + for c in c0..=c1 { + counts[r * cols + c] += 1; + } + } + } + let (seg_start, mut fill) = Self::starts(&counts); + let mut seg_items = vec![0u32; seg_start.last().copied().unwrap_or(0) as usize]; + for (i, s) in g.segs.iter().enumerate() { + let (c0, r0, c1, r1) = seg_cells(s); + if (c1 - c0 + 1) * (r1 - r0 + 1) > most_cells { + continue; + } + for r in r0..=r1 { + for c in c0..=c1 { + let k = r * cols + c; + seg_items[fill[k] as usize] = i as u32; + fill[k] += 1; + } + } + } + // Targets: the cells of their three points. + let tgt_cells = |t: &[f32; 6]| { + let mut cs = [grid.cell_of(t[0], t[1]), grid.cell_of(t[2], t[3]), grid.cell_of(t[4], t[5])]; + cs.sort_unstable(); + cs + }; + let mut counts = vec![0u32; cells]; + for t in &g.tgts { + let cs = tgt_cells(t); + for (j, &k) in cs.iter().enumerate() { + if j == 0 || cs[j - 1] != k { + counts[k] += 1; + } + } + } + let (tgt_start, mut fill) = Self::starts(&counts); + let mut tgt_items = vec![0u32; tgt_start.last().copied().unwrap_or(0) as usize]; + for (i, t) in g.tgts.iter().enumerate() { + let cs = tgt_cells(t); + for (j, &k) in cs.iter().enumerate() { + if j == 0 || cs[j - 1] != k { + tgt_items[fill[k] as usize] = i as u32; + fill[k] += 1; + } + } + } + grid.seg_start = seg_start; + grid.seg_items = seg_items; + grid.tgt_start = tgt_start; + grid.tgt_items = tgt_items; + grid.wide = wide; + grid + } + + /// Prefix sums of `counts` (the starts), and a copy to fill from. + fn starts(counts: &[u32]) -> (Vec, Vec) { + let mut start = Vec::with_capacity(counts.len() + 1); + let mut total = 0u32; + start.push(0); + for &c in counts { + total = total.saturating_add(c); + start.push(total); + } + let fill = start[..counts.len()].to_vec(); + (start, fill) + } + + fn col(&self, x: f32) -> usize { + (((x - self.x0) / self.cell).max(0.0) as usize).min(self.cols.saturating_sub(1)) + } + fn row(&self, y: f32) -> usize { + (((y - self.y0) / self.cell).max(0.0) as usize).min(self.rows.saturating_sub(1)) + } + fn cell_of(&self, x: f32, y: f32) -> usize { + self.row(y) * self.cols + self.col(x) + } + /// The cells (first and last column, first and last row) a box covers. + fn range(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> (usize, usize, usize, usize) { + (self.col(x0), self.row(y0), self.col(x1), self.row(y1)) + } + + /// The segments and targets near `at` (all within `tolerance` of it, and maybe more), each + /// list in drawing order without repeats. + fn near(&self, g: &Geometry, at: Point, tolerance: f64) -> (Vec, Vec) { + let all = || ((0..g.segs.len() as u32).collect(), (0..g.tgts.len() as u32).collect()); + if self.cols == 0 || self.rows == 0 { + return if g.segs.is_empty() && g.tgts.is_empty() { (Vec::new(), Vec::new()) } else { all() }; + } + // Rounding to `f32` can stop the box short of a candidate's cell: this much wider can't. + let t = (tolerance + (at[0].abs().max(at[1].abs()) + tolerance) * 2.0 * f64::from(f32::EPSILON)) as f32; + let (x, y) = (at[0] as f32, at[1] as f32); + if !(x.is_finite() && y.is_finite() && t.is_finite()) { + return all(); + } + let (c0, r0, c1, r1) = self.range(x - t, y - t, x + t, y + t); + // Entirely outside the grid: nothing is near (except wide segments). + let outside = + x + t < self.x0 || y + t < self.y0 || x - t > self.x0 + self.cell * self.cols as f32 || y - t > self.y0 + self.cell * self.rows as f32; + if (c1 - c0 + 1) * (r1 - r0 + 1) > MAX_QUERY_CELLS { + return all(); + } + let mut segs: Vec = self.wide.clone(); + let mut tgts: Vec = Vec::new(); + if !outside { + for r in r0..=r1 { + for c in c0..=c1 { + let k = r * self.cols + c; + let range = |start: &[u32]| (start[k] as usize)..(start[k + 1] as usize); + segs.extend_from_slice(self.seg_items.get(range(&self.seg_start)).unwrap_or(&[])); + tgts.extend_from_slice(self.tgt_items.get(range(&self.tgt_start)).unwrap_or(&[])); + } + } + } + segs.sort_unstable(); + segs.dedup(); + tgts.sort_unstable(); + tgts.dedup(); + (segs, tgts) + } +} + +#[cfg(test)] +mod grid_tests { + use super::*; + + fn geometry(segs: Vec<[f32; 4]>, tgts: Vec<[f32; 6]>, max_entries: u64) -> Geometry { + let mut g = Geometry { segs, tgts, ..Geometry::default() }; + g.grid = Grid::build(&g, max_entries); + g + } + + /// Past the allowance the index holds fewer entries and finds the same. + #[test] + fn a_bounded_index_finds_what_a_full_one_does() { + let mut segs = Vec::new(); + for i in 0..4000u16 { + let (x, y) = (f32::from(i % 64) * 15.0, f32::from(i / 64) * 15.0); + segs.push([x, y, x + 37.0, y + 41.0]); + } + let tgts = Vec::new(); + let full = geometry(segs.clone(), tgts.clone(), u64::MAX); + let budget = segs.len() as u64; + let bounded = geometry(segs, tgts, budget); + assert!(full.grid.seg_items.len() as u64 > budget, "the sheet needs more than the allowance"); + assert!(bounded.grid.seg_items.len() as u64 <= budget, "{} entries", bounded.grid.seg_items.len()); + let options = SnapOptions { endpoints: false, midpoints: false, intersections: true, paths: true }; + for i in 0..400u16 { + let at = [f64::from(i) * 2.37, f64::from(i) * 1.13]; + assert_eq!(bounded.snap(at, 6.0, options).unwrap(), full.snap(at, 6.0, options).unwrap(), "at {at:?}"); + } + } + + #[test] + fn limits_stay_within_what_the_index_can_number() { + let limits = Limits { segments: usize::MAX, targets: usize::MAX, bytes: 1 }.indexable(); + assert!(limits.segments <= u32::MAX as usize && limits.targets <= u32::MAX as usize); + assert_eq!(Limits::DEFAULT.indexable(), Limits::DEFAULT); + } + + /// A tolerance below zero finds nothing; it must not reverse the cells searched. + #[test] + fn a_negative_tolerance_finds_nothing() { + let segs = (0..4000u16) + .map(|i| [f32::from(i % 64) * 15.0, f32::from(i / 64) * 15.0, f32::from(i % 64) * 15.0 + 5.0, f32::from(i / 64) * 15.0]) + .collect(); + let g = geometry(segs, Vec::new(), u64::MAX); + assert!(g.grid.cols > 1 && g.grid.rows > 1); + assert!(!g.intersection_limited([400.0, 400.0], -200.0)); + } + + /// Near 1e8 one `f32` step is 8 units, which must not hide an end just within tolerance. + #[test] + fn rounding_doesnt_hide_a_target_at_the_edge_of_the_grid() { + let g = geometry(Vec::new(), vec![[100_000_008.0, 0.0, 100_000_016.0, 0.0, 100_000_012.0, 0.0]], u64::MAX); + let options = SnapOptions { endpoints: true, midpoints: false, intersections: false, paths: false }; + let hit = g.snap([100_000_003.999_999_9, 0.0], 4.000_000_11, options).unwrap(); + assert_eq!(hit.map(|h| h.point), Some([100_000_008.0, 0.0])); + } + + /// A target is three points, and all three must fit. + #[test] + fn a_target_is_kept_only_if_all_its_points_fit() { + let mut g = Geometry::with_limits(Limits { segments: 10, targets: 2, bytes: 1 }); + g.target([[0.0, 0.0], [1.0, 0.0]], [0.5, 0.0]); + assert_eq!(g.targets(), 0); + assert!(g.truncated); + } } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index f265f086ec..b039f7c57f 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -112,7 +112,7 @@ fn filled_open_triangle_snaps_to_closing_edge() { for (op, closed) in [("f", true), ("F", true), ("f*", true), ("B", true), ("B*", true), ("h f", true), ("S", false)] { let d = fixture(&format!("40 50 m 160 50 l 100 170 l {op}"), "", &[]); let paths = snap::geometry(&d, 0).unwrap(); - assert_eq!(paths.segments.len(), if closed { 3 } else { 2 }, "{op}"); + assert_eq!(paths.segment_count(), if closed { 3 } else { 2 }, "{op}"); let hit = paths.snap([70.0, 110.0], 5.0, snap::SnapOptions::default()).unwrap(); assert_eq!(hit.is_some(), closed, "{op}"); } @@ -121,7 +121,7 @@ fn filled_open_triangle_snaps_to_closing_edge() { fn snap_paths_endpoints_midpoints_and_intersections() { let d = fixture("10 20 m 110 20 l S 60 0 m 60 80 l S 10 100 100 40 re S", "", &[]); let paths = snap::geometry(&d, 0).unwrap(); - assert_eq!(paths.segments.len(), 6); + assert_eq!(paths.segment_count(), 6); let opts = snap::SnapOptions::default(); let hit = paths.snap([11.0, 20.0], 3.0, opts).unwrap().unwrap(); assert_eq!(hit.kind, snap::SnapKind::Endpoint); @@ -147,9 +147,9 @@ fn filled_open_subpath_offers_the_same_snap_edges_as_explicit_close() { let explicit = snap::geometry(&closed, 0).unwrap(); let stroked = snap::geometry(&stroke, 0).unwrap(); - assert_eq!(filled.segments, explicit.segments); - assert_eq!(filled.segments.len(), 3); - assert_eq!(stroked.segments.len(), 2); + assert!(filled.segments().eq(explicit.segments())); + assert_eq!(filled.segment_count(), 3); + assert_eq!(stroked.segment_count(), 2); let paths_only = snap::SnapOptions { endpoints: false, midpoints: false, intersections: false, paths: true }; let hit = filled.snap([55.0, 80.0], 5.0, paths_only).unwrap().unwrap(); assert_eq!(hit.kind, snap::SnapKind::Path); @@ -163,12 +163,12 @@ fn fills_close_all_open_subpaths_but_strokes_do_not() { for paint in ["f", "F", "f*", "B", "B*", "b", "b*"] { let doc = fixture(&format!("{triangles} {paint}"), "", &[]); let geometry = snap::geometry(&doc, 0).unwrap(); - assert_eq!(geometry.segments.len(), 6, "{paint}"); - assert!(geometry.segments.contains(&[[10.0, 20.0], [0.0, 0.0]]), "{paint}"); - assert!(geometry.segments.contains(&[[50.0, 20.0], [40.0, 0.0]]), "{paint}"); + assert_eq!(geometry.segment_count(), 6, "{paint}"); + assert!(geometry.segments().any(|s| s == [[10.0, 20.0], [0.0, 0.0]]), "{paint}"); + assert!(geometry.segments().any(|s| s == [[50.0, 20.0], [40.0, 0.0]]), "{paint}"); } let stroked = fixture(&format!("{triangles} S"), "", &[]); - assert_eq!(snap::geometry(&stroked, 0).unwrap().segments.len(), 4); + assert_eq!(snap::geometry(&stroked, 0).unwrap().segment_count(), 4); } #[test] @@ -180,12 +180,12 @@ fn nested_forms_transforms_curves_and_cycles_are_bounded() { ); let d = fixture("q 1 0 0 1 100 200 cm /Nested Do Q", "", &[&form]); let geometry = snap::geometry(&d, 0).unwrap(); - assert_eq!(geometry.segments[0], [[100.0, 200.0], [120.0, 200.0]]); - assert!(geometry.endpoints.contains(&[120.0, 200.0])); - assert!(geometry.midpoints.contains(&[110.0, 222.5])); - assert!(geometry.segments.len() > 4 && geometry.segments.len() < 200); + assert_eq!(geometry.segments().next(), Some([[100.0, 200.0], [120.0, 200.0]])); + assert!(geometry.endpoints().any(|p| p == [120.0, 200.0])); + assert!(geometry.midpoints().any(|p| p == [110.0, 222.5])); + assert!(geometry.segment_count() > 4 && geometry.segment_count() < 200); let d = fixture(&"0 0 m 1 1 l S ".repeat(20_001), "", &[]); - assert!(snap::geometry(&d, 0).unwrap().truncated); + assert!(snap::geometry_with_limits(&d, 0, SMALL).unwrap().truncated); } #[test] fn measurement_csv_quotes_newlines_and_blocks_formula_cells() { @@ -242,35 +242,39 @@ fn imported_measurement_appearance_is_not_silently_replaced() { close(list(&d).measurements[0].reading.value, 100.0); } +/// The limits snapping had before heavy sheets, which the cap tests below were written for. +const SMALL: snap::Limits = snap::Limits { segments: 20_000, targets: 40_000, bytes: 16 << 20 }; + #[test] fn degenerate_and_dense_paths_keep_snap_targets_bounded() { // Rejected (zero-length) segments record no endpoints or midpoints at all. let d = fixture(&"0 0 0 0 re f ".repeat(100_000), "", &[]); - let g = snap::geometry(&d, 0).unwrap(); - assert!(g.segments.is_empty() && g.endpoints.is_empty() && g.midpoints.is_empty(), "{} {}", g.endpoints.len(), g.midpoints.len()); + let g = snap::geometry_with_limits(&d, 0, SMALL).unwrap(); + assert!(g.segment_count() == 0 && g.endpoints().next().is_none() && g.midpoints().next().is_none()); // Every paint drains into the page geometry; the page-wide target cap still holds. let d = fixture(&"0 0 1 1 re f ".repeat(30_000), "", &[]); - let g = snap::geometry(&d, 0).unwrap(); + let g = snap::geometry_with_limits(&d, 0, SMALL).unwrap(); assert!(g.truncated); - assert!(g.endpoints.len() + g.midpoints.len() <= 40_000 + 12, "{}", g.endpoints.len() + g.midpoints.len()); - assert!(g.segments.len() <= 20_000 + 4); + let targets = g.endpoints().count() + g.midpoints().count(); + assert!(targets <= 40_000 + 12, "{targets}"); + assert!(g.segment_count() <= 20_000 + 4); // Unpainted curves and lines are capped too. let d = fixture(&"0 0 m 5 9 1 9 7 0 c 0 3 l ".repeat(30_000), "", &[]); - let g = snap::geometry(&d, 0).unwrap(); - assert!(g.truncated && g.endpoints.is_empty()); + let g = snap::geometry_with_limits(&d, 0, SMALL).unwrap(); + assert!(g.truncated && g.endpoints().next().is_none()); } #[test] fn deep_graphics_state_and_unreadable_streams_are_skipped_not_fatal() { let d = fixture(&format!("0 0 m 10 0 l S {}", "q ".repeat(300)), "", &[]); let g = snap::geometry(&d, 0).unwrap(); assert!(g.truncated); - assert_eq!(g.segments.len(), 1); + assert_eq!(g.segment_count(), 1); // An unsupported filter cannot be decoded at all (corrupt Flate is decoded tolerantly). let broken = "<< /Length 10 /Filter /NoSuchDecode >>\nstream\nnot-zlib!!\nendstream"; let d = fixture_with("[4 0 R 5 0 R]", "0 0 m 10 0 l S", "", &[broken]); let g = snap::geometry(&d, 0).unwrap(); assert_eq!(g.unreadable, 1); - assert_eq!(g.segments.len(), 1); + assert_eq!(g.segment_count(), 1); let d = fixture_with("5 0 R", "", "", &[broken]); assert_eq!(snap::geometry(&d, 0).unwrap().unreadable, 1); } @@ -410,7 +414,7 @@ fn xobject_resources_are_read_once_per_stream() { let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]); snap::XOBJECT_DICT_READS.with(|c| c.set(0)); let paths = snap::geometry(&d, 0).unwrap(); - assert!(paths.segments.is_empty()); + assert_eq!(paths.segment_count(), 0); let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); assert!(reads <= 1, "{reads} reads of the page's XObject resources"); } @@ -466,10 +470,10 @@ fn a_stream_over_the_byte_budget_uses_it_up_once() { let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap(); let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50)); let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]); - let g = snap::geometry(&d, 0).unwrap(); + let g = snap::geometry_with_limits(&d, 0, SMALL).unwrap(); assert_eq!(g.unreadable, 1); assert!(g.truncated); - assert_eq!(g.segments.len(), 1, "the parts before it still snap"); + assert_eq!(g.segment_count(), 1, "the parts before it still snap"); } /// A content stream drawn again and again pays for every filter stage's output each time, not @@ -484,7 +488,7 @@ fn repeated_content_pays_for_every_filter_stage() { .obj("<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R 4 0 R] >>") .stream("/Filter [/ASCIIHexDecode /RunLengthDecode]", &hex); let d = Document::open(Arc::new(pdf.build())).unwrap(); - let g = snap::geometry(&d, 0).unwrap(); + let g = snap::geometry_with_limits(&d, 0, SMALL).unwrap(); assert!(g.truncated, "eight draws decode past the page's bound"); } diff --git a/crates/measure/tests/snap_property.rs b/crates/measure/tests/snap_property.rs index a38bb80aaf..ddb071dc33 100644 --- a/crates/measure/tests/snap_property.rs +++ b/crates/measure/tests/snap_property.rs @@ -131,18 +131,20 @@ fn finite(point: [f64; 2]) -> bool { fn geometry_of(content: &[u8]) -> Geometry { let doc = Document::open(Arc::new(one_page_pdf(content))).unwrap(); - snap::geometry(&doc, 0).unwrap() + // The caps these properties were written for. + let limits = snap::Limits { segments: MAX_GEOMETRY_SEGMENTS, targets: 2 * MAX_GEOMETRY_SEGMENTS, bytes: 16 << 20 }; + snap::geometry_with_limits(&doc, 0, limits).unwrap() } /// Checks the bounds and finiteness that snapping relies on, and returns the extracted geometry. fn check_snap_invariants(content: &[u8]) -> Geometry { assert!(content.len() <= MAX_CONTENT_BYTES, "generated content exceeded the byte cap"); let geometry = geometry_of(content); - assert!(geometry.segments.len() <= MAX_GEOMETRY_SEGMENTS, "geometry exceeded the segment cap: {}", geometry.segments.len()); - assert!(geometry.endpoints.len().saturating_add(geometry.midpoints.len()) <= MAX_SNAP_TARGETS, "geometry exceeded the snap-target cap"); - assert!(geometry.segments.iter().flatten().copied().all(finite), "geometry contains a non-finite segment coordinate"); - assert!(geometry.endpoints.iter().copied().all(finite), "geometry contains a non-finite endpoint"); - assert!(geometry.midpoints.iter().copied().all(finite), "geometry contains a non-finite midpoint"); + assert!(geometry.segment_count() <= MAX_GEOMETRY_SEGMENTS, "geometry exceeded the segment cap: {}", geometry.segment_count()); + assert!(geometry.endpoints().count().saturating_add(geometry.midpoints().count()) <= MAX_SNAP_TARGETS, "geometry exceeded the snap-target cap"); + assert!(geometry.segments().flatten().all(finite), "geometry contains a non-finite segment coordinate"); + assert!(geometry.endpoints().all(finite), "geometry contains a non-finite endpoint"); + assert!(geometry.midpoints().all(finite), "geometry contains a non-finite midpoint"); geometry.intersection_limited([0.0, 0.0], 10_000.0); let snap = geometry.snap([0.0, 0.0], 10_000.0, SnapOptions::default()).unwrap(); @@ -173,7 +175,7 @@ fn mutated_snap_geometry_stays_bounded_and_finite() { fn high_curvature_geometry_hits_segment_cap() { let stress_curve = b"0 0 m 0 100000000 100000000 100000000 100000000 0 c S ".repeat(6); let geometry = check_snap_invariants(&stress_curve); - assert!(geometry.truncated, "high-curvature geometry did not report its extraction cap ({} segments)", geometry.segments.len()); + assert!(geometry.truncated, "high-curvature geometry did not report its extraction cap ({} segments)", geometry.segment_count()); } fn two_piece_pdf(first: &[u8], second: &[u8]) -> Vec { @@ -216,3 +218,95 @@ fn trailing_comment_in_previous_piece_hides_next_piece_first_line() { assert!(snap_at_box_corner(b"q % tail comment, no end of line").is_none(), "snapped to a rectangle the renderer comments out"); assert!(snap_at_box_corner(b"q % tail comment\n").is_some(), "control: the rectangle is drawn"); } + +/// A snap computed by scanning the whole page, as snapping did before its grid index: the +/// reference the indexed snap must equal. +fn snap_by_scan(g: &Geometry, at: [f64; 2], tolerance: f64, options: SnapOptions) -> Option { + let distance = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).hypot(a[1] - b[1]); + let nearest = |p: [f64; 2], a: [f64; 2], b: [f64; 2]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + if len2 <= f64::EPSILON { + return a; + } + let t = (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0); + [a[0] + t * dx, a[1] + t * dy] + }; + let mut best: Option = None; + let consider = |best: &mut Option, p: [f64; 2], kind: snap::SnapKind| { + let d = distance(at, p); + if d <= tolerance && best.is_none_or(|b| d < b.distance - 1e-9) { + *best = Some(snap::Snap { point: p, kind, distance: d }); + } + }; + if options.endpoints { + g.endpoints().for_each(|p| consider(&mut best, p, snap::SnapKind::Endpoint)); + } + if options.midpoints { + g.midpoints().for_each(|p| consider(&mut best, p, snap::SnapKind::Midpoint)); + } + let local: Vec<[[f64; 2]; 2]> = g.segments().filter(|[a, b]| distance(at, nearest(at, *a, *b)) <= tolerance).take(256).collect(); + if options.intersections { + for (i, s) in local.iter().enumerate() { + for t in local.iter().skip(i + 1) { + if let Some(p) = snap::intersection(s[0], s[1], t[0], t[1]) { + consider(&mut best, p, snap::SnapKind::Intersection); + } + } + } + } + if options.paths && best.is_none() { + g.segments().for_each(|[a, b]| consider(&mut best, nearest(at, a, b), snap::SnapKind::Path)); + } + best +} + +/// Whether more than 256 segments pass within `tolerance` of `at`, by a scan of the whole page. +fn limited_by_scan(g: &Geometry, at: [f64; 2], tolerance: f64) -> bool { + g.segments() + .filter(|[a, b]| { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 <= f64::EPSILON { 0.0 } else { (((at[0] - a[0]) * dx + (at[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) }; + (at[0] - (a[0] + t * dx)).hypot(at[1] - (a[1] + t * dy)) <= tolerance + }) + .count() + > 256 +} + +#[test] +fn indexed_snapping_matches_a_scan_of_the_whole_page() { + let mut rng = Rng(SEED ^ 0x5eed); + let mut checked = 0usize; + for case in 0..200 { + let seed = SEEDS[rng.below(SEEDS.len())]; + // A drawing with many short lines, some long ones (kept outside the cells) and curves. + let mut content = mutate(&mut rng, seed); + for i in 0..200 + rng.below(800) { + let (x, y) = (rng.below(600) as f64, rng.below(800) as f64); + let line = match i % 7 { + 0 => format!("{x} {y} m {} {} l S ", x + rng.below(600) as f64, y), + 1 => format!("{x} {y} m {} {} {} {} {} {} c S ", x + 5.0, y + 9.0, x + 9.0, y + 5.0, x + 12.0, y), + _ => format!("{x} {y} m {} {} l S ", x + rng.below(20) as f64, y + rng.below(20) as f64), + }; + content.extend_from_slice(line.as_bytes()); + } + let g = geometry_of(&content); + for _ in 0..40 { + let at = [rng.below(700) as f64 - 50.0 + 0.37, rng.below(900) as f64 - 50.0 + 0.61]; + let tolerance = [0.5, 2.0, 7.5, 40.0, 400.0][rng.below(5)]; + let options = SnapOptions { + endpoints: rng.below(4) != 0, + midpoints: rng.below(4) != 0, + intersections: rng.below(3) != 0, + paths: rng.below(2) == 0, + }; + let indexed = g.snap(at, tolerance, options).unwrap(); + let scanned = snap_by_scan(&g, at, tolerance, options); + assert_eq!(indexed, scanned, "case {case} at {at:?} tolerance {tolerance} {options:?}"); + assert_eq!(g.intersection_limited(at, tolerance), limited_by_scan(&g, at, tolerance), "case {case} at {at:?} tolerance {tolerance}"); + checked += 1; + } + } + assert!(checked >= 8000); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 886684c6d7..94da24ea1c 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -18,6 +18,10 @@ pub struct Page { pub dict: Dict, } +/// The side, in points, at or below which a `/MediaBox` has no area and the page falls back to +/// US Letter (the renderer's `is_nearly_zero`, 1/4096). +const MIN_BOX_SIDE: f64 = 1.0 / 4096.0; + fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { let o = doc.resolve(o?); let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); @@ -25,9 +29,13 @@ fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { } impl Page { - /// The visible region (crop box clipped to the media box), in user space. + /// The visible region (crop box clipped to the media box), in user space. A page with no + /// `/MediaBox`, or one without area (a side of at most 1/4096 pt, as the renderer measures + /// it), is US Letter, as the renderer and inspector size it. pub fn crop(&self, doc: &Document) -> [f64; 4] { - let media = rect(doc, self.dict.get(b"MediaBox")).unwrap_or([0.0, 0.0, 612.0, 792.0]); + let media = rect(doc, self.dict.get(b"MediaBox")) + .filter(|m| m[2] - m[0] > MIN_BOX_SIDE && m[3] - m[1] > MIN_BOX_SIDE) + .unwrap_or([0.0, 0.0, 612.0, 792.0]); match rect(doc, self.dict.get(b"CropBox")) { Some(c) => { let r = [c[0].max(media[0]), c[1].max(media[1]), c[2].min(media[2]), c[3].min(media[3])]; @@ -230,6 +238,11 @@ mod tests { format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> {pages} >>"), format!("<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] {page} >>"), ]; + doc_from(&objs) + } + + /// A document whose objects 1, 2, … are `objs`, object 1 its catalog. + fn doc_from(objs: &[String]) -> Document { let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -245,6 +258,93 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + /// A page with no `/MediaBox`, its own or inherited, is US Letter, the size the renderer + /// and the inspector use too (hayro-syntax patch 44; ISO 32000-2 Table 31 gives no default). + #[test] + fn a_page_without_a_media_box_is_letter() { + let d = doc_from(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + ]); + let p = &pages(&d)[0]; + assert_eq!(p.crop(&d), [0.0, 0.0, 612.0, 792.0]); + assert_eq!(p.display_size(&d), (612.0, 792.0)); + } + + /// A page without a `/MediaBox` of its own inherits its `/Pages` parent's (ISO 32000-2 + /// §7.7.3.4): only a box neither has falls back to US Letter. + #[test] + fn a_page_inherits_its_parents_media_box_before_falling_back_to_letter() { + let d = doc_from(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + ]); + let p = &pages(&d)[0]; + assert_eq!(p.crop(&d), [0.0, 0.0, 300.0, 400.0]); + assert_eq!(p.display_size(&d), (300.0, 400.0)); + } + + /// The box can come from further up: a grandparent `/Pages` node's `/MediaBox`, through a + /// `/Pages` node without one, with its origin away from 0 0 kept in the crop and view matrix. + #[test] + fn a_page_inherits_a_grandparents_media_box_with_its_origin() { + let d = doc_from(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [100 200 400 600] >>".to_string(), + "<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 3 0 R >>".to_string(), + ]); + let p = &pages(&d)[0]; + assert_eq!(p.crop(&d), [100.0, 200.0, 400.0, 600.0]); + assert_eq!(p.display_size(&d), (300.0, 400.0)); + assert_eq!(p.view_matrix(&d), view_matrix_for(0, [100.0, 200.0, 400.0, 600.0])); + assert_ne!(p.view_matrix(&d), view_matrix_for(0, [0.0, 0.0, 300.0, 400.0]), "the origin is kept"); + } + + /// The nearest ancestor's `/MediaBox` wins over the root's, origin and all: the crop and the view matrix come from + /// the intermediate `/Pages` node's box. + #[test] + fn a_page_takes_its_nearest_ancestors_media_box_and_origin() { + let d = doc_from(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_string(), + "<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 /MediaBox [100 200 400 600] >>".to_string(), + "<< /Type /Page /Parent 3 0 R >>".to_string(), + ]); + let p = &pages(&d)[0]; + assert_eq!(p.crop(&d), [100.0, 200.0, 400.0, 600.0]); + assert_eq!(p.view_matrix(&d), view_matrix_for(0, [100.0, 200.0, 400.0, 600.0])); + } + + /// An intermediate `/Pages` node's `/MediaBox` without area is the inherited one, so the page is US Letter, not + /// its grandparent's box. + #[test] + fn a_nearest_media_box_without_area_gives_letter_not_a_further_ancestors() { + let d = doc_from(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 /MediaBox [0 0 0 0] >>".to_string(), + "<< /Type /Page /Parent 3 0 R >>".to_string(), + ]); + let p = &pages(&d)[0]; + assert_eq!(p.crop(&d), [0.0, 0.0, 612.0, 792.0]); + } + + /// A `/MediaBox` without area (`[0 0 0 0]`) is US Letter too, as the renderer draws it. + #[test] + fn a_page_whose_media_box_has_no_area_is_letter() { + let d = doc_from(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 0 0] >>".to_string(), + ]); + let p = &pages(&d)[0]; + assert_eq!(p.crop(&d), [0.0, 0.0, 612.0, 792.0]); + assert_eq!(p.display_size(&d), (612.0, 792.0)); + } + #[test] fn inherited_copies_stop_at_a_budget() { let mut d = doc(0); diff --git a/crates/optimize/src/audit.rs b/crates/optimize/src/audit.rs index 5aa6669b45..afa95f555e 100644 --- a/crates/optimize/src/audit.rs +++ b/crates/optimize/src/audit.rs @@ -1,14 +1,15 @@ //! PDF Optimizer ▸ Audit space usage: how many bytes each kind of content takes. //! -//! Every object is sized as it would be written (dictionary plus stream data). Resources go by -//! kind first, wherever they are used: a font or image a form field or comment draws with is -//! still a font or image. Every other object goes to the first category that reaches it; what -//! remains (the catalog, page tree, info, metadata, cross-reference data and anything -//! unreachable) is document overhead. +//! Every standalone object is sized as it would be written (dictionary plus stream data), while +//! objects packed in compressed object streams share their object stream's compressed bytes in +//! proportion to their uncompressed size. Resources go by kind first, wherever they are used: a +//! font or image a form field or comment draws with is still a font or image. Every other object +//! goes to the first category that reaches it; what remains (the catalog, page tree, info, +//! metadata, cross-reference data and anything unreachable) is document overhead. use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{Dict, Document, MAX_DECODED, Object, XrefEntry}; /// The categories, in Acrobat's order. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -189,12 +190,27 @@ fn annotation_category(doc: &Document, annot: &Object) -> SpaceCategory { if widget { SpaceCategory::Forms } else { SpaceCategory::Comments } } -fn size(doc: &Document, num: u32) -> u64 { - let obj = doc.get(ObjRef::new(num, doc.generation(num))); +fn serialized_len(doc: &Document, num: u32) -> Option { + let obj = doc.try_get(num).ok()?; let mut out = Vec::new(); pdfcraft_cos::serialize(&obj, &mut out); + Some(out.len() as u64) +} + +fn size(doc: &Document, num: u32) -> u64 { // "n g obj\n … \nendobj\n" and its cross-reference entry. - out.len() as u64 + 20 + 20 + serialized_len(doc, num).map_or(0, |len| len + 20 + 20) +} + +/// The unedited object stream `num` is packed in, when `num` itself has no unsaved edit. +fn object_stream(doc: &Document, num: u32) -> Option { + if doc.is_edited(num) { + return None; + } + match doc.xref_entry(num) { + Some(XrefEntry::InStream { stream, .. }) if !doc.is_edited(stream) => Some(stream), + _ => None, + } } /// Audit the space a document uses; `file_len` is the size of the file on disk (the rest is @@ -253,9 +269,57 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { a.claim(e, SpaceCategory::EmbeddedFiles); } let mut bytes: HashMap = HashMap::new(); + let mut streams: HashMap = HashMap::new(); + for &num in a.owner.keys() { + if let Some(stream) = object_stream(doc, num) + && matches!(doc.try_get(stream).as_deref(), Ok(Object::Stream(_))) + { + streams.entry(stream).or_insert(([0; SpaceCategory::ALL.len()], 0, 0)); + } + } for num in doc.object_numbers() { - if let Some(c) = a.owner.get(&num) { - *bytes.entry(*c).or_default() += size(doc, num); + if let Some(stream) = object_stream(doc, num) + && let Some((by_cat, total_u, count)) = streams.get_mut(&stream) + { + if let Some(len) = serialized_len(doc, num) { + let u = len + 1; + *total_u += u; + *count += 1; + if let Some(&c) = a.owner.get(&num) { + by_cat[c as usize] += u; + } + } + } else if !streams.contains_key(&num) + && let Some(&c) = a.owner.get(&num) + { + *bytes.entry(c).or_default() += size(doc, num); + } + } + for (stream, (by_cat, mut total_u, count)) in streams { + let Ok(stm_obj) = doc.try_get(stream) else { continue }; + let Object::Stream(stm) = &*stm_obj else { continue }; + let declared_n = usize::try_from(stm.dict.int(b"N").unwrap_or(0).max(0)).unwrap_or(0); + if count < declared_n + && let Ok(decoded) = stm.decoded_within(MAX_DECODED) + { + let first = usize::try_from(stm.dict.int(b"First").unwrap_or(0).max(0)).unwrap_or(0); + total_u = total_u.max(decoded.len().saturating_sub(first) as u64); + } + if total_u == 0 { + continue; + } + let raw_len = u128::from(stm.raw.len() as u64); + let total_u = u128::from(total_u); + let mut acc: u128 = 0; + for c in SpaceCategory::ALL { + let cat_u = u128::from(by_cat[c as usize]); + if cat_u == 0 { + continue; + } + let prev = (acc * raw_len) / total_u; + acc += cat_u; + let next = (acc * raw_len) / total_u; + *bytes.entry(c).or_default() += (next - prev) as u64; } } let counted: u64 = bytes.values().sum(); @@ -269,3 +333,220 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, Stream, write_full}; + + use super::*; + + fn one_page_doc() -> (Document, ObjRef) { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT ET"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + d.set(b"Count".to_vec(), Object::Int(1)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + (doc, page) + } + + #[test] + fn space_audit_sizes_objects_packed_in_compressed_object_streams_as_compressed() { + let (mut doc, page) = one_page_doc(); + let outlines = doc.add(Object::Null); + let mut items = Vec::new(); + for i in 0..80 { + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(PdfString::literal(format!("Chapter {i}: Section heading with repeated text").into_bytes()))); + item.set(b"Parent".to_vec(), Object::Ref(outlines)); + item.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + items.push(doc.add(Object::Dict(item))); + } + let mut root_outlines = Dict::new(); + root_outlines.set(b"First".to_vec(), Object::Ref(items[0])); + root_outlines.set(b"Last".to_vec(), Object::Ref(*items.last().unwrap())); + root_outlines.set(b"Count".to_vec(), Object::Int(items.len() as i64)); + doc.set(outlines, Object::Dict(root_outlines)); + for pair in items.windows(2) { + doc.update_dict(pair[0], |d| d.set(b"Next".to_vec(), Object::Ref(pair[1]))).unwrap(); + doc.update_dict(pair[1], |d| d.set(b"Prev".to_vec(), Object::Ref(pair[0]))).unwrap(); + } + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Ref(outlines))).unwrap(); + + let unpacked = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let packed = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(packed.len() * 3 < unpacked.len(), "{} vs {}", packed.len(), unpacked.len()); + + let unpacked_doc = Document::open(Arc::new(unpacked.clone())).unwrap(); + let unpacked_bookmarks = + audit_space(&unpacked_doc, unpacked.len() as u64).into_iter().find(|u| u.category == SpaceCategory::Bookmarks).unwrap().bytes; + + let reopened = Document::open(Arc::new(packed.clone())).unwrap(); + let audit = audit_space(&reopened, packed.len() as u64); + let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().clone(); + let bookmarks = get(SpaceCategory::Bookmarks); + let overhead = get(SpaceCategory::DocumentOverhead); + + assert!(bookmarks.bytes > 0 && bookmarks.bytes < packed.len() as u64, "{audit:?}"); + assert!(bookmarks.bytes * 3 < unpacked_bookmarks, "{} vs {}", bookmarks.bytes, unpacked_bookmarks); + assert!(overhead.bytes > 0, "packed bookmarks must not swallow the file's overhead: {audit:?}"); + assert_eq!(audit.iter().map(|u| u.bytes).sum::(), packed.len() as u64); + } + + #[test] + fn space_audit_shares_an_object_stream_across_categories_and_survives_freed_or_edited_members() { + let (mut doc, page) = one_page_doc(); + // A small bookmark and a large structure tree packed into the same object stream. + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(PdfString::literal(b"Intro".to_vec()))); + item.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let item = doc.add(Object::Dict(item)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Ref(item)); + outlines.set(b"Last".to_vec(), Object::Ref(item)); + outlines.set(b"Count".to_vec(), Object::Int(1)); + let outlines = doc.add(Object::Dict(outlines)); + doc.update_dict(item, |d| d.set(b"Parent".to_vec(), Object::Ref(outlines))).unwrap(); + + let mut kids = Vec::new(); + for i in 0..40 { + let mut elem = Dict::new(); + elem.set(b"Type".to_vec(), Object::name("StructElem")); + elem.set(b"S".to_vec(), Object::name("P")); + elem.set(b"Alt".to_vec(), Object::String(PdfString::literal(format!("Paragraph {i} with descriptive alternate text").into_bytes()))); + kids.push(Object::Ref(doc.add(Object::Dict(elem)))); + } + let mut struct_root = Dict::new(); + struct_root.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + struct_root.set(b"K".to_vec(), Object::Array(kids.clone())); + let struct_root = doc.add(Object::Dict(struct_root)); + + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + c.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root)); + }) + .unwrap(); + + let packed = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(packed.clone())).unwrap(); + let before = audit_space(&reopened, packed.len() as u64); + let bytes_of = |rows: &[SpaceUse], c: SpaceCategory| rows.iter().find(|u| u.category == c).unwrap().bytes; + let bookmarks_before = bytes_of(&before, SpaceCategory::Bookmarks); + let struct_before = bytes_of(&before, SpaceCategory::StructureInfo); + assert!(bookmarks_before > 0 && struct_before > bookmarks_before * 5, "{before:?}"); + + // Freeing the structure elements in memory leaves their dead bytes in the object stream on + // disk as overhead; the surviving bookmark keeps its own small share. + let mut freed_struct = reopened.clone(); + let cat_ref = freed_struct.root().unwrap(); + let sr = freed_struct.get(cat_ref).as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + let elem_refs: Vec = + freed_struct.get(sr).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + freed_struct + .update_dict(cat_ref, |c| { + c.remove(b"StructTreeRoot"); + }) + .unwrap(); + freed_struct.free(sr); + for r in elem_refs { + freed_struct.free(r); + } + let after_free = audit_space(&freed_struct, packed.len() as u64); + assert_eq!(bytes_of(&after_free, SpaceCategory::StructureInfo), 0); + assert_eq!(bytes_of(&after_free, SpaceCategory::Bookmarks), bookmarks_before, "bookmark share stays unchanged after freeing other members"); + + // Editing the bookmark in memory moves it out of the object stream and sizes its new value. + let mut edited = reopened; + let outlines_ref = edited.get(cat_ref).as_dict().unwrap().reference(b"Outlines").unwrap(); + let first_ref = edited.get(outlines_ref).as_dict().unwrap().reference(b"First").unwrap(); + edited + .update_dict(first_ref, |d| { + d.set(b"Title".to_vec(), Object::String(PdfString::literal(vec![b'X'; 400]))); + }) + .unwrap(); + let after_edit = audit_space(&edited, packed.len() as u64); + assert!(bytes_of(&after_edit, SpaceCategory::Bookmarks) > bookmarks_before + 400); + assert_eq!(bytes_of(&after_edit, SpaceCategory::StructureInfo), struct_before); + + // A malformed reference pointing directly at the object stream itself must not count the + // stream a second time alongside its members. + let Some(XrefEntry::InStream { stream: stm_num, .. }) = edited.xref_entry(outlines_ref.num) else { + panic!("expected outlines in an object stream"); + }; + let mut self_ref = edited.clone(); + self_ref + .update_dict(outlines_ref, |d| { + d.set(b"Last".to_vec(), Object::Ref(ObjRef::new(stm_num, 0))); + }) + .unwrap(); + let after_self_ref = audit_space(&self_ref, packed.len() as u64); + assert_eq!(bytes_of(&after_self_ref, SpaceCategory::StructureInfo), struct_before); + + // Editing the object stream itself detaches its members from compressed stream sizing. + edited + .update_dict(ObjRef::new(stm_num, 0), |d| { + d.set(b"First".to_vec(), Object::Int(999_999)); + }) + .unwrap(); + let after_stream_edit = audit_space(&edited, 0); + assert!(bytes_of(&after_stream_edit, SpaceCategory::StructureInfo) > struct_before); + } + + #[test] + fn space_audit_skips_corrupted_object_streams() { + let (mut doc, page) = one_page_doc(); + // Fill the first object stream (100 objects) with structure elements so the bookmark item + // lands in the second object stream while the catalog stays readable in the first. + let mut kids = Vec::new(); + for i in 0..105 { + let mut elem = Dict::new(); + elem.set(b"Type".to_vec(), Object::name("StructElem")); + elem.set(b"S".to_vec(), Object::name("P")); + elem.set(b"Alt".to_vec(), Object::String(PdfString::literal(format!("Item {i}").into_bytes()))); + kids.push(Object::Ref(doc.add(Object::Dict(elem)))); + } + let mut struct_root = Dict::new(); + struct_root.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + struct_root.set(b"K".to_vec(), Object::Array(kids)); + let struct_root = doc.add(Object::Dict(struct_root)); + + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(PdfString::literal(b"Broken".to_vec()))); + item.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let item = doc.add(Object::Dict(item)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Ref(item)); + outlines.set(b"Last".to_vec(), Object::Ref(item)); + let outlines = doc.add(Object::Dict(outlines)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root)); + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + }) + .unwrap(); + + let mut packed = write_full(&doc, &SaveOptions::default()).unwrap(); + // Corrupt the last object stream's Flate payload while keeping the catalog's stream intact. + let marker = b"/Type /ObjStm"; + let pos = packed.windows(marker.len()).rposition(|w| w == marker).unwrap(); + let stream_kw = packed[pos..].windows(8).position(|w| w == b"\nstream\n").unwrap() + pos + 8; + for b in &mut packed[stream_kw..stream_kw + 8] { + *b ^= 0xFF; + } + let corrupted = Document::open(Arc::new(packed.clone())).unwrap(); + let audit = audit_space(&corrupted, packed.len() as u64); + assert_eq!(audit.iter().map(|u| u.bytes).sum::(), packed.len() as u64); + } +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 10388b8107..f72579778a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -764,8 +764,18 @@ fn process( if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } - let resize = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0; + let resize = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && settings.target_ppi.is_finite(); let recompress = matches!(settings.compression, Compression::Jpeg(_)) || (matches!(settings.compression, Compression::Flate) && was_dct); + let (nw, nh) = if resize { + // Downsampling never enlarges: a target above the image's own resolution (or a hostile + // settings value) must not size a buffer from the setting. + let k = (settings.target_ppi / ppi).min(1.0); + (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1)) + } else { + (w, h) + }; + // A target at or above the image's own resolution resizes nothing. + let resize = resize && (nw, nh) != (w, h); if !resize && !recompress { return None; } @@ -773,12 +783,6 @@ fn process( if !resize && (w as u64) * (h as u64) < 64 * 64 { return None; } - let (nw, nh) = if resize { - let k = settings.target_ppi / ppi; - (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1)) - } else { - (w, h) - }; let px = pixels(doc, s, n, budget)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; diff --git a/crates/optimize/tests/hostile.rs b/crates/optimize/tests/hostile.rs new file mode 100644 index 0000000000..0600fd4d17 --- /dev/null +++ b/crates/optimize/tests/hostile.rs @@ -0,0 +1,129 @@ +//! The optimizer on absurd image sizes, drawing matrices and settings: it may refuse or leave +//! things alone, but never panics, hangs or allocates without bound. + +use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_optimize::{Compression, ImageSettings, Settings, optimize}; + +fn image_dict(w: i64, h: i64, bpc: i64, cs: &str) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w)); + d.set(b"Height".to_vec(), Object::Int(h)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + d +} + +fn doc_with(img: Stream, content: &str) -> Document { + doc_with_image(img, content).0 +} + +/// Like [`doc_with`], also returning the image's object so a test can read it back. +fn doc_with_image(img: Stream, content: &str) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = Document::new_empty(); + let im = doc.add(Object::Stream(img)); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(b"Im".to_vec(), Object::Ref(im)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(c)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + (doc, im) +} + +fn settings(target: f64, above: f64, compression: Compression) -> Settings { + let s = ImageSettings { downsample: true, target_ppi: target, above_ppi: above, compression }; + Settings { color: s, gray: s, ..Settings::default() } +} + +#[test] +fn absurd_image_sizes_never_panic() { + let dims = + [(0, 0), (-1, -1), (1, 0), (0, 1), (i64::MAX, 1), (1, i64::MAX), (i64::MAX, i64::MAX), (1_000_000_000, 1_000_000_000), (100_000, 100_000)]; + for (w, h) in dims { + for cs in ["DeviceGray", "DeviceRGB", "DeviceCMYK"] { + for bpc in [0, 1, 8, 16, 99, -8] { + let img = Stream::flate(image_dict(w, h, bpc, cs), &[1, 2, 3, 4]); + let mut doc = doc_with(img, "q 72 0 0 72 0 0 cm /Im Do Q"); + let _ = optimize(&mut doc, &Settings::default()); + } + } + } +} + +#[test] +fn absurd_drawing_matrices_never_panic() { + let ctms = [ + "0 0 0 0 0 0 cm", + "1e300 0 0 1e300 0 0 cm", + "1e-300 0 0 1e-300 0 0 cm", + "-1e300 0 0 1e300 1e300 1e300 cm", + "1e999 0 0 1e999 0 0 cm", + "1 1e300 1e300 1 0 0 cm", + "72 0 0 0 0 0 cm", + ]; + for ctm in ctms { + let mut px = Vec::new(); + px.extend(std::iter::repeat_n(7u8, 64 * 64 * 3)); + let img = Stream::flate(image_dict(64, 64, 8, "DeviceRGB"), &px); + let mut doc = doc_with(img, &format!("q {ctm} /Im Do Q")); + let _ = optimize(&mut doc, &Settings::default()); + } +} + +#[test] +fn absurd_settings_never_panic() { + let nums = [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, f64::MIN_POSITIVE]; + let comps = [Compression::Jpeg(0), Compression::Jpeg(255), Compression::Flate, Compression::Retain]; + for t in nums { + for a in nums { + for c in comps { + let img = Stream::flate(image_dict(300, 300, 8, "DeviceRGB"), &vec![9u8; 300 * 300 * 3]); + let mut doc = doc_with(img, "q 72 0 0 72 0 0 cm /Im Do Q"); + let _ = optimize(&mut doc, &settings(t, a, c)); + } + } + } +} + +/// An image drawn 72 pt wide is 300 ppi. Returns its (width, height) and raw bytes after optimizing. +fn optimized_300ppi(target: f64) -> ((i64, i64), Vec) { + let img = Stream::flate(image_dict(300, 300, 8, "DeviceRGB"), &vec![9u8; 300 * 300 * 3]); + let (mut doc, im) = doc_with_image(img, "q 72 0 0 72 0 0 cm /Im Do Q"); + let _ = optimize(&mut doc, &settings(target, 225.0, Compression::Retain)); + let o = doc.get(im); + let Object::Stream(s) = &*o else { panic!("the image is no longer a stream") }; + ((s.dict.int(b"Width").unwrap_or(-1), s.dict.int(b"Height").unwrap_or(-1)), s.raw.to_vec()) +} + +#[test] +fn a_hostile_target_ppi_leaves_the_image_as_it_is() { + let (_, original) = optimized_300ppi(300.0); + for target in [1e9, f64::INFINITY, f64::NAN, -1.0, 0.0] { + let (dims, raw) = optimized_300ppi(target); + assert_eq!(dims, (300, 300), "target_ppi {target}"); + assert_eq!(raw, original, "target_ppi {target} must not re-encode the image"); + } +} + +#[test] +fn a_normal_target_downsamples_and_an_equal_target_changes_nothing() { + let (_, original) = optimized_300ppi(300.0); + assert_eq!(optimized_300ppi(150.0).0, (150, 150)); + let (dims, raw) = optimized_300ppi(300.0); + assert_eq!(dims, (300, 300)); + assert_eq!(raw, original, "a target equal to the image's own resolution must not resize or re-encode it"); +} diff --git a/crates/organize/src/boxes.rs b/crates/organize/src/boxes.rs index c9ccb0f154..ff56c73f67 100644 --- a/crates/organize/src/boxes.rs +++ b/crates/organize/src/boxes.rs @@ -29,7 +29,8 @@ impl PageBox { } pub fn from_name(s: &str) -> Option { - match s.to_ascii_lowercase().trim_end_matches("box") { + let lower = s.trim().to_ascii_lowercase(); + match lower.strip_suffix("box").unwrap_or(&lower) { "media" => Some(Self::Media), "crop" => Some(Self::Crop), "bleed" => Some(Self::Bleed), @@ -51,16 +52,30 @@ pub enum BoxSpec { Margins([f64; 4]), } +/// Parses a 4-number PDF rectangle `[x0, y0, x1, y1]` into `[xmin, ymin, xmax, ymax]` when it has +/// finite coordinates and positive width and height. fn rect_of(o: Option<&Object>, doc: &Document) -> Option<[f64; 4]> { let o = doc.resolve(o?); let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + if v.len() != 4 || !v.iter().all(|x| x.is_finite()) { + return None; + } + let r = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()).then_some(r) } +/// Returns the coordinate-wise intersection of two normalized `[xmin, ymin, xmax, ymax]` rectangles. fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] } +/// Clips rectangle `a` to `b`, returning `None` when their intersection has no positive area. +fn clip(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = intersect(a, b); + (r[2] > r[0] && r[3] > r[1]).then_some(r) +} + /// The effective boxes of every page: media, crop, bleed, trim, art. pub fn page_boxes(doc: &Document) -> Result, OrganizeError> { Ok(walk(doc)? @@ -70,8 +85,8 @@ pub fn page_boxes(doc: &Document) -> Result, OrganizeError> { let d = obj.as_dict().cloned().unwrap_or_default(); let own = |k: &[u8]| rect_of(d.get(k).or_else(|| inherited.get(k)), doc); let media = own(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); - let crop = own(b"CropBox").map(|c| intersect(c, media)).unwrap_or(media); - let child = |k: &[u8]| rect_of(d.get(k), doc).map(|c| intersect(c, media)).unwrap_or(crop); + let crop = own(b"CropBox").and_then(|c| clip(c, media)).unwrap_or(media); + let child = |k: &[u8]| rect_of(d.get(k), doc).and_then(|c| clip(c, media)).unwrap_or(crop); [media, crop, child(b"BleedBox"), child(b"TrimBox"), child(b"ArtBox")] }) .collect()) @@ -106,22 +121,34 @@ pub fn set_page_box(doc: &mut Document, pages: &[usize], which: PageBox, spec: B Some([media[0] + m[0], media[1] + m[1], media[2] - m[2], media[3] - m[3]]) } }; - if let Some(r) = rect - && (r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0) - { - return Err(bad(&format!("page {} would be smaller than a point", i + 1))); + if let Some(r) = rect { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + if !w.is_finite() || !h.is_finite() { + return Err(bad("the rectangle is not a number")); + } + if w < 1.0 || h < 1.0 { + return Err(bad(&format!("page {} would be smaller than a point", i + 1))); + } } - plan.push((all[i].0, all[i].1.contains(which.key()), rect, media)); + plan.push((i, all[i].0, rect, media)); } - for (page, inherited, rect, media) in plan { + for &(_, page, rect, _) in &plan { doc.update_dict(page, |d| match rect { Some(r) => d.set(which.key().to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())), - // An inherited crop box would come back: pin the default explicitly instead. - None if inherited => d.set(which.key().to_vec(), Object::Array(media.iter().map(|v| Object::Real(*v)).collect())), None => { d.remove(which.key()); } })?; } + if spec == BoxSpec::Remove && which == PageBox::Crop { + // An inherited crop box from an ancestor `/Pages` node would come back after removal: + // pin the page's media box explicitly only when an ancestor still supplies a valid CropBox. + let after = walk(doc)?; + for &(i, page, _, media) in &plan { + if after.get(i).and_then(|(_, inh)| rect_of(inh.get(b"CropBox"), doc)).is_some() { + doc.update_dict(page, |d| d.set(b"CropBox".to_vec(), Object::Array(media.iter().map(|v| Object::Real(*v)).collect())))?; + } + } + } Ok(()) } diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 6ec4d3bd92..c9b4b85a67 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -103,8 +103,8 @@ fn added_objects(dst: &Document, before: &HashSet) -> Vec { /// Insert Pages from File: [`import_pages_with`], and the inserted file's bookmarks come along /// under one new bookmark titled `title` (the file's name) at its first inserted page, as Combine /// Files does for each file. That bookmark goes among the top-level bookmarks where the pages went: -/// before the first one that points after them. Bookmarks to pages left out lose their destination; -/// a file without bookmarks adds none. +/// before the first one that points after them. Bookmarks to pages left out stay behind, unless a +/// bookmark below them leads to an inserted page; a file without bookmarks adds none. pub fn insert_file_pages( dst: &mut Document, src: &Document, diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 15a412e231..00fb9bd0b8 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -98,9 +98,13 @@ pub(super) fn source_outline(src: &Document, pages: HashMap) -> .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); + let whole = crate::page_count(src).is_ok_and(|n| pages.len() >= n); let mapper = Copier::new(src, HashMap::new(), pages); let mut budget = 10_000usize; - outline_level(&mapper, first, 0, &mut budget) + let outline = outline_level(&mapper, first, 0, &mut budget); + // Of some of the pages, only the bookmarks that lead to a copied page and those above them: + // the others would carry their titles along into a file without their pages. + if whole { outline } else { kept_bookmarks(outline) } } /// `nodes` without the bookmarks whose page was not copied (no destination left), unless a bookmark diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 06972ae101..7782e5de18 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -1,5 +1,5 @@ use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; -use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; +use super::{Dict, Document, FieldConflicts, HashMap, HashSet, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the @@ -95,7 +95,8 @@ pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> } /// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; -/// `None` for all of them). Bookmarks that point at pages left out lose their destination. +/// `None` for all of them). A source whose pages are not all used brings only the bookmarks that +/// lead to its copied pages (and those above them), and none of its attachments. pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) } @@ -187,7 +188,11 @@ impl Combiner { let outline = source_outline(src, imported.page_map); self.marks.push(Mark { title: title.to_string(), page: *first, outline }); } - collect_attachments(&mut self.out, src, &mut self.attachments)?; + // Attachments belong to the whole file: they come along with all of its pages, not with a + // selection of them. + if pages.iter().collect::>().len() == n { + collect_attachments(&mut self.out, src, &mut self.attachments)?; + } let declared = crate::pdfx::PrintStandard::of(src); match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { Some(same) => self.agreed &= same, @@ -227,9 +232,9 @@ pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); /// Combine Files where a file's pages may be split into several runs with other files' pages /// between them. Each file (group) is copied once, all its pages together, then the pages are put -/// in the runs' order: its links between its own pages, its form fields and its attachments stay -/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its -/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). +/// in the runs' order: its links between its own pages and its form fields stay whole across its +/// runs, its attachments come along when its runs use all of its pages, and it gets one top-level +/// bookmark (the title of its first run) at its first page, its own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). pub fn combine_grouped(runs: &[Run<'_>]) -> Result { combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) } @@ -281,7 +286,10 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu if let Some(first) = imported.pages.first() { marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); } - collect_attachments(&mut out, file.src, &mut attachments)?; + // Attachments come along with all of a file's pages, not with a selection of them. + if file.pages.iter().collect::>().len() == crate::page_count(file.src)? { + collect_attachments(&mut out, file.src, &mut attachments)?; + } let declared = crate::pdfx::PrintStandard::of(file.src); match &standard { None => standard = Some((declared, file.src, imported.objects)), diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index 8fed2112b6..96d206d249 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -107,6 +107,11 @@ impl<'a> Copier<'a> { Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + // Opened without the password its embedded files need, the source holds their data + // encrypted: copied into another document, it would read as the file. + Object::Stream(s) if self.src.reads_as_stored(s) => Err(OrganizeError::Invalid( + "an attached file is encrypted and the document was opened without its password: open it with the password to copy it".into(), + )), Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { let mut s = s.clone(); s.dict = dict; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 13c02eb662..e9e20bd9fc 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -301,7 +301,9 @@ fn view_of_explicit(doc: &Document, dest: &[Object]) -> Option<(Vec, Vec Option> { if let Some(dest) = d.get(b"Dest").and_then(|dest| explicit_dest(doc, targets, dest, 0)) && page_of_explicit(targets, &dest).is_some() @@ -309,8 +311,8 @@ fn item_dest(doc: &Document, targets: &mut Targets, d: &Dict) -> Option depth && to_parent[..depth] == from[..depth] && to_parent[depth] > from[depth] { to_parent[depth] -= 1; } - let parent = container(doc, &to_parent)?; let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); let at = index.min(kids.len()); kids.insert(at, r); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 593efd2600..ed95321006 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1591,13 +1591,12 @@ fn inserting_a_file_brings_its_bookmarks_under_one_named_after_it() { let page_of = |title: &str| marks.iter().find(|b| b.title == title).and_then(|b| b.page); assert_eq!((page_of("annex"), page_of("Chapter one"), page_of("Section"), page_of("End")), (Some(1), Some(1), Some(2), Some(4))); assert!(!marks.iter().find(|b| b.title == "annex").unwrap().open, "the file's bookmarks start collapsed"); - // Inserted last, after every bookmark; only the pages chosen keep a destination. + // Inserted last, after every bookmark; a bookmark to a page left out stays behind with it. let mut host = open(fixture()); add_bookmark(&mut host, &[], 0, "Start", 0).unwrap(); insert_file_pages(&mut host, &doc_c(), "annex", &[0], 9, FieldConflicts::Rename).unwrap(); let marks = bookmarks(&host); - assert_eq!(titles(&marks), ["Start", "annex[Chapter one[Section]]"]); - assert_eq!(marks.iter().find(|b| b.title == "Section").unwrap().page, None, "its page was left out"); + assert_eq!(titles(&marks), ["Start", "annex[Chapter one]"], "Section's page was left out"); // A file without bookmarks adds none, and a host without an outline gets one only when needed. let mut host = open(fixture()); insert_file_pages(&mut host, &doc_b(), "plain", &[0], 0, FieldConflicts::Rename).unwrap(); @@ -1731,6 +1730,15 @@ fn count_of(doc: &Document, r: ObjRef) -> Option { doc.get(r).as_dict().and_then(|d| d.int(b"Count")) } +#[test] +fn move_bookmark_to_missing_parent_keeps_outline() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "Intro", 0).unwrap(); + crate::add_bookmark(&mut d, &[], 9, "Results", 2).unwrap(); + assert!(crate::move_bookmark(&mut d, &[0], &[7], 0).is_err()); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results"]); +} + #[test] fn bookmarks_add_rename_move_delete() { let mut d = doc_a(); @@ -1956,6 +1964,32 @@ fn bookmarks_name_their_pages_through_every_kind_of_destination() { assert_eq!(listed, ["Named@Some(0)", "GoTo@Some(1)", "Appendix@Some(1)", "Hi@Some(1)", "Padded@None"]); } +/// Only a go-to's `/D` is a page of this document. A remote go-to's names a page or destination of +/// the other file (ISO 32000-2 §12.6.4.3), so `[1 /Fit]` there is not this file's page 2, and a +/// destination name is not looked up here; neither is any other action's `/D`. An action without +/// a type is still read as the go-to its `/D` suggests. +#[test] +fn only_a_go_to_action_names_a_page_here() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R /Names << /Dests 12 0 R >> >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Outlines /First 6 0 R /Last 11 0 R /Count 6 >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Title (GoTo) /Parent 3 0 R /Next 7 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Other file) /Parent 3 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>".into(), + "<< /Title (Other file, named) /Parent 3 0 R /Prev 7 0 R /Next 9 0 R /A << /S /GoToR /F (other.pdf) /D (intro) >> >>".into(), + "<< /Title (Web) /Parent 3 0 R /Prev 8 0 R /Next 10 0 R /A << /S /URI /URI (https://example.org/) /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Indirect type) /Parent 3 0 R /Prev 9 0 R /Next 11 0 R /A << /S 13 0 R /D [5 0 R /Fit] >> >>".into(), + "<< /Title (No type) /Parent 3 0 R /Prev 10 0 R /A << /D [5 0 R /Fit] >> >>".into(), + "<< /Names [(intro) [4 0 R /Fit]] >>".into(), + "/GoTo".into(), + ]; + let d = open(build(&objs, "/Root 1 0 R")); + let listed: Vec = crate::bookmarks(&d).iter().map(|b| format!("{}@{:?}", b.title, b.page)).collect(); + assert_eq!(listed, ["GoTo@Some(1)", "Other file@None", "Other file, named@None", "Web@None", "Indirect type@Some(1)", "No type@Some(1)"]); +} + // ---- page labels (M4.4) ------------------------------------------------------------------------ #[test] @@ -2334,6 +2368,62 @@ fn page_boxes_default_inherit_and_set() { assert_eq!(set_page_box(&mut doc, &[7], PageBox::Crop, BoxSpec::Remove), Err(OrganizeError::NoSuchPage(7))); } +#[test] +fn page_boxes_remove_uninherited_crop_clip_disjoint_boxes_and_reject_overflow() { + let mut doc = open(fixture()); + set_page_box(&mut doc, &[0], PageBox::Crop, BoxSpec::Margins([10.0, 20.0, 30.0, 40.0])).unwrap(); + set_page_box(&mut doc, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + assert!(!page_dict(&doc, 0).contains(b"CropBox"), "removing an uninherited CropBox deletes the key instead of pinning the old MediaBox"); + set_page_box(&mut doc, &[0], PageBox::Media, BoxSpec::Rect([0.0, 0.0, 400.0, 500.0])).unwrap(); + assert_eq!(page_boxes(&doc).unwrap()[0][1], [0.0, 0.0, 400.0, 500.0], "CropBox tracks the resized MediaBox after removal"); + + // When /Pages itself carries an inherited /CropBox, removing a page's CropBox pins the page's MediaBox + // even when the page also had its own /CropBox and omitted /Parent. + let mut d = open(build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] /CropBox [20 20 280 380] >>".into(), + "<< /Type /Page /CropBox [40 40 260 360] >>".into(), + ], + "/Root 1 0 R", + )); + assert_eq!(page_boxes(&d).unwrap()[0][1], [40.0, 40.0, 260.0, 360.0]); + set_page_box(&mut d, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + assert_eq!(page_boxes(&d).unwrap()[0][1], [0.0, 0.0, 300.0, 400.0]); + + // An inherited /CropBox that is currently disjoint from the page's /MediaBox still causes removal to pin the MediaBox. + let mut d_disjoint_inh = open(build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] /CropBox [350 350 450 450] >>".into(), + "<< /Type /Page /CropBox [40 40 260 360] >>".into(), + ], + "/Root 1 0 R", + )); + set_page_box(&mut d_disjoint_inh, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + set_page_box(&mut d_disjoint_inh, &[0], PageBox::Media, BoxSpec::Rect([0.0, 0.0, 500.0, 500.0])).unwrap(); + assert_eq!(page_boxes(&d_disjoint_inh).unwrap()[0][1], [0.0, 0.0, 300.0, 400.0]); + + // Disjoint or zero-area boxes in a file fall back to MediaBox/CropBox instead of producing inverted rectangles. + let disjoint = open(build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /CropBox [500 500 600 600] /TrimBox [700 700 800 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /CropBox [10 20 290 380] /TrimBox [500 500 600 600] /ArtBox [50 50 50 100] >>".into(), + ], + "/Root 1 0 R", + )); + let boxes = page_boxes(&disjoint).unwrap(); + assert_eq!(boxes[0][1], [0.0, 0.0, 300.0, 400.0], "disjoint CropBox falls back to MediaBox"); + assert_eq!(boxes[0][3], [0.0, 0.0, 300.0, 400.0], "disjoint TrimBox falls back to CropBox"); + assert_eq!(boxes[1][3], [10.0, 20.0, 290.0, 380.0], "disjoint TrimBox falls back to page's CropBox"); + assert_eq!(boxes[1][4], [10.0, 20.0, 290.0, 380.0], "zero-width ArtBox falls back to page's CropBox"); + + // Finite coordinates whose span overflows f64 are rejected. + assert!(matches!(set_page_box(&mut doc, &[0], PageBox::Media, BoxSpec::Rect([-1e308, 0.0, 1e308, 100.0])), Err(OrganizeError::InvalidBox(_)))); +} + #[test] fn duplicate_pages_inserts_copies_after_the_last() { let mut doc = open(fixture()); @@ -2467,6 +2557,56 @@ fn an_unchanged_opening_destination_keeps_its_position_and_name() { } } +/// A percentage is written as the `/XYZ` zoom factor (ISO 32000-2 Table 149), not the percentage: +/// 25% as 0.25 and 100% as 1, and each survives a save. +#[test] +fn initial_view_percentages_are_written_as_zoom_factors() { + use crate::view::Magnification; + for (percent, factor) in [(25.0, 0.25), (100.0, 1.0), (150.0, 1.5)] { + let mut doc = open(fixture()); + let v = crate::initial_view(&doc); + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::Percent(percent), ..v }).unwrap(); + let doc = save_and_reopen(&doc); + let root = doc.root().unwrap(); + let open_action = doc.get(root).as_dict().and_then(|c| c.get(b"OpenAction").cloned()).map(|o| doc.resolve(&o).as_ref().clone()); + let zoom = open_action.as_ref().and_then(Object::as_array).and_then(|a| a.get(4)).and_then(Object::as_f64); + assert_eq!(zoom, Some(factor), "{percent}%: {open_action:?}"); + } +} + +#[test] +fn choosing_100_percent_keeps_an_actual_size_opening_destination() { + use crate::view::Magnification; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(1)]); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"S".to_vec(), Object::name("GoTo")); + named.set(b"D".to_vec(), Object::name("there")); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(1)])); + for action in [xyz, Object::Dict(named)] { + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action.clone()); + c.set(b"Dests".to_vec(), Object::Dict(dests.clone())); + }) + .unwrap(); + let v = crate::initial_view(&doc); + assert_eq!(v.magnification, Magnification::ActualSize); + // 100% is the same zoom: the action keeps its position or name. + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::Percent(100.0), ..v }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_eq!(catalog.get(b"OpenAction"), Some(&action)); + } + // Without one, 100% writes zoom 1, which reads back as Actual Size. + let mut doc = open(fixture()); + let v = crate::initial_view(&doc); + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::Percent(100.0), ..v }).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::ActualSize); +} + /// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at /// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree. fn doc_opening_at(action: Object) -> Document { @@ -4312,3 +4452,42 @@ fn a_form_action_brings_no_field_from_a_page_left_behind() { // With every page, the actions still list it. assert!(holds_value(&full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()), "90000")); } + +/// The titles of a bookmark's children, each with its own children's titles. +fn bookmark_titles(doc: &Document, first: Option) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + let mut at = first; + while let Some(r) = at { + let item = doc.get(r).as_dict().cloned().unwrap(); + let title = item.get(b"Title").and_then(|t| t.as_string()).map(|t| t.to_text()).unwrap_or_default(); + let children = bookmark_titles(doc, item.reference(b"First")).into_iter().map(|(t, _)| t).collect(); + out.push((title, children)); + at = item.reference(b"Next"); + } + out +} + +/// Combining some of a file's pages brings only the bookmarks that lead to them (and those above +/// them), and none of the file's attachments. +#[test] +fn combining_some_pages_brings_only_their_bookmarks_and_no_attachments() { + let source_bookmarks = |out: &Document| { + let outlines = out.get(catalog(out).reference(b"Outlines").unwrap()).as_dict().cloned().unwrap(); + let file = out.get(outlines.reference(b"First").unwrap()).as_dict().cloned().unwrap(); + bookmark_titles(out, file.reference(b"First")) + }; + let attachments = |out: &Document| { + catalog(out).get(b"Names").map(|n| out.resolve(n)).is_some_and(|n| n.as_dict().is_some_and(|d| d.contains(b"EmbeddedFiles"))) + }; + // Page 1 only: "Section" leads to page 2. + let first = full_roundtrip(&combine_selected(&[("C", &doc_c(), Some(&[0]))]).unwrap()); + assert_eq!(source_bookmarks(&first), [("Chapter one".to_string(), vec![])]); + assert!(!attachments(&first), "an attachment came along with a selection of pages"); + // Page 2 only: "Chapter one" stays, as the way to "Section". + let second = full_roundtrip(&combine_selected(&[("C", &doc_c(), Some(&[1]))]).unwrap()); + assert_eq!(source_bookmarks(&second), [("Chapter one".to_string(), vec!["Section".to_string()])]); + // Every page: all of it, attachments too. + let all = full_roundtrip(&combine_selected(&[("C", &doc_c(), Some(&[0, 1]))]).unwrap()); + assert_eq!(source_bookmarks(&all), [("Chapter one".to_string(), vec!["Section".to_string()])]); + assert!(attachments(&all)); +} diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index 899293b980..bb39dcdf9a 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -163,13 +163,23 @@ pub fn displays_doc_title(doc: &Document) -> bool { .unwrap_or(false) } +/// Whether `a` and `b` open at the same magnification. 100% writes the zoom Actual Size writes, +/// which [`initial_view`] reads back as Actual Size. +fn same_magnification(a: Magnification, b: Magnification) -> bool { + let actual = |m: Magnification| match m { + Magnification::Percent(p) if (p / 100.0 - 1.0).abs() < 1e-6 => Magnification::ActualSize, + m => m, + }; + actual(a) == actual(b) +} + /// Write the initial view. An opening action that isn't a plain destination (a script, say) is /// kept unless the page or magnification asks for a destination, and an opening action whose page /// and magnification `v` leaves as [`initial_view`] reads them is kept as it is. pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { let same_destination = { let now = initial_view(doc); - now.page == v.page && now.magnification == v.magnification + now.page == v.page && same_magnification(now.magnification, v.magnification) }; let (root, c) = catalog(doc)?; let pages = crate::walk(doc)?; diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 5b6f3930c8..adda9ab862 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -195,8 +195,9 @@ fn unescape(text: &str) -> String { _ => entity .strip_prefix("#x") .or_else(|| entity.strip_prefix("#X")) + .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) .and_then(|h| u32::from_str_radix(h, 16).ok()) - .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .or_else(|| entity.strip_prefix('#').filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse().ok())) .and_then(char::from_u32), }; match c { @@ -294,24 +295,31 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { /// start and end of its value, and its quote character. Only matches inside a start tag. fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { let mut from = 0; - // The last `<` and `>` before the match, found by one pass over the packet rather than by - // searching back from every match. - let (mut scanned, mut last_open, mut last_close) = (0, None, None); + // Track tag and quote state in one pass rather than searching back from every match. + let (mut scanned, mut in_tag, mut quote) = (0, false, None); while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { from = i + name.len(); let before = xmp.get(..i)?; let gap = xmp.get(scanned..i)?; #[cfg(test)] crate::work(gap.len()); - if let Some(at) = gap.rfind('<') { - last_open = Some(scanned + at); - } - if let Some(at) = gap.rfind('>') { - last_close = Some(scanned + at); + for c in gap.chars() { + if let Some(q) = quote { + if c == q { + quote = None; + } + } else { + match c { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + '\'' | '\"' if in_tag => quote = Some(c), + _ => {} + } + } } scanned = i; - // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). - if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() { + // Preceded by whitespace, and inside a start tag. + if !before.ends_with(char::is_whitespace) || !in_tag || quote.is_some() { continue; } let rest = xmp.get(from..)?; @@ -341,6 +349,13 @@ mod tests { Old subject\n\ \n \n\n"; + #[test] + fn unescape_rejects_signs_in_numeric_references() { + assert_eq!(unescape("a&#x+41;b&#+66;"), "a&#x+41;b&#+66;"); + assert_eq!(unescape("&#x;&#;"), "&#x;&#;"); + assert_eq!(unescape("AA"), "AA"); + } + #[test] fn values_are_read_back_unescaped() { assert_eq!(unescape("a & b <c> AB &bogus; � & end"), "a & b AB &bogus; � & end"); @@ -417,6 +432,19 @@ mod tests { assert!(out.contains("New"), "{out}"); } + #[test] + fn attribute_like_text_inside_another_quoted_attribute_is_not_a_property() { + let packet = ""; + assert_eq!(attribute_span(packet, "dc:title"), None); + } + + #[test] + fn apostrophe_in_text_does_not_hide_a_later_attribute() { + let packet = "don't"; + let found = attribute_span(packet, "dc:title").and_then(|(_, start, end, quote)| packet.get(start..end).map(|value| (value, quote))); + assert_eq!(found, Some(("real", '\''))); + } + #[test] fn hostile_packets_do_not_panic() { for packet in ["", "<", "", ") -> std::io::Result<()> { + #[cfg(windows)] + { + let zone = source.and_then(read_zone).unwrap_or_else(|| INTERNET_ZONE.as_bytes().to_vec()); + std::fs::write(zone_stream(target), zone) + } + #[cfg(not(windows))] + { + let _ = (target, source); + Ok(()) + } +} + +/// `path`'s `Zone.Identifier` stream, when it has one that reads as a zone. +#[cfg(windows)] +pub fn read_zone(path: &Path) -> Option> { + use std::io::Read; + let file = std::fs::File::open(zone_stream(path)).ok()?; + let mut zone = Vec::new(); + file.take(MAX_ZONE_STREAM.saturating_add(1)).read_to_end(&mut zone).ok()?; + let fits = u64::try_from(zone.len()).is_ok_and(|n| n <= MAX_ZONE_STREAM); + (fits && has_zone_id(&zone)).then_some(zone) +} + +/// Whether `stream` has a `ZoneId=` entry inside its `[ZoneTransfer]` section. Text that +/// only mentions those words elsewhere (say a `HostUrl` ending in `?ZoneId=3`) is not a zone. +#[cfg(windows)] +fn has_zone_id(stream: &[u8]) -> bool { + let mut in_section = false; + for line in stream.split(|&b| b == b'\n').map(<[u8]>::trim_ascii) { + if line.starts_with(b"[") { + in_section = line.eq_ignore_ascii_case(b"[ZoneTransfer]"); + continue; + } + let Some(eq) = line.iter().position(|&b| b == b'=') else { continue }; + let (key, value) = line.split_at(eq); + let value = value.get(1..).unwrap_or_default().trim_ascii(); + if in_section && key.trim_ascii().eq_ignore_ascii_case(b"ZoneId") && (1..=10).contains(&value.len()) && value.iter().all(u8::is_ascii_digit) { + return true; + } + } + false +} + +/// The path of `path`'s `Zone.Identifier` alternate data stream. +#[cfg(windows)] +fn zone_stream(path: &Path) -> std::path::PathBuf { + let mut stream = path.as_os_str().to_owned(); + stream.push(":Zone.Identifier"); + stream.into() +} + +#[cfg(test)] +#[cfg(windows)] +mod tests { + use super::*; + + /// A fresh folder in the temp directory, removed on drop. + struct Dir(std::path::PathBuf); + + impl Dir { + fn new(name: &str) -> Dir { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!("pdfcraft-zone-{name}-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Dir(path) + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn a_file_from_a_document_without_a_zone_gets_the_internet_zone() { + let dir = Dir::new("none"); + let target = dir.0.join("invoice.exe"); + std::fs::write(&target, b"MZ").unwrap(); + mark_from(&target, None).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + let source = dir.0.join("local.pdf"); + std::fs::write(&source, b"%PDF").unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + assert_eq!(std::fs::read(&target).unwrap(), b"MZ", "the file's own bytes are untouched"); + } + + #[test] + fn a_file_from_a_document_gets_that_documents_zone() { + let dir = Dir::new("copy"); + let source = dir.0.join("from mail.pdf"); + std::fs::write(&source, b"%PDF").unwrap(); + let zone = "[ZoneTransfer]\r\nZoneId=3\r\nReferrerUrl=https://mail.example/\r\nHostUrl=https://mail.example/a.pdf\r\n"; + std::fs::write(zone_stream(&source), zone).unwrap(); + let target = dir.0.join("macro.docm"); + std::fs::write(&target, b"PK").unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), zone.as_bytes()); + } + + #[test] + fn a_stream_that_isnt_a_zone_is_not_copied() { + let dir = Dir::new("junk"); + let source = dir.0.join("odd.pdf"); + std::fs::write(&source, b"%PDF").unwrap(); + std::fs::write(zone_stream(&source), b"not a zone").unwrap(); + let target = dir.0.join("notes.txt"); + std::fs::write(&target, b"hello").unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + for odd in [ + "[ZoneTransfer]\r\nHostUrl=https://example.test/?ZoneId=3\r\n", + "[Other]\r\nZoneId=3\r\n[ZoneTransfer]\r\nReferrerUrl=x\r\n", + "[ZoneTransfer]\r\nZoneId=\r\n", + "[ZoneTransfer]\r\nZoneId=three\r\n", + ] { + std::fs::write(zone_stream(&source), odd).unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes(), "{odd:?}"); + } + let huge = [INTERNET_ZONE.as_bytes(), &vec![b'x'; 70 * 1024]].concat(); + std::fs::write(zone_stream(&source), huge).unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + } +} diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs new file mode 100644 index 0000000000..64c19de116 --- /dev/null +++ b/crates/preflight/src/gstate.rs @@ -0,0 +1,235 @@ +//! PDF/A-2 6.2.5, the extended graphics state: device-dependent settings a conforming file may not +//! rely on. +//! +//! - no transfer function: no `/TR` entry, and `/TR2` only as `/Default` (ISO 32000-1 10.4); +//! - no `/HTP` (halftone phase) entry, a PDF 1.2 key ISO 32000-1 no longer defines; +//! - halftones of type 1 or 5 only, without a `/HalftoneName` (ISO 32000-1 10.5.5); the halftones +//! a type 5 halftone holds for its colorants are checked the same way; +//! - a halftone's `/TransferFunction` only where one is needed: a type 5 halftone's component for +//! a non-primary colorant has one, and no other halftone does. The primary colour components +//! are those of the standard device spaces: Gray; Red, Green and Blue; Cyan, Magenta, Yellow +//! and Black (ISO 32000-1 10.5.5.6; ISO 32000-2 10.6.5.6). The `Default` component is left as +//! it is (ISO 32000-1 Table 130); +//! - a rendering intent (`/RI`) that is one of the four of ISO 32000-1 8.6.5.8, Table 70. +//! +//! Graphics state dictionaries are found as the `/ExtGState` entries of resource dictionaries +//! (`/Resources` of pages, forms, Type 3 fonts, patterns and annotation appearances, and an +//! interactive form's `/DR`), and as the `/ExtGState` of a shading pattern (ISO 32000-1 8.7.4.3, +//! Table 76). They are looked for in what the document reaches from its trailer, following +//! references, but not into private application data (`/PieceInfo`), so an object only that data +//! refers to isn't read. A map shared by reference is read once. + +use std::collections::{BTreeSet, HashSet, VecDeque}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How deep a direct object is followed: as deep as the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The rendering intents of ISO 32000-1 Table 70. +const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; +/// The entries of a type 5 halftone that aren't colorant halftones. +const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; +/// The primary colour components of DeviceGray, DeviceRGB and DeviceCMYK, whose halftones take +/// the graphics state's transfer function. +const PRIMARY: [&[u8]; 8] = [b"Gray", b"Red", b"Green", b"Blue", b"Cyan", b"Magenta", b"Yellow", b"Black"]; +/// The entries that hold resource dictionaries. +const RESOURCES: [&[u8]; 2] = [b"Resources", b"DR"]; + +/// What the graphics states of a document break of 6.2.5. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// A graphics state has a `/TR` entry. + pub(crate) transfer: bool, + /// A graphics state has a `/TR2` other than `/Default`. + pub(crate) transfer2: bool, + /// A graphics state has an `/HTP`. + pub(crate) phase: bool, + /// Halftone types other than 1 and 5, in order. + pub(crate) halftone_types: BTreeSet, + /// A halftone's `/HalftoneType` is missing or isn't an integer. + pub(crate) untyped: bool, + /// A halftone has a `/HalftoneName`. + pub(crate) halftone_name: bool, + /// A halftone that may not have a `/TransferFunction` has one. + pub(crate) halftone_transfer: bool, + /// Non-primary colorants whose halftone has no `/TransferFunction`, in name order. + pub(crate) missing_transfer: BTreeSet, + /// Rendering intents outside Table 70, in name order. + pub(crate) intents: BTreeSet, + /// A rendering intent isn't a name. + pub(crate) intent_not_name: bool, +} + +/// The 6.2.5 findings over what `doc` reaches from its trailer. +pub(crate) fn check(doc: &Document) -> Findings { + scan(doc).findings +} + +/// How many objects [`check`] reads. +#[cfg(test)] +pub(crate) fn reached(doc: &Document) -> usize { + scan(doc).reached.len() +} + +fn scan(doc: &Document) -> Scan<'_> { + let mut scan = Scan { doc, read: HashSet::new(), reached: HashSet::new(), queue: VecDeque::new(), findings: Findings::default() }; + scan.walk(&Object::Dict(doc.trailer().clone()), 0); + // References are followed from a queue, not by recursion: a chain of them can be as long as + // the file. + while let Some(r) = scan.queue.pop_front() { + let o = doc.get(r); + scan.walk(&o, 0); + } + scan +} + +struct Scan<'a> { + doc: &'a Document, + /// Resource dictionaries, `/ExtGState` maps and graphics states read so far, by object number. + read: HashSet, + /// Object numbers reached so far, queued or walked. Both sets go by number, as + /// [`Document::get`] does: references differing only in generation reach the same object. + reached: HashSet, + queue: VecDeque, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + if depth > MAX_DEPTH { + return; + } + let d = match o { + Object::Ref(r) => { + if self.reached.insert(r.num) { + self.queue.push_back(*r); + } + return; + } + Object::Array(a) => { + for v in a { + self.walk(v, depth.saturating_add(1)); + } + return; + } + Object::Dict(d) => d, + Object::Stream(s) => &s.dict, + _ => return, + }; + self.holder(d); + for (k, v) in d.iter() { + if k.as_slice() != b"PieceInfo" { + self.walk(v, depth.saturating_add(1)); + } + } + } + + /// Whether `o` is a reference read before; marks it read. + fn read_before(&mut self, o: &Object) -> bool { + matches!(o, Object::Ref(r) if !self.read.insert(r.num)) + } + + /// A dictionary that holds graphics states: through its resources, or as a shading pattern. + fn holder(&mut self, d: &Dict) { + for key in RESOURCES { + if let Some(resources) = d.get(key) { + self.resources(resources); + } + } + if d.get(b"PatternType").is_some() + && let Some(gs) = d.get(b"ExtGState") + && !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { + self.gstate(gs); + } + } + + fn resources(&mut self, resources: &Object) { + if self.read_before(resources) { + return; + } + let resources = self.doc.resolve(resources); + let Some(states) = resources.as_dict().and_then(|r| r.get(b"ExtGState")) else { return }; + if self.read_before(states) { + return; + } + let states = self.doc.resolve(states); + let Some(states) = states.as_dict() else { return }; + for (_, gs) in states.iter() { + if !self.read_before(gs) + && let Some(gs) = self.doc.resolve(gs).as_dict() + { + self.gstate(gs); + } + } + } + + /// `key` of `d`, resolved, unless it is missing or null (ISO 32000-1 7.3.9). + fn present(&self, d: &Dict, key: &[u8]) -> Option> { + d.get(key).map(|v| self.doc.resolve(v)).filter(|v| !matches!(**v, Object::Null)) + } + + fn gstate(&mut self, gs: &Dict) { + if self.present(gs, b"TR").is_some() { + self.findings.transfer = true; + } + if self.present(gs, b"TR2").is_some_and(|f| f.as_name() != Some(b"Default")) { + self.findings.transfer2 = true; + } + if self.present(gs, b"HTP").is_some() { + self.findings.phase = true; + } + if let Some(ht) = self.present(gs, b"HT") + && let Some(ht) = ht.as_dict() + { + self.halftone(ht, None); + } + if let Some(ri) = self.present(gs, b"RI") { + match ri.as_name() { + Some(ri) if !INTENTS.contains(&ri) => { + self.findings.intents.insert(String::from_utf8_lossy(ri).into_owned()); + } + Some(_) => {} + None => self.findings.intent_not_name = true, + } + } + } + + /// A halftone dictionary or stream: the graphics state's own (`colorant` is `None`), or the + /// one a type 5 halftone holds for `colorant`. + fn halftone(&mut self, ht: &Dict, colorant: Option<&[u8]>) { + // An integer (ISO 32000-1 Table 128), not a real of the same value. + let kind = self.present(ht, b"HalftoneType").and_then(|t| if let Object::Int(t) = *t { Some(t) } else { None }); + match kind { + None => self.findings.untyped = true, + Some(1 | 5) => {} + Some(kind) => { + self.findings.halftone_types.insert(kind); + } + } + if self.present(ht, b"HalftoneName").is_some() { + self.findings.halftone_name = true; + } + let transfer = self.present(ht, b"TransferFunction").is_some(); + match colorant { + Some(b"Default") => {} + Some(c) if !PRIMARY.contains(&c) => { + if !transfer { + self.findings.missing_transfer.insert(String::from_utf8_lossy(c).into_owned()); + } + } + _ => self.findings.halftone_transfer |= transfer, + } + if colorant.is_none() && kind == Some(5) { + for (k, v) in ht.iter() { + if HALFTONE_KEYS.contains(&k.as_slice()) { + continue; + } + if let Some(component) = self.doc.resolve(v).as_dict() { + self.halftone(component, Some(k)); + } + } + } + } +} diff --git a/crates/preflight/src/header.rs b/crates/preflight/src/header.rs new file mode 100644 index 0000000000..3e9263ef28 --- /dev/null +++ b/crates/preflight/src/header.rs @@ -0,0 +1,71 @@ +//! PDF/A-2 6.1.2, the file header: the file starts at its first byte with `%PDF-1.n`, `n` from 0 +//! to 7, and an end-of-line marker; the next line is a comment, `%` and at least four bytes each +//! above 127, which marks the file as binary (ISO 32000-1 7.5.2). +//! +//! A document whose next save rewrites it in full is judged by the header that save writes: the +//! version it keeps, followed by the writer's own binary comment. + +use pdfcraft_cos::Document; + +/// What the header of `doc`, as it will be saved, breaks of 6.1.2. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Header { + /// The first line isn't `%PDF-1.0` to `%PDF-1.7` at the start of the file. + pub(crate) version: bool, + /// The second line isn't a comment of at least four bytes above 127. + pub(crate) comment: bool, +} + +/// What the header of `doc` breaks of 6.1.2: as stored, or, when the next save rewrites the +/// file, as that save will write it. +pub(crate) fn check(doc: &Document) -> Header { + if doc.full_save_required() || doc.encryption_changed() { + let written = doc.version().get(..3).unwrap_or_default(); + return Header { version: !allowed(written.as_bytes()), comment: false }; + } + let data = doc.bytes(); + let Some(rest) = data.strip_prefix(b"%PDF-") else { return Header { version: true, comment: false } }; + let version = rest.get(..3).unwrap_or_default(); + let Some(rest) = rest.get(3..).and_then(eol) else { + return Header { version: true, comment: !binary_comment(after_line(rest)) }; + }; + Header { version: !allowed(version), comment: !binary_comment(rest) } +} + +/// Whether a version is `1.0` to `1.7`. +fn allowed(version: &[u8]) -> bool { + matches!(version, [b'1', b'.', b'0'..=b'7']) +} + +/// What follows an end-of-line marker (CR, LF or CR LF) at the start of `data`. +fn eol(data: &[u8]) -> Option<&[u8]> { + match data { + [b'\r', b'\n', rest @ ..] | [b'\r', rest @ ..] | [b'\n', rest @ ..] => Some(rest), + _ => None, + } +} + +/// What follows the first end-of-line marker in `data`, or nothing. +fn after_line(data: &[u8]) -> &[u8] { + let end = data.iter().position(|&b| b == b'\r' || b == b'\n').unwrap_or(data.len()); + data.get(end..).and_then(eol).unwrap_or_default() +} + +/// Whether `data` starts with `%` and four bytes above 127. +fn binary_comment(data: &[u8]) -> bool { + matches!(data, [b'%', a, b, c, d, ..] if [a, b, c, d].iter().all(|&&x| x > 127)) +} + +/// Makes the next save of `doc` write a header 6.1.2 allows, when the current one breaks it: +/// a full save, at version 1.7 when the version kept isn't 1.0 to 1.7. What it fixed, if anything. +pub(crate) fn rewrite(doc: &mut Document) -> Option { + let header = check(doc); + if !header.version && !header.comment { + return None; + } + if !allowed(doc.version().as_bytes().get(..3).unwrap_or_default()) { + doc.set_version("1.7"); + } + doc.require_full_save(); + Some("Rewrote the file header".into()) +} diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 1cbe9a6ed3..d5a6d80b04 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -10,7 +10,9 @@ //! - every font embedded; //! - annotations: allowed types, printable, not hidden, with appearance streams; //! - actions: none of the forbidden kinds (JavaScript, Launch, Sound, Movie, ResetForm, -//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView); no additional actions on the +//! ImportData, Hide, SetOCGState, Rendition, Trans, GoTo3DView, and the PDF 1.2 SetState and +//! NOP that ISO 32000-1 no longer defines); Named actions only for NextPage, PrevPage, +//! FirstPage and LastPage (ISO 32000-1 12.6.4.11, Table 212); no additional actions on the //! catalog, pages, fields or widgets; `NeedAppearances` not true; //! - images: no `Interpolate`, no `Alternates`, no OPI; no PostScript XObjects; //! - embedded files: PDF/A-2 only allows embedded PDF/A files; PDF/A-3 needs `AFRelationship`. @@ -21,8 +23,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod gstate; +mod header; pub mod icc; +mod limits; +mod spot; +mod trailer; +mod transparency; mod xmp; +mod xmp_extension; mod xmp_types; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -153,14 +162,46 @@ pub const WORDINGS: &[&str] = &[ "The title in the metadata and the document information differ", "The XMP property {property} in metadata stream {object} should have the value type {type}", "The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", // The document as a whole. + "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", + "The file header isn't followed by a comment of four bytes above 127", + "The trailer has no file identifier (ID)", + "The file identifier (ID) in the trailer isn't two non-empty strings", "The document is encrypted", + "An integer is outside the 32-bit range", + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", + "A CMap maps a character code to a CID above 65535", + "A string is longer than 32767 bytes", + "A name is longer than 127 bytes", + "Page {} has a boundary smaller than 3 or larger than 14400 units", "Device colour is used, but there is no PDF/A output intent", + "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", + "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", "DeviceCMYK is used: it needs a CMYK output intent (not added automatically)", "DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile", "DeviceRGB is used, but the PDF/A output intent isn't an RGB profile", + "Separation colour spaces named {} differ in their alternate space or tint transform", + "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", "The document has additional actions (/AA)", "The document has document-level JavaScript", + "A graphics state has a transfer function (TR)", + "A graphics state has a transfer function (TR2) other than Default", + "A graphics state has a halftone phase (HTP)", + "A halftone has type {}: only types 1 and 5 are allowed", + "A halftone's HalftoneType is missing or isn't an integer", + "A halftone has a HalftoneName", + "A halftone has a transfer function, which only a non-primary colorant's halftone may have", + "The halftone for the colorant {} has no transfer function", + "A graphics state has the rendering intent {}, which isn't a standard one", + "A graphics state has a rendering intent that isn't a name", "The form asks viewers to make appearances (NeedAppearances)", // Pages, actions, annotations and fields. "Page {} has additional actions", @@ -192,8 +233,23 @@ pub struct Declared { pub output_intents: Vec, } -const FORBIDDEN_ACTIONS: &[&[u8]] = - &[b"JavaScript", b"Launch", b"Sound", b"Movie", b"ResetForm", b"ImportData", b"Hide", b"SetOCGState", b"Rendition", b"Trans", b"GoTo3DView"]; +const FORBIDDEN_ACTIONS: &[&[u8]] = &[ + b"JavaScript", + b"Launch", + b"Sound", + b"Movie", + b"ResetForm", + b"ImportData", + b"Hide", + b"SetOCGState", + b"Rendition", + b"Trans", + b"GoTo3DView", + b"SetState", + b"NOP", +]; +/// The named actions of ISO 32000-1 Table 212, the only ones a Named action may name. +const NAMED_ACTIONS: &[&[u8]] = &[b"NextPage", b"PrevPage", b"FirstPage", b"LastPage"]; const FORBIDDEN_ANNOTS: &[&[u8]] = &[b"Sound", b"Movie", b"Screen", b"3D", b"RichMedia"]; fn catalog(doc: &Document) -> Dict { @@ -250,6 +306,19 @@ fn dict_of(o: &Object) -> Option<&Dict> { } } +/// Whether the document has a PDF/A output intent whose `/DestOutputProfile` is a stream: one with +/// a null or missing profile describes no colour, so it doesn't stand in for a blending colour +/// space (6.2.10). +fn pdfa_intent_has_profile(doc: &Document) -> bool { + let cat = catalog(doc); + let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().any(|oi| { + let oi = doc.resolve(oi); + let Some(d) = oi.as_dict() else { return false }; + d.name(b"S") == Some(b"GTS_PDFA1") && d.get(b"DestOutputProfile").is_some_and(|p| matches!(&*doc.resolve(p), Object::Stream(_))) + }) +} + fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } @@ -369,6 +438,37 @@ fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) out } +/// The fonts the document draws with: those a resource dictionary names (`/Resources`, of a page or +/// a page tree node, a form XObject or an annotation's appearance stream, a pattern or a Type 3 +/// font). PDF/A asks that every font "used for rendering" be embedded (ISO 19005-2, 6.2.11.4.1). A +/// font that only the interactive form's default resources hold (`/AcroForm /DR`, ISO 32000-1 +/// 12.7.2) draws nothing, so it needn't be: an appearance stream that does draw with it names it in +/// its own `/Resources`. +fn drawing_fonts(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (_, o) in objs { + let Some(resources) = dict_of(o).and_then(|d| d.get(b"Resources")).map(|r| doc.resolve(r)) else { continue }; + let Some(fonts) = resources.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { continue }; + if let Some(fonts) = fonts.as_dict() { + out.extend(fonts.iter().filter_map(|(_, f)| f.as_ref()).map(|r| font_object(doc, r))); + } + } + out +} + +/// How many references [`font_object`] follows, as the document's own `resolve` does. +const MAX_REFERENCE_HOPS: usize = 32; + +/// The object a font entry's reference `r` ends at: an object can be nothing but a reference to +/// another (ISO 32000-1 7.3.10), and the font dictionary is the last one in that chain. +fn font_object(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..MAX_REFERENCE_HOPS { + let Object::Ref(next) = &*doc.get(r) else { break }; + r = *next; + } + r +} + /// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a /// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out /// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content. @@ -486,7 +586,48 @@ pub fn verify(doc: &Document, level: Level) -> Vec { let message = fill(pattern, None, &values); push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable }); }; - let cat = catalog(doc); + check_identification(doc, level, &mut issue); + let objs = objects(doc); + check_xmp_packets(&objs, &located_issue); + check_output_intent(doc, &objs, &mut issue); + check_catalog_actions(doc, &mut issue); + // Pages: annotations and page actions. + let pages = pdfcraft_model::pages(doc); + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); + for (i, p) in pages.iter().enumerate() { + if p.dict.get(b"AA").is_some() { + issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); + } + } + check_transparency(doc, &pages, &mut issue); + let drawing = drawing_fonts(doc, &objs); + check_objects(doc, level, &objs, &drawing, &page_of, &mut issue); + check_limits(doc, &objs, &pages, &mut issue); + check_spots(doc, &objs, &mut issue); + check_graphics_states(doc, &mut issue); + // One message per kind of problem is enough for repeated ones (as preflight summaries do). + let mut out = out.into_inner(); + let mut seen = std::collections::HashSet::new(); + out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); + out +} + +/// 6.1.2, 6.1.3 and 6.6: identification and metadata, the file header, the file identifier and +/// encryption. +fn check_identification(doc: &Document, level: Level, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.1.3 / 6.6: identification and metadata. match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), @@ -503,16 +644,37 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } } } - // 6.1.3: encryption. + // 6.1.2: the file header. + let header = header::check(doc); + if header.version { + issue("6.1.2", "The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line", None, None, true); + } + if header.comment { + issue("6.1.2", "The file header isn't followed by a comment of four bytes above 127", None, None, true); + } + // 6.1.3: the file identifier and encryption. + match trailer::check(doc) { + Some(trailer::IdProblem::Missing) => issue("6.1.3", "The trailer has no file identifier (ID)", None, None, true), + Some(trailer::IdProblem::Malformed) => { + issue("6.1.3", "The file identifier (ID) in the trailer isn't two non-empty strings", None, None, true) + } + None => {} + } if doc.trailer().get(b"Encrypt").is_some() { issue("6.1.3", "The document is encrypted", None, None, true); } - // 6.2.2: output intent when device colour is used. - let objs = objects(doc); - // 6.6.2.3.1: the value types of predefined XMP properties, in every metadata stream. - let mut mistyped = Vec::new(); - xmp_types::for_each_packet(&objs, |object, packet| { +} + +/// 6.6.2.3.1 and 6.6.2.3.3: predefined XMP value types and extension schema descriptions, in +/// every metadata stream. +fn check_xmp_packets( + objs: &[(ObjRef, std::sync::Arc)], + located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool), +) { + let (mut mistyped, mut extension) = (Vec::new(), xmp_extension::Findings::default()); + xmp_types::for_each_packet(objs, |object, packet| { mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty))); + extension.add(object, packet); }); for (object, name, written, ty) in mistyped { let mut values = vec![("{property}", name.clone()), ("{object}", object.to_string()), ("{type}", ty)]; @@ -535,7 +697,104 @@ pub fn verify(doc: &Document, level: Level) -> Vec { ); } } - let used = uses_device_colour(doc, &objs); + report_xmp_extension(extension, located_issue); +} + +/// 6.6.2.3.3: what the extension schema descriptions of the metadata streams lack. +fn report_xmp_extension( + extension: xmp_extension::Findings, + located_issue: &impl Fn(&'static str, &'static str, Vec<(&'static str, String)>, ObjRef, bool), +) { + for m in extension.missing { + let mut values = vec![("{schema}", m.schema), ("{object}", m.object.to_string()), ("{entry}", m.entry)]; + match (m.item, m.empty) { + (None, false) => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has no {entry} entry", + values, + m.object, + false, + ), + (None, true) => located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} has an empty {entry} entry", + values, + m.object, + false, + ), + (Some(item), false) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} without a {entry} entry", + values, + m.object, + false, + ); + } + (Some(item), true) => { + values.push(("{item}", item)); + located_issue( + "6.6.2.3.3", + "The XMP extension schema {schema} in metadata stream {object} describes {item} with an empty {entry} entry", + values, + m.object, + false, + ); + } + } + } + for c in extension.not_bag { + let object = c.object; + if c.found.is_empty() { + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds no rdf:Bag", + vec![("{object}", object.to_string())], + object, + false, + ); + } else { + let values = vec![("{object}", object.to_string()), ("{found}", c.found)]; + located_issue( + "6.6.2.3.3", + "The XMP extension schema container in metadata stream {object} holds {found}, not one rdf:Bag", + values, + object, + false, + ); + } + } + for p in extension.prefixes { + let mut values = vec![("{object}", p.object.to_string()), ("{namespace}", p.namespace.to_owned()), ("{prefix}", p.own.to_owned())]; + if p.written.is_empty() { + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} without a prefix, not with {prefix}", + values, + p.object, + false, + ); + } else { + values.push(("{written}", p.written)); + located_issue( + "6.6.2.3.3", + "Metadata stream {object} writes the namespace {namespace} with the prefix {written}, not {prefix}", + values, + p.object, + false, + ); + } + } +} + +/// 6.2.3 and 6.2.4.3: the output intent device colour needs. +fn check_output_intent( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let used = uses_device_colour(doc, objs); match pdfa_intent_profile(doc) { None if used.any() => { issue("6.2.3", "Device colour is used, but there is no PDF/A output intent", None, None, !used.cmyk); @@ -554,7 +813,11 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } None => {} } +} + +fn check_catalog_actions(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { // 6.1.3 / 6.6.1: catalog actions. + let cat = catalog(doc); if cat.get(b"AA").is_some() { issue("6.5.2", "The document has additional actions (/AA)", None, None, true); } @@ -568,61 +831,137 @@ pub fn verify(doc: &Document, level: Level) -> Vec { { issue("6.4.1", "The form asks viewers to make appearances (NeedAppearances)", None, None, true); } - // Pages: annotations and page actions. - let pages = pdfcraft_model::pages(doc); - // Each annotation's page: the first page that lists it. - let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); +} + +fn check_transparency( + doc: &Document, + pages: &[pdfcraft_model::Page], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + // 6.2.10: blend modes are those ISO 32000-1 defines, and without a PDF/A output intent (one with + // a profile) a page with transparency names a blending colour space. + let intent = pdfa_intent_has_profile(doc); + // Pages share resources, forms and fonts: what one page found under them, the next takes as is. + let mut shared = transparency::Shared::default(); for (i, p) in pages.iter().enumerate() { - if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) - && let Some(a) = a.as_array() - { - #[cfg(test)] - ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); - for r in a.iter().filter_map(Object::as_ref) { - annot_pages.entry(r).or_insert(i); - } + let painting = transparency::page_painting(doc, &p.dict, &mut shared); + for name in painting.unknown_blend_modes { + issue("6.2.10", "A graphics state uses the blend mode {}, which ISO 32000-1 doesn't define", Some(name), Some(i), false); + } + if painting.transparency && !intent && !transparency::page_names_blending_space(doc, &p.dict) { + let n = Some((i + 1).to_string()); + issue("6.2.10", "Page {} has transparency but no device-independent blending colour space or PDF/A output intent", n, Some(i), false); } } - let page_of = |r: ObjRef| annot_pages.get(&r).copied(); +} + +fn check_limits( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + pages: &[pdfcraft_model::Page], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + // 6.1.13: the implementation limits of ISO 32000-1 Annex C. + let over = limits::exceeded(doc, objs, &referenced_content(doc, objs)); + if over.int { + issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); + } + if over.real { + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", None, None, false); + } + if over.string { + issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); + } + if limits::cid_over_limit(doc, objs) { + issue("6.1.13", "A CMap maps a character code to a CID above 65535", None, None, false); + } + if over.name { + issue("6.1.13", "A name is longer than 127 bytes", None, None, false); + } for (i, p) in pages.iter().enumerate() { - if p.dict.get(b"AA").is_some() { - issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); + if limits::page_box_out_of_range(doc, &p.dict) { + issue("6.1.13", "Page {} has a boundary smaller than 3 or larger than 14400 units", Some((i + 1).to_string()), Some(i), false); } } - check_objects(doc, level, &objs, &page_of, &mut issue); - // One message per kind of problem is enough for repeated ones (as preflight summaries do). - let mut out = out.into_inner(); - let mut seen = std::collections::HashSet::new(); - out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); - out +} + +fn check_spots( + doc: &Document, + objs: &[(ObjRef, std::sync::Arc)], + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + // 6.2.4.4: Separation and DeviceN colour spaces. + let spots = spot::check(doc, objs); + for name in spots.differing { + issue("6.2.4.4", "Separation colour spaces named {} differ in their alternate space or tint transform", Some(name), None, false); + } + for name in spots.undefined { + issue("6.2.4.4", "The spot colour {} of a DeviceN colour space isn't in its Colorants dictionary", Some(name), None, false); + } +} + +fn check_graphics_states(doc: &Document, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + // 6.2.5: the extended graphics state. + let gs = gstate::check(doc); + if gs.transfer { + issue("6.2.5", "A graphics state has a transfer function (TR)", None, None, false); + } + if gs.transfer2 { + issue("6.2.5", "A graphics state has a transfer function (TR2) other than Default", None, None, false); + } + if gs.phase { + issue("6.2.5", "A graphics state has a halftone phase (HTP)", None, None, false); + } + for kind in gs.halftone_types { + issue("6.2.5", "A halftone has type {}: only types 1 and 5 are allowed", Some(kind.to_string()), None, false); + } + if gs.untyped { + issue("6.2.5", "A halftone's HalftoneType is missing or isn't an integer", None, None, false); + } + if gs.halftone_name { + issue("6.2.5", "A halftone has a HalftoneName", None, None, false); + } + if gs.halftone_transfer { + issue("6.2.5", "A halftone has a transfer function, which only a non-primary colorant's halftone may have", None, None, false); + } + for colorant in gs.missing_transfer { + issue("6.2.5", "The halftone for the colorant {} has no transfer function", Some(colorant), None, false); + } + for intent in gs.intents { + issue("6.2.5", "A graphics state has the rendering intent {}, which isn't a standard one", Some(intent), None, false); + } + if gs.intent_not_name { + issue("6.2.5", "A graphics state has a rendering intent that isn't a name", None, None, false); + } } fn check_objects( doc: &Document, level: Level, objects: &[(ObjRef, std::sync::Arc)], + drawing: &std::collections::HashSet, page_of: &impl Fn(ObjRef) -> Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { for (r, o) in objects { let Some(d) = dict_of(o) else { continue }; - check_one_object(doc, level, *r, d, o, page_of, issue); + check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } } fn check_one_object( doc: &Document, level: Level, - reference: ObjRef, dict: &Dict, object: &Object, - page_of: &impl Fn(ObjRef) -> Option, + draws: bool, + page: Option, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), ) { let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. @@ -631,7 +970,8 @@ fn check_one_object( if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { issue("6.5.2", "A form field has additional actions", None, None, true); } - if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + // A Type 0 font's descendant is checked through it. + if draws && (ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some()) { check_font(doc, dict, issue); } if let Object::Stream(stream) = object { @@ -829,10 +1169,8 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec if *read > MAX_ACTION_CHAIN { return Some("too deeply chained".into()); } - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - { - return Some(String::from_utf8_lossy(s).into_owned()); + if let Some(s) = action_forbidden(doc, d) { + return Some(s); } match d.get(b"Next") { Some(Object::Dict(next)) => stack.push(next), @@ -849,6 +1187,24 @@ fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collec None } +/// The forbidden kind of the action `d` itself, if it is one: a forbidden type, or a Named +/// action for anything but the four of [`NAMED_ACTIONS`]. The type and the name may each be given +/// directly or by reference. +fn action_forbidden(doc: &Document, d: &Dict) -> Option { + let s = doc.resolve(d.get(b"S")?); + let s = s.as_name()?; + if FORBIDDEN_ACTIONS.contains(&s) { + return Some(String::from_utf8_lossy(s).into_owned()); + } + let n = d.get(b"N").map(|n| doc.resolve(n)); + match n.as_deref().and_then(Object::as_name) { + _ if s != b"Named" => None, + Some(n) if NAMED_ACTIONS.contains(&n) => None, + Some(n) => Some(format!("Named {}", String::from_utf8_lossy(n))), + None => Some("Named".into()), + } +} + /// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { @@ -874,6 +1230,9 @@ pub fn convert(doc: &mut Document, level: Level) -> Result bool { + self.int && self.real && self.string && self.name + } + + fn name(&mut self, n: &[u8]) { + self.name |= n.len() > MAX_NAME; + } + + /// One value and, within [`MAX_DEPTH`], what it holds: a dictionary's keys count as names. + fn value(&mut self, o: &Object, depth: u8) { + if self.all() || depth > MAX_DEPTH { + return; + } + match o { + Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL || (*r != 0.0 && r.abs() < MIN_REAL), + Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, + Object::Name(n) => self.name(n), + Object::Array(a) => { + for v in a { + self.value(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => self.dict(d, depth), + Object::Stream(s) => self.dict(&s.dict, depth), + Object::Null | Object::Bool(_) | Object::Ref(_) => {} + } + } + + /// The operands of one content stream, inline images' dictionaries included. + fn content(&mut self, data: &[u8]) { + for op in pdfcraft_content::ops(data) { + if self.all() { + return; + } + for v in &op.operands { + self.value(v, 0); + } + if let Some((image, _)) = &op.inline { + self.dict(image, 0); + } + } + } + + fn dict(&mut self, d: &Dict, depth: u8) { + for (k, v) in d.iter() { + self.name(k); + self.value(v, depth.saturating_add(1)); + } + } +} + +/// The limits that the trailer, the indirect objects `objs` and the content streams go past. +/// `content` holds the content streams only a reference identifies (a page's `/Contents`, Type 3 +/// glyph procedures), besides form XObjects and appearance streams, which say so themselves. +pub(crate) fn exceeded(doc: &Document, objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { + let mut found = Exceeded::default(); + found.dict(doc.trailer(), 0); + for (_, o) in objs { + if found.all() { + return found; + } + found.value(o, 0); + } + let mut budget = DecodeBudget::new(MAX_CONTENT_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for page in pdfcraft_model::pages(doc) { + if found.all() || budget.remaining() == 0 { + return found; + } + let refs = contents(doc, &page.dict); + let mut joined = Vec::new(); + for r in &refs { + if let Object::Stream(s) = &*doc.get(*r) + && let Ok(data) = budget.decode(s) + { + joined.extend_from_slice(&data); + joined.push(b' '); + } + } + read.extend(refs); + found.content(&joined); + } + for (r, o) in objs { + if found.all() || budget.remaining() == 0 { + break; + } + if let Object::Stream(s) = &**o + && !read.contains(r) + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = budget.decode(s) + { + found.content(&data); + } + } + found +} + +/// A page's `/Contents` streams, in order. +fn contents(doc: &Document, page: &Dict) -> Vec { + match page.get(b"Contents") { + Some(Object::Ref(r)) => match &*doc.get(*r) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => vec![*r], + }, + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + _ => Vec::new(), + } +} + +/// Whether one of a page's boundaries (with the media and crop boxes it inherits) is narrower or +/// lower than 3 units, or wider or higher than 14400. +pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { + BOXES.iter().filter_map(|k| page.get(k)).any(|b| { + let b = doc.resolve(b); + let v: Vec = b.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + let [x0, y0, x1, y1] = v.as_slice() else { return false }; + let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); + // A size past `f64` (corners near ±1e308) is as much too large as any other. + [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) + }) +} + +/// Whether an embedded CMap of a Type 0 font among `objs`, or one it uses through `/UseCMap`, maps +/// a code to a CID above 65535: a `begincidrange` entry ` cid` reaches +/// `cid + high − low`, a `begincidchar` entry ` cid` reaches `cid` (ISO 32000-1 9.7.5.4). A +/// CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded +/// within one allowance for the document. +pub(crate) fn cid_over_limit(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> bool { + let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for (_, o) in objs { + let Some(d) = o.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Type0") { + continue; + } + let mut next = d.get(b"Encoding").and_then(Object::as_ref); + for _ in 0..MAX_CMAP_CHAIN { + let Some(r) = next.filter(|r| read.insert(*r)) else { break }; + if budget.remaining() == 0 { + return false; + } + let Object::Stream(cmap) = &*doc.get(r) else { break }; + if budget.decode(cmap).is_ok_and(|data| maps_past_limit(&data)) { + return true; + } + next = cmap.dict.get(b"UseCMap").and_then(Object::as_ref); + } + } + false +} + +/// Whether the CMap program `data` maps a code to a CID above [`MAX_CID`]. The entries of a +/// `begincidrange` … `endcidrange` block are the operands of its `endcidrange`, read as content is. +fn maps_past_limit(data: &[u8]) -> bool { + let value = |o: &Object| -> Option { + match o { + Object::String(s) if s.bytes.len() <= 4 => Some(s.bytes.iter().fold(0u64, |v, b| (v << 8) | u64::from(*b))), + Object::Int(i) => u64::try_from(*i).ok(), + _ => None, + } + }; + pdfcraft_content::ops(data).any(|op| match op.op.as_slice() { + b"endcidrange" => op.operands.as_chunks::<3>().0.iter().any(|[low, high, cid]| match (value(low), value(high), value(cid)) { + (Some(low), Some(high), Some(cid)) => cid.saturating_add(high.saturating_sub(low)) > MAX_CID, + _ => false, + }), + b"endcidchar" => op.operands.as_chunks::<2>().0.iter().any(|[_, cid]| value(cid).is_some_and(|cid| cid > MAX_CID)), + _ => false, + }) +} diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs new file mode 100644 index 0000000000..8367d8d28a --- /dev/null +++ b/crates/preflight/src/spot.rs @@ -0,0 +1,212 @@ +//! PDF/A-2 6.2.4.4, Separation and DeviceN colour spaces: +//! +//! - Separation spaces of the same colorant name agree on their alternate space and tint +//! transform, also where a DeviceN space's `/Colorants` dictionary holds them. They are compared +//! as objects, whether stored directly or by reference, at any depth (ISO 32000-1 7.3.10); +//! - a DeviceN space names each spot colorant it uses in the `/Colorants` dictionary of its +//! attributes (ISO 32000-1 8.6.6.5, Table 71); a null or missing entry defines nothing. The +//! process colorants (`Cyan`, `Magenta`, `Yellow`, `Black`, and those an NChannel space's +//! `/Process` lists) and `None` are not spot colorants. +//! +//! Colour spaces are found wherever they are written: in resource dictionaries, images, shadings +//! and other colour spaces, direct or by reference. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; + +/// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as +/// the parser nests them. +const MAX_DEPTH: u8 = 128; +/// How deep two definitions are compared, references followed. +const MAX_COMPARE_DEPTH: u8 = 32; +/// How many object pairs the comparisons of one document visit in all. Past it, a comparison +/// claims no difference. +const MAX_COMPARE_STEPS: u32 = 1 << 20; +/// The decoded bytes the stream comparisons of one document may spend in all. +const MAX_COMPARE_BYTES: usize = 64 << 20; +/// The stream entries that say how the data is stored, not what it is. +const ENCODING: [&[u8]; 3] = [b"Length", b"Filter", b"DecodeParms"]; +/// The colorants of DeviceCMYK, which a DeviceN space may name without defining them. +const PROCESS: [&[u8]; 4] = [b"Cyan", b"Magenta", b"Yellow", b"Black"]; + +/// What the colour spaces of a document break of 6.2.4.4. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Colorant names whose Separation spaces differ, in name order. + pub(crate) differing: BTreeSet, + /// Spot colorants a DeviceN space uses without a `/Colorants` entry, in name order. + pub(crate) undefined: BTreeSet, +} + +/// The 6.2.4.4 findings over the indirect objects `objs`. +pub(crate) fn check(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> Findings { + let compare = Compare { doc, equal: HashSet::new(), steps: MAX_COMPARE_STEPS, budget: DecodeBudget::new(MAX_COMPARE_BYTES, 0) }; + let mut scan = Scan { doc, compare, separations: HashMap::new(), findings: Findings::default() }; + for (_, o) in objs { + scan.walk(o, 0); + } + scan.findings +} + +struct Scan<'a> { + doc: &'a Document, + compare: Compare<'a>, + /// Each colorant name's first Separation space: its alternate space and tint transform. + separations: HashMap, (Object, Object)>, + findings: Findings, +} + +impl Scan<'_> { + fn walk(&mut self, o: &Object, depth: u8) { + if depth > MAX_DEPTH { + return; + } + match o { + Object::Array(a) => { + match a.first().and_then(Object::as_name) { + Some(b"Separation") => self.separation(a), + Some(b"DeviceN") => self.devicen(a), + _ => {} + } + for v in a { + self.walk(v, depth.saturating_add(1)); + } + } + Object::Dict(d) => { + for (_, v) in d.iter() { + self.walk(v, depth.saturating_add(1)); + } + } + Object::Stream(s) => { + for (_, v) in s.dict.iter() { + self.walk(v, depth.saturating_add(1)); + } + } + _ => {} + } + } + + /// `[/Separation name alternateSpace tintTransform]`: the same name must keep the same + /// alternate space and tint transform. + fn separation(&mut self, a: &[Object]) { + let [_, name, alternate, tint, ..] = a else { return }; + let name = self.doc.resolve(name); + let Some(name) = name.as_name() else { return }; + let this = (alternate.clone(), tint.clone()); + match self.separations.get(name) { + Some((alt, fun)) => { + if !self.compare.same(alt, &this.0, 0) || !self.compare.same(fun, &this.1, 0) { + self.findings.differing.insert(String::from_utf8_lossy(name).into_owned()); + } + } + None => { + self.separations.insert(name.to_vec(), this); + } + } + } + + /// `[/DeviceN names alternateSpace tintTransform attributes]`: each spot colorant in `names` + /// needs an entry in the attributes' `/Colorants`. + fn devicen(&mut self, a: &[Object]) { + let Some(names) = a.get(1).map(|n| self.doc.resolve(n)) else { return }; + let Some(names) = names.as_array() else { return }; + let attributes = a.get(4).map(|d| self.doc.resolve(d)); + let attributes = attributes.as_deref().and_then(Object::as_dict); + let colorants = attributes.and_then(|d| d.get(b"Colorants")).map(|c| self.doc.resolve(c)); + let colorants = colorants.as_deref().and_then(Object::as_dict); + let nchannel = attributes.and_then(|d| d.get(b"Subtype")).map(|t| self.doc.resolve(t)).is_some_and(|t| t.as_name() == Some(b"NChannel")); + let process = attributes + .filter(|_| nchannel) + .and_then(|d| d.get(b"Process")) + .map(|p| self.doc.resolve(p)) + .and_then(|p| p.as_dict().and_then(|p| p.get(b"Components")).map(|c| self.doc.resolve(c))); + let process: Vec> = process + .as_deref() + .and_then(Object::as_array) + .map(|c| c.iter().filter_map(|n| self.doc.resolve(n).as_name().map(<[u8]>::to_vec)).collect()) + .unwrap_or_default(); + for n in names.iter().map(|n| self.doc.resolve(n)) { + let Some(n) = n.as_name() else { continue }; + let spot = n != b"None" && !PROCESS.contains(&n) && !process.iter().any(|p| p == n); + // Each entry is a Separation space for the colorant it is keyed by (ISO 32000-1 8.6.6.5, + // Table 71); a null entry, or one referring to no object, defines nothing (7.3.9). + let defined = colorants.and_then(|c| c.get(n)).is_some_and(|e| self.separation_named(e, n)); + if spot && !defined { + self.findings.undefined.insert(String::from_utf8_lossy(n).into_owned()); + } + } + } + + /// Whether `o` is `[/Separation name ...]` for the colorant `name`. + fn separation_named(&self, o: &Object, name: &[u8]) -> bool { + let o = self.doc.resolve(o); + let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false }; + kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name) + } +} + +/// Compares definitions, remembering which reference pairs it found equal, so a structure shared +/// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`]. +struct Compare<'a> { + doc: &'a Document, + /// Reference pairs compared equal. A depth cut-off only ever answers "different", so an + /// equal answer holds at any depth. + equal: HashSet<(ObjRef, ObjRef)>, + steps: u32, + budget: DecodeBudget, +} + +impl Compare<'_> { + /// Whether `a` and `b` are the same object, wherever their parts are stored: references are + /// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object + /// numbers are the same, and streams compare by their decoded data. Once the steps or the + /// decode allowance run out, nothing more is called different. + fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool { + let pair = match (a, b) { + (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true, + (Object::Ref(x), Object::Ref(y)) => Some((*x, *y)), + _ => None, + }; + let Some(steps) = self.steps.checked_sub(1) else { return true }; + self.steps = steps; + if depth > MAX_COMPARE_DEPTH { + return false; + } + let (a, b) = (self.doc.resolve(a), self.doc.resolve(b)); + let deeper = depth.saturating_add(1); + let same = match (&*a, &*b) { + (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)), + (Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper), + (Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper), + (x, y) => x == y, + }; + if same && let Some(pair) = pair { + self.equal.insert(pair); + } + same + } + + /// Whether two dictionaries hold the same entries, those keyed in `skip` aside. + fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool { + let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count(); + kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth))) + } + + /// Whether two streams hold the same data, however each is encoded. Data that can't be + /// decoded within the allowance is not called different. + fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool { + let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) { + (None, None) => true, + (Some(x), Some(y)) => c.same(x, y, depth), + _ => false, + }; + if a.raw == b.raw && stored_alike(self, b"Filter") && stored_alike(self, b"DecodeParms") { + return true; + } + match (self.budget.decode(a), self.budget.decode(b)) { + (Ok(x), Ok(y)) => x == y, + _ => true, + } + } +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e691adae84..e0107d9f21 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -1,7 +1,11 @@ use super::*; fn build(objs: &[&str], trailer_extra: &str) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); + build_with_header(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", objs, trailer_extra) +} + +fn build_with_header(header: &[u8], objs: &[&str], trailer_extra: &str) -> Document { + let mut out = header.to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); @@ -491,12 +495,279 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } +/// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): +/// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name +/// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page +/// boundary under 3 or over 14400 units, read after inheritance, so a page that overrides its +/// parent's box is judged by its own. +#[test] +fn implementation_limits_are_reported() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let doc = |extra: &str, content: &str, parent_box: &str, page_box: &str| { + let objects = [ + format!("<< /Type /Catalog /Pages 2 0 R {extra} >>"), + format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {parent_box} >>"), + format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R {page_box} >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let refs: Vec<&str> = objects.iter().map(String::as_str).collect(); + build(&refs, "") + }; + let ok = "[0 0 200 200]"; + assert!(limits(&doc("", "", ok, "")).is_empty(), "{:?}", limits(&doc("", "", ok, ""))); + assert_eq!(limits(&doc("/Big 2147483648", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&doc("/Small -2147483649", "", ok, "")), ["An integer is outside the 32-bit range"]); + assert!(limits(&doc("/Edge -2147483648 /Top 2147483647", "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "2147483648 0 Td", ok, "")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&doc(&format!("/S ({long})"), "", ok, "")), ["A string is longer than 32767 bytes"]); + assert_eq!(limits(&doc("", &format!("BT ({long}) Tj ET"), ok, "")), ["A string is longer than 32767 bytes"]); + assert!(limits(&doc(&format!("/S ({})", "x".repeat(32767)), "", ok, "")).is_empty()); + let name = "n".repeat(128); + assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc("", &format!("/{name} Do"), ok, "")), ["A name is longer than 127 bytes"]); + // 200 bytes as written, 66 once the escapes are read. + let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4)); + assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty()); + assert_eq!(limits(&doc("", "", "[0 0 2 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); + assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's"); +} + +/// 6.1.13 also reads the trailer, a page's `/Contents` streams joined (operands may end one +/// stream and their operator start the next, ISO 32000-1 7.8.2), values nested as deep as the +/// parser allows, and a page box whose size overflows to infinity. +#[test] +fn implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |data: &str| { + format!( + "<< /Length {} >> +stream +{data} +endstream", + data.len() + ) + }; + let page = |contents: &str, catalog_extra: &str, media: &str, trailer: &str| { + let (first, second) = contents.split_once('|').unwrap_or((contents, "")); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + &format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {media} >>"), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>", + &stream(first), + &stream(second), + ], + trailer, + ) + }; + let ok = "[0 0 200 200]"; + assert!(limits(&page("|", "", ok, "")).is_empty()); + assert_eq!(limits(&page("|", "", ok, "/Custom 2147483648")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); + let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); + // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. + assert_eq!( + limits(&page("|", "", "[-1e308 0 1e308 200]", "")), + [ + "A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸", + "Page 1 has a boundary smaller than 3 or larger than 14400 units" + ] + ); +} + +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸ (or +/// nonzero and nearer zero than ±1.175 × 10⁻³⁸), and +/// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching +/// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). +#[test] +fn implementation_limits_cover_reals_and_cids() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = |catalog_extra: &str, cmap: &str, used: &str| { + let first = stream("/Type /CMap /CMapName /Test-A /UseCMap 6 0 R", cmap); + let second = stream("/Type /CMap /CMapName /Test-B", used); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F 4 0 R >> >> >>", + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding 5 0 R /DescendantFonts [] >>", + &first, + &second, + ], + "", + ) + }; + let range = |low: &str, high: &str, cid: u32| format!("1 begincidrange <{low}> <{high}> {cid} endcidrange"); + let near = format!("34{}.0", "0".repeat(37)); + let far = format!("4{}.0", "0".repeat(38)); + assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + // A nonzero real nearer zero than ±1.175 × 10⁻³⁸ is not allowed either; zero and the + // smallest allowed magnitude are. + let tiny = format!("0.{}1", "0".repeat(39)); + let smallest = format!("0.{}1175", "0".repeat(37)); + assert!(limits(&doc(&format!("/Z 0.0 /M -0.0 /S {smallest} /T -{smallest}"), "", "")).is_empty(), "zero and the smallest real"); + assert_eq!(limits(&doc(&format!("/R {tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{tiny}"), "", "")), ["A real number is outside ±3.403 × 10³⁸, or nonzero but within ±1.175 × 10⁻³⁸"]); + let cid = ["A CMap maps a character code to a CID above 65535"]; + assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); + assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536"); + assert_eq!(limits(&doc("", "1 begincidchar <01> 70000 endcidchar", "")), cid); + assert_eq!(limits(&doc("", &range("0000", "00ff", 0), &range("3f00", "3fff", 65536))), cid, "through /UseCMap"); +} + +/// PDF/A-2 6.2.4.4: Separation spaces of one colorant name agree on their alternate space and tint +/// transform, also inside a DeviceN space's `/Colorants`; and a DeviceN space names each spot +/// colorant it uses in its `/Colorants` (ISO 32000-1 8.6.6.5, Table 71). Process colorants (the +/// CMYK four, and an NChannel space's `/Process` components) and `None` need no entry. +#[test] +fn separation_and_devicen_spaces_are_checked() { + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let red = |alt: &str, f: u32| format!("[/Separation /Red /{alt} {f} 0 R]"); + // The same name, the same alternate and an identical tint transform (another object): fine. + assert!(spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 6))).is_empty()); + assert_eq!( + spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceRGB", 5))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + assert_eq!( + spots(&format!("/A {} /B {}", red("DeviceRGB", 4), red("DeviceGray", 4))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + // Inside two DeviceN spaces' Colorants. + let nchannel = |f: u32| format!("[/DeviceN [/Red] /DeviceRGB 4 0 R << /Subtype /NChannel /Colorants << /Red {} >> >>]", red("DeviceRGB", f)); + assert_eq!( + spots(&format!("/A {} /B {}", nchannel(4), nchannel(5))), + ["Separation colour spaces named Red differ in their alternate space or tint transform"] + ); + // Spot colorants need a Colorants entry; process colorants and None don't. + assert_eq!( + spots("/A [/DeviceN [/Spot1 /Cyan /None] /DeviceCMYK 4 0 R]"), + ["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"] + ); + assert_eq!( + spots("/A [/DeviceN [/Spot1] /DeviceRGB 4 0 R << /Colorants << >> >>]"), + ["The spot colour Spot1 of a DeviceN colour space isn't in its Colorants dictionary"] + ); + assert!(spots(&format!("/A [/DeviceN [/Red /Black] /DeviceRGB 4 0 R << /Colorants << /Red {} >> >>]", red("DeviceRGB", 4))).is_empty()); + assert!( + spots("/A [/DeviceN [/PrCyan /Black] /DeviceCMYK 4 0 R << /Subtype /NChannel /Process << /ColorSpace /DeviceCMYK /Components [/PrCyan /M /Y /Black] >> >>]") + .is_empty() + ); +} + +/// From the review of the 6.2.4.4 checks: nested parts compare by content, `All` and `None` get +/// no exemption, names may be given by reference, `/Process` exempts only in an NChannel space, a +/// null `/Colorants` entry defines nothing, and a colour space nested deep is still found. +#[test] +fn separation_and_devicen_checks_follow_references_depth_and_nchannel() { + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let icc = "<< /N 3 /Length 4 >>\nstream\nabcd\nendstream"; + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + icc, + icc, + "/Red", + "null", + ], + "", + ); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let differ = |n: &str| vec![format!("Separation colour spaces named {n} differ in their alternate space or tint transform")]; + let undefined = |n: &str| vec![format!("The spot colour {n} of a DeviceN colour space isn't in its Colorants dictionary")]; + // Identical ICC streams under different numbers are the same alternate space. + assert!(spots("/A [/Separation /Red [/ICCBased 6 0 R] 4 0 R] /B [/Separation /Red [/ICCBased 7 0 R] 4 0 R]").is_empty()); + assert_eq!(spots("/A [/Separation /All /DeviceRGB 4 0 R] /B [/Separation /All /DeviceRGB 5 0 R]"), differ("All")); + assert_eq!(spots("/A [/Separation 8 0 R /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 5 0 R]"), differ("Red")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Process << /Components [/Spot] >> >>]"), undefined("Spot")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot null >> >>]"), undefined("Spot")); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot 9 0 R >> >>]"), undefined("Spot")); + let deep = format!("/A {}[/DeviceN [/Spot] /DeviceRGB 4 0 R]{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(spots(&deep), undefined("Spot")); +} + +/// From the second review of the 6.2.4.4 checks: a `/Colorants` entry is a Separation space of +/// its own colorant, tint-transform streams compare by decoded data, and a structure shared by +/// many definitions is compared once rather than once per path through it. +#[test] +fn separation_checks_match_colorant_entries_decode_streams_and_share_comparisons() { + // Two copies of a chain of arrays, each level naming the next twice: comparing them path by + // path takes about 2^19 steps. + const LEVELS: usize = 18; + let chain = |first: usize| -> Vec { + (0..LEVELS).map(|k| if k + 1 < LEVELS { format!("[{n} 0 R {n} 0 R]", n = first + k + 1) } else { "[1 2 3]".into() }).collect() + }; + let (a, b) = (5, 5 + LEVELS); + let spots = |spaces: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>".into(), + ]; + objects.extend(chain(a)); + objects.extend(chain(b)); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream".into()); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Filter /ASCIIHexDecode /Length 31 >>\nstream\n7B206475702064757020706F70207D>\nendstream".into()); + objects.push("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup pop dup }\nendstream".into()); + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let doc = build(&objects, ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect() + }; + let differ = vec!["Separation colour spaces named Red differ in their alternate space or tint transform".to_string()]; + let undefined = vec!["The spot colour Spot of a DeviceN colour space isn't in its Colorants dictionary".to_string()]; + // A Colorants entry that isn't a Separation space of the same colorant defines nothing. + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot /DeviceRGB >> >>]"), undefined); + assert_eq!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Other /DeviceRGB 4 0 R] >> >>]"), undefined); + assert!(spots("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]").is_empty()); + // The same PostScript function, once plain and once hex-encoded, is the same tint transform. + let (plain, hex, other) = (5 + 2 * LEVELS, 6 + 2 * LEVELS, 7 + 2 * LEVELS); + assert!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {hex} 0 R]")).is_empty()); + assert_eq!(spots(&format!("/A [/Separation /Red /DeviceRGB {plain} 0 R] /B [/Separation /Red /DeviceRGB {other} 0 R]")), differ); + // Equal copies compared again and again still leave room to find the one that differs. + let sep = |f: usize| format!("[/Separation /Red /DeviceRGB {f} 0 R]"); + assert_eq!(spots(&format!("/A {} /B {} /C {} /D {} /E {}", sep(a), sep(b), sep(b), sep(b), sep(4))), differ); +} + const XMP_NS: &str = concat!( r#"xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" "#, r#"xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" "#, r#"xmlns:stRef="http://ns.adobe.com/xap/1.0/sType/ResourceRef#" xmlns:xmpDM="http://ns.adobe.com/xmp/1.0/DynamicMedia/" "#, r#"xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmlns:xmpTPg="http://ns.adobe.com/xap/1.0/t/pg/" "#, - r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/""# + r#"xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" "#, + r#"xmlns:tiff="http://ns.adobe.com/tiff/1.0/" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:aux="http://ns.adobe.com/exif/1.0/aux/""# ); /// The mistyped predefined properties of a packet whose one description holds `body` (its @@ -720,6 +991,400 @@ fn xmp_value_types_are_reported_by_verify() { assert_eq!(found.iter().map(|i| i.object).collect::>(), [Some(ObjRef::new(5, 0))]); } +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas of the September 2005 XMP +/// Specification (pages 58–70), with the EXIF value types of pages 83–87. +#[test] +fn xmp_exif_schemas_have_their_value_types() { + // TIFF: integers, closed choices of integer, rationals, sequences, language alternatives. + assert!(element("tiff:ImageWidth", "256").is_empty()); + assert_eq!(element("tiff:ImageWidth", "256 mm"), ["tiff:ImageWidth"]); + assert_eq!(element("tiff:Orientation", "4/1"), ["tiff:Orientation"]); + assert!(element("tiff:XResolution", "72/1").is_empty()); + assert_eq!(element("tiff:XResolution", "1/2"), ["tiff:XResolution"]); + assert!(element("tiff:BitsPerSample", "88").is_empty()); + assert_eq!(element("tiff:BitsPerSample", "88.0"), ["tiff:BitsPerSample"]); + assert_eq!(element("tiff:TransferFunction", "0"), ["tiff:TransferFunction"]); + assert_eq!(element("tiff:ImageDescription", "A picture"), ["tiff:ImageDescription"]); + // EXIF: GPS coordinates (page 85). + for good in ["51,30,26N", "0,5.25W", "120,5,0E", "8,0.000001S"] { + assert!(element("exif:GPSLatitude", good).is_empty(), "{good}"); + } + for bad in ["51.5N", "51,30,26", "51,30.N", "51,30,26X", "N", "51,30,2.6N", "a,30,26N", "51,30N", "51.5,30,26N"] { + assert_eq!(element("exif:GPSLatitude", bad), ["exif:GPSLatitude"], "{bad}"); + } + assert!(element("exif:GPSTimeStamp", "2016-02-01T13:19:21").is_empty()); + assert_eq!(element("exif:ISOSpeedRatings", "100"), ["exif:ISOSpeedRatings"]); + // EXIF structures: Flash, OECF/SFR, CFAPattern, DeviceSettings (pages 85–87). + assert!(mistyped(r#">True3"#).is_empty()); + assert_eq!(mistyped(r#">yes"#), ["exif:Flash"]); + assert_eq!(mistyped(r#">"#), ["exif:Flash"]); + assert_eq!(element("exif:Flash", "fired"), ["exif:Flash"]); + assert!( + mistyped(r#">1/2"#).is_empty() + ); + assert_eq!( + mistyped(r#">0.5"#), + ["exif:OECF"] + ); + assert_eq!( + mistyped( + r#">1/2"# + ), + ["exif:CFAPattern"] + ); + assert_eq!( + mistyped(r#">2.0"#), + ["exif:DeviceSettingDescription"] + ); + assert_eq!( + mistyped(r#">a"#), + ["exif:DeviceSettingDescription"] + ); + // Additional EXIF properties (page 69). + assert!(element("aux:Lens", "50mm").is_empty()); + assert_eq!(element("aux:Lens", "50mm"), ["aux:Lens"]); + assert_eq!(element("aux:SerialNumber", "1"), ["aux:SerialNumber"]); +} + +/// The 6.6.2.3.3 findings of a packet whose extension schema bag holds `schema`, with the five +/// description namespaces bound to the prefixes in `prefixes` (`pdfaSchema` and so on by default). +fn extension(schema: &str, prefixes: [&str; 4]) -> (Vec, Vec<&'static str>) { + let found = extension_findings(schema, prefixes); + (found.missing.into_iter().map(|m| m.entry).collect(), found.prefixes.into_iter().map(|p| p.own).collect()) +} + +/// [`extension`]'s findings in full, as if from the metadata stream `3 0 R`. +fn extension_findings(schema: &str, prefixes: [&str; 4]) -> xmp_extension::Findings { + let [s, p, t, f] = prefixes; + let xmp = format!( + concat!( + r#""#, + r#""#, + r#"{schema}"#, + r#""# + ), + s = s, + p = p, + t = t, + f = f, + schema = schema + ); + let parsed = xmp::parse(&xmp).expect("parses"); + let mut found = xmp_extension::Findings::default(); + found.add(ObjRef::new(3, 0), &parsed); + found +} + +const OWN: [&str; 4] = ["pdfaSchema", "pdfaProperty", "pdfaType", "pdfaField"]; + +/// A complete schema description, with `drop` left out of it (`pdfaSchema:schema` and so on). +fn schema_without(drop: &str) -> String { + let entry = |name: &str, value: &str| if name == drop { String::new() } else { format!("<{name}>{value}") }; + let property = ["pdfaProperty:name", "pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"] + .iter() + .map(|n| entry(n, "x")) + .collect::(); + let field = ["pdfaField:name", "pdfaField:valueType", "pdfaField:description"].iter().map(|n| entry(n, "x")).collect::(); + let field = entry("pdfaType:field", &format!(r#"{field}"#)); + let value_type = + ["pdfaType:type", "pdfaType:namespaceURI", "pdfaType:prefix", "pdfaType:description"].iter().map(|n| entry(n, "x")).collect::(); + [ + entry("pdfaSchema:schema", "Some schema"), + entry("pdfaSchema:namespaceURI", "http://example.com/ns/"), + entry("pdfaSchema:prefix", "ex"), + entry("pdfaSchema:property", &format!(r#"{property}"#)), + entry("pdfaSchema:valueType", &format!(r#"{value_type}{field}"#)), + ] + .concat() +} + +/// PDF/A-2 6.6.2.3.3: every extension schema description has its required entries, in each of its +/// four kinds, and writes each description namespace with its own prefix. +#[test] +fn xmp_extension_schema_descriptions_are_complete() { + assert_eq!(extension(&schema_without(""), OWN), (vec![], vec![])); + // The property and value type lists of a schema are optional. + assert_eq!(extension(&schema_without("pdfaSchema:property"), OWN), (vec![], vec![])); + assert_eq!(extension(&schema_without("pdfaSchema:valueType"), OWN), (vec![], vec![])); + // Every other entry is required. + for required in [ + "pdfaSchema:schema", + "pdfaSchema:namespaceURI", + "pdfaSchema:prefix", + "pdfaProperty:name", + "pdfaProperty:valueType", + "pdfaProperty:category", + "pdfaProperty:description", + "pdfaType:type", + "pdfaType:namespaceURI", + "pdfaType:prefix", + "pdfaType:description", + "pdfaType:field", + "pdfaField:name", + "pdfaField:valueType", + "pdfaField:description", + ] { + assert_eq!(extension(&schema_without(required), OWN), (vec![required.to_string()], vec![]), "{required}"); + } + // An entry may be an attribute of an rdf:Description item. + let as_attribute = r#""#; + assert_eq!(extension(&format!("{}{as_attribute}", schema_without("")), OWN), (vec![], vec![])); + let missing_attribute = r#""#; + assert_eq!(extension(&format!("{}{missing_attribute}", schema_without("")), OWN).0, ["pdfaSchema:namespaceURI"]); + // Each namespace with its own prefix: the right URI under another prefix breaks the rule. + for (i, own) in OWN.iter().enumerate() { + let mut prefixes = OWN; + prefixes[i] = "other"; + let schema = schema_without("").replace(&format!("{own}:"), "other:"); + assert_eq!(extension(&schema, prefixes), (vec![], vec![*own]), "{own}"); + } + // A second property list is read as well as the first. + let complete = schema_without(""); + let second = complete.replacen( + "", + r#"p"#, + 1, + ); + assert_eq!(extension(&second, OWN).0, ["pdfaProperty:valueType", "pdfaProperty:category", "pdfaProperty:description"]); + // The container is an rdf:Bag (TechNote 0009, 4.2). + let container = |inner: &str| { + let xmp = format!( + r#"{inner}"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(ObjRef::new(3, 0), &xmp::parse(&xmp).expect("parses")); + found.not_bag.into_iter().map(|c| c.found).collect::>() + }; + let schema = r#"Shttp://e/e"#; + assert!(container(&format!("{schema}")).is_empty()); + // What it holds instead is named as the packet writes it. + assert_eq!(container(&format!("{schema}")), ["rdf:Seq"]); + assert_eq!(container(r#""#), ["rdf:Description"]); + assert_eq!(container(&format!("{schema}")), ["rdf:Bag, rdf:Bag"]); + assert_eq!(container(""), [""]); + // The container itself is written as pdfaExtension too (TechNote 0009, 4.1). + let xmp = concat!( + r#""#, + r#""#, + r#"Shttp://e/"#, + r#"e"# + ); + let mut found = xmp_extension::Findings::default(); + found.add(ObjRef::new(3, 0), &xmp::parse(xmp).expect("parses")); + assert!(found.missing.is_empty()); + let written: Vec<(&str, &str, &str)> = found.prefixes.iter().map(|p| (p.namespace, p.own, p.written.as_str())).collect(); + assert_eq!(written, [("http://www.aiim.org/pdfa/ns/extension/", "pdfaExtension", "ext")]); + // Through verify, under 6.6.2.3.3 and not fixable. + let xmp = concat!( + r#""#, + r#""#, + r#"Se"#, + r#""# + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let issues: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").collect(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].message, "The XMP extension schema #1 in metadata stream 3 0 R has no pdfaSchema:namespaceURI entry"); + assert_eq!(issues[0].object, Some(ObjRef::new(3, 0))); + assert!(!issues[0].fixable); +} + +/// PDF/A-2 6.6.2.3.3: an entry that names something is incomplete when it is empty, while the +/// human-readable descriptions may be empty (TechNote 0009, 4.3 to 4.6). +#[test] +fn xmp_extension_naming_entries_are_not_empty() { + let complete = schema_without(""); + let emptied = |name: &str, value: &str, with: &str| { + let element = format!("<{name}>{value}"); + assert_eq!(complete.matches(&element).count(), 1, "{name}"); + complete.replace(&element, &format!("<{name}>{with}")) + }; + let said = |schema: &str| -> Vec<(String, bool)> { extension_findings(schema, OWN).missing.into_iter().map(|m| (m.entry, m.empty)).collect() }; + for (name, value) in [ + ("pdfaSchema:namespaceURI", "http://example.com/ns/"), + ("pdfaSchema:prefix", "ex"), + ("pdfaProperty:name", "x"), + ("pdfaProperty:valueType", "x"), + ("pdfaProperty:category", "x"), + ("pdfaType:type", "x"), + ("pdfaType:namespaceURI", "x"), + ("pdfaType:prefix", "x"), + ("pdfaField:name", "x"), + ("pdfaField:valueType", "x"), + ] { + assert_eq!(said(&emptied(name, value, "")), [(name.to_owned(), true)], "{name}"); + assert_eq!(said(&emptied(name, value, " \n\t ")), [(name.to_owned(), true)], "{name} blank"); + } + for (name, value) in + [("pdfaSchema:schema", "Some schema"), ("pdfaProperty:description", "x"), ("pdfaType:description", "x"), ("pdfaField:description", "x")] + { + assert_eq!(said(&emptied(name, value, "")), [], "{name}"); + } + // An empty element written shut, and an empty attribute, are empty too. + let shut = complete.replace("x", ""); + assert_eq!(said(&shut), [("pdfaType:prefix".to_owned(), true)]); + let attribute = r#""#; + assert_eq!(said(&format!("{complete}{attribute}")), [("pdfaSchema:prefix".to_owned(), true)]); + // A URI given as an rdf:resource, or a value given as rdf:value, is a value. + let resource = + complete.replace("x", r#""#); + assert_eq!(said(&resource), []); + let value = complete.replace("x", r#""#); + assert_eq!(said(&value), []); + // A comment is not a value. + assert_eq!(said(&emptied("pdfaType:prefix", "x", "")), [("pdfaType:prefix".to_owned(), true)]); + + // Through verify, with their own wordings. + let xmp = concat!( + r#""#, + r#""#, + r#""#, + r#"Shttp://a.example/"#, + r#""#, + r#"Thttp://t.example/"#, + r#" d"#, + r#""#, + r#""# + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect(); + assert_eq!( + messages, + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R has an empty pdfaSchema:prefix entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes T with an empty pdfaType:prefix entry", + ] + ); +} + +/// PDF/A-2 6.6.2.3.3: each incomplete description is reported by itself, named by its schema's +/// namespace URI, its own name and its metadata stream, and so is each misused prefix and container. +#[test] +fn xmp_extension_findings_say_where_they_are() { + // Two schemas lacking the same entries are two findings, each with its schema. + let schema = |uri: &str, property: &str| { + format!( + concat!( + r#"S{uri}"#, + r#"e{property}"#, + r#""# + ), + uri = uri, + property = property + ) + }; + let named = "pText"; + let two = format!(r#"{}{}"#, schema("http://a.example/", named), schema("http://b.example/", "")); + let found = extension_findings(&two, OWN); + let said: Vec<(&str, Option<&str>, &str)> = found.missing.iter().map(|m| (m.schema.as_str(), m.item.as_deref(), m.entry.as_str())).collect(); + assert_eq!( + said, + [ + ("http://a.example/", Some("p"), "pdfaProperty:category"), + ("http://a.example/", Some("p"), "pdfaProperty:description"), + ("http://b.example/", Some("#1"), "pdfaProperty:name"), + ("http://b.example/", Some("#1"), "pdfaProperty:valueType"), + ("http://b.example/", Some("#1"), "pdfaProperty:category"), + ("http://b.example/", Some("#1"), "pdfaProperty:description"), + ] + ); + assert!(found.missing.iter().all(|m| m.object == ObjRef::new(3, 0))); + // A field is named by its value type and its own name. + let field = schema_without("pdfaField:description") + .replace(">x", ">Dim") + .replace(">x", ">w"); + let found = extension_findings(&field, OWN); + assert_eq!(found.missing.iter().map(|m| m.item.as_deref()).collect::>(), [Some("Dim/w")]); + // A name the packet gives is repeated only so far. + let long = format!("http://example.com/{}", "a".repeat(400)); + let found = extension_findings(&schema(&long, ""), OWN); + assert!(found.missing[0].schema.ends_with('…') && found.missing[0].schema.chars().count() == 201, "{}", found.missing[0].schema); + // So many findings are enough. + let many = vec![schema("http://m.example/", ""); 400].join(r#""#); + assert_eq!(extension_findings(&many, OWN).missing.len(), 1000); + + // Through verify: each wording, with the stream it is in. + let verify_packet = |description: &str| -> Vec { + let xmp = format!( + concat!( + r#""#, + r#""# + ), + description + ); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.3").map(|i| i.message).collect() + }; + let ns = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#""#; + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema("http://a.example/", named) + )), + [ + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:category entry", + "The XMP extension schema http://a.example/ in metadata stream 3 0 R describes p without a pdfaProperty:description entry", + ] + ); + assert_eq!( + verify_packet(&format!( + r#"{ns}>{}"#, + schema( + "http://a.example/", + &format!("{named}externald") + ) + )), + ["The XMP extension schema container in metadata stream 3 0 R holds rdf:Seq, not one rdf:Bag"] + ); + assert_eq!( + verify_packet(&format!(r#"{ns}>"#)), + ["The XMP extension schema container in metadata stream 3 0 R holds no rdf:Bag"] + ); + let other = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:s="http://www.aiim.org/pdfa/ns/schema#""#; + assert_eq!( + verify_packet(&format!( + r#"{other}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# with the prefix s, not pdfaSchema"] + ); + let default = r#"xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/""#; + assert_eq!( + verify_packet(&format!( + r#"{default}>Shttp://a.example/e"# + )), + ["Metadata stream 3 0 R writes the namespace http://www.aiim.org/pdfa/ns/schema# without a prefix, not with pdfaSchema"] + ); +} + +/// PDF/A-2 6.6.2.3.1 for the TIFF, EXIF and Additional EXIF schemas: a property is known by its +/// namespace URI, whatever prefix the packet binds to it, and is named by the namespace's +/// preferred prefix. +#[test] +fn xmp_exif_properties_are_known_by_namespace_uri() { + let tiff = r#"xmlns:foo="http://ns.adobe.com/tiff/1.0/""#; + assert_eq!(mistyped(&format!(r#">256 mm"#)), ["tiff:ImageWidth"]); + assert!(mistyped(&format!(r#">256"#)).is_empty()); + assert_eq!(mistyped(r#"xmlns:bar="http://ns.adobe.com/exif/1.0/" bar:GPSLatitude="51.5N">"#), ["exif:GPSLatitude"]); + assert_eq!( + mistyped(r#">50mm"#), + ["aux:Lens"] + ); + // The same local names in another namespace, even under the preferred prefixes, are not these + // properties. + let other = r#"xmlns:tiff="http://example.com/not-tiff/""#; + assert!(mistyped(&format!(r#">256 mm"#)).is_empty()); + assert!(mistyped(r#"xmlns:exif="http://example.com/not-exif/" exif:GPSLatitude="51.5N">"#).is_empty()); + assert!(mistyped(r#">50mm"#).is_empty()); +} + /// Holes are filled in one pass: the first `{}` from `value`, named holes from `values`, and what /// has nothing for it, a value's own braces and a brace left open stay as they are. #[test] @@ -755,6 +1420,12 @@ fn xmp_mistyped_properties_name_their_type() { [t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")] ); assert_eq!(types(">p"), [t("xmpMM:Pantry", "bag struct")]); + // The TIFF and EXIF types, as the 2005 edition writes them. + assert_eq!(types(r#"tiff:XResolution="0.5" exif:GPSLatitude="N">"#), [t("exif:GPSLatitude", "GPSCoordinate"), t("tiff:XResolution", "Rational")]); + assert_eq!( + types(">8firedo"), + [t("exif:Flash", "Flash"), t("exif:OECF", "OECF/SFR"), t("tiff:BitsPerSample", "seq Integer")] + ); } /// PDF/A-2 6.6.2.3.1: a property is known by its namespace URI, whatever prefix the packet binds @@ -831,3 +1502,661 @@ fn xmp_value_forms_are_those_xmp_allows() { ["xmpMM:DerivedFrom"] ); } + +/// PDF/A asks for the fonts "used for rendering" to be embedded (ISO 19005-2, 6.2.11.4.1). A font +/// that only the form's default resources hold (`/AcroForm /DR`, ISO 32000-1 12.7.2) draws +/// nothing, as in veraPDF's 6-4-1-t02-pass-a; one that a widget's appearance stream draws with is +/// named in that stream's `/Resources`, and still has to be embedded. +#[test] +fn only_fonts_something_draws_with_must_be_embedded() { + let fonts = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.11.4").map(|i| i.message).collect() }; + let form = |appearance_fonts: &str| { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 4 0 R /ZaDb 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << >> /Annots [6 0 R] >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /F 4 /Rect [10 10 90 30] /P 3 0 R /AP << /N 7 0 R >> >>", + &format!("<< /Type /XObject /Subtype /Form /BBox [0 0 80 20] /Resources << {appearance_fonts} >> /Length 0 >>\nstream\n\nendstream"), + // An object that is nothing but a reference to the Helvetica font (ISO 32000-1 7.3.10). + "4 0 R", + ], + "", + ) + }; + assert!(fonts(&form("")).is_empty(), "{:?}", fonts(&form(""))); + assert_eq!(fonts(&form("/Font << /Helv 4 0 R >>")), ["The font Helvetica isn't embedded"]); + // Named through a reference to it, it is still the font something draws with. + assert_eq!(fonts(&form("/Font << /Helv 8 0 R >>")), ["The font Helvetica isn't embedded"]); + // A page's own font is checked as before. + assert_eq!(fonts(&messy()), ["The font Helvetica isn't embedded"]); +} + +/// PDF/A-2 6.1.2: the file starts with `%PDF-1.0` to `%PDF-1.7` and an end-of-line marker, then a +/// comment of `%` and four bytes above 127; Save as PDF/A rewrites a header that doesn't. +#[test] +fn file_header_is_checked_and_rewritten() { + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let header = |h: &[u8]| -> Vec { + let doc = build_with_header(h, &objs, ""); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.2").map(|i| i.message).collect() + }; + let version = || vec!["The file doesn't start with a %PDF-1.0 to %PDF-1.7 header line".to_string()]; + let comment = || vec!["The file header isn't followed by a comment of four bytes above 127".to_string()]; + for fine in [&b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"[..], b"%PDF-1.4\r\n%\x80\x81\x82\x83af\r\n", b"%PDF-1.0\r%\xFF\xFF\xFF\xFF\r"] { + assert!(header(fine).is_empty(), "{}", String::from_utf8_lossy(fine)); + } + let wrong_version = [ + &b"%PDF-2.0\n%\xE2\xE3\xCF\xD3\n"[..], + b"%PDF-1.9\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.70\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.7 \n%\xE2\xE3\xCF\xD3\n", + b"SomeData\n%PDF-1.7\n%\xE2\xE3\xCF\xD3\n", + ]; + for wrong in wrong_version { + assert_eq!(header(wrong), version(), "{}", String::from_utf8_lossy(wrong)); + } + let wrong_comment = [ + &b"%PDF-1.7\n%\xE2\xE3\xCF\n"[..], + b"%PDF-1.7\ninvalidData\n", + b"%PDF-1.7\n%a\xE3\xCF\xD3\n", + b"%PDF-1.7\n\n%\xE2\xE3\xCF\xD3\n", + b"%PDF-1.7\n", + ]; + for wrong in wrong_comment { + assert_eq!(header(wrong), comment(), "{}", String::from_utf8_lossy(wrong)); + } + // Save as PDF/A rewrites the header: the version drops to 1.7 and the next save is full. + let mut doc = build_with_header(b"%PDF-2.0\n%ab\n", &objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert_eq!(doc.version(), "1.7"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.2")); + let saved = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + assert!(saved.starts_with(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")); + // A good version with a short comment is rewritten too, keeping its version. + let mut doc = build_with_header(b"%PDF-1.4\n%ab\n", &objs, ""); + assert!(!doc.full_save_required()); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Rewrote the file header")); + assert!(doc.full_save_required()); + assert_eq!(doc.version(), "1.4"); + // A good header is left as it is. + let mut doc = build(&objs, ""); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(!report.fixed.iter().any(|f| f == "Rewrote the file header")); +} + +/// PDF/A-2 6.2.5: a graphics state has no transfer function, halftones of type 1 or 5 without a +/// name, and a standard rendering intent, wherever it is kept. +#[test] +fn extended_graphics_states_are_checked() { + let gs = |states: &str| -> Vec { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << {states} >> >> >>"); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + "<< /TR 4 0 R >>", + "<< /Type /Halftone /HalftoneType 6 /Width 2 /Height 2 /Length 4 >>\nstream\nabcd\nendstream", + "<< /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >>", + ], + "", + ); + let issues = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5"); + issues.inspect(|i| assert!(!i.fixable, "{}", i.message)).map(|i| i.message).collect() + }; + let tr = "A graphics state has a transfer function (TR)"; + let tr2 = "A graphics state has a transfer function (TR2) other than Default"; + let name = "A halftone has a HalftoneName"; + // What 6.2.5 allows. + assert!(gs("/A << /TR2 /Default /HT 5 0 R /RI /Perceptual >> /B << /HT /Default /RI /AbsoluteColorimetric >>").is_empty()); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R >> /RI /Saturation >> /B << /TR null >>").is_empty()); + // Transfer functions. + assert_eq!(gs("/A << /TR 4 0 R >>"), [tr]); + assert_eq!(gs("/A << /TR /Identity >>"), [tr]); + assert_eq!(gs("/A 6 0 R"), [tr]); + assert_eq!(gs("/A << /TR2 4 0 R >>"), [tr2]); + assert_eq!(gs("/A << /TR2 /Identity >>"), [tr2]); + assert_eq!(gs("/A << /HTP [0 1] >>"), ["A graphics state has a halftone phase (HTP)"]); + // Halftones: the type, also of a type 5 halftone's colorants and of a stream, and the name. + assert_eq!(gs("/A << /HT << /HalftoneType 6 >> >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!(gs("/A << /HT 7 0 R >>"), ["A halftone has type 6: only types 1 and 5 are allowed"]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Magenta << /HalftoneType 10 >> >> >>"), + ["A halftone has type 10: only types 1 and 5 are allowed"] + ); + assert_eq!(gs("/A << /HT << /HalftoneType 1 /HalftoneName (Screen) >> >>"), [name]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default << /HalftoneType 1 /HalftoneName (S) >> >> >>"), [name]); + // Rendering intents. + assert_eq!(gs("/A << /RI /Custom >>"), ["A graphics state has the rendering intent Custom, which isn't a standard one"]); + assert_eq!(gs("/A << /RI (Perceptual) >>"), ["A graphics state has a rendering intent that isn't a name"]); + // A halftone's type is required; a null name is no name. + let untyped = ["A halftone's HalftoneType is missing or isn't an integer"]; + assert_eq!(gs("/A << /HT << /Frequency 60 >> >>"), untyped); + assert_eq!(gs("/A << /HT << /HalftoneType 1.0 >> >>"), untyped); + assert!(gs("/A << /HT << /HalftoneType 1 /HalftoneName null >> >>").is_empty()); + // Transfer functions in halftones: only, and always, for a non-primary colorant. + let in_halftone = "A halftone has a transfer function, which only a non-primary colorant's halftone may have"; + assert_eq!(gs("/A << /HT << /HalftoneType 1 /TransferFunction 4 0 R >> >>"), [in_halftone]); + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Cyan << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + // The primary components are those of DeviceGray, DeviceRGB and DeviceCMYK (ISO 32000-1 10.5.5.6). + assert_eq!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Gray << /HalftoneType 1 /TransferFunction /Identity >> >> >>"), [in_halftone]); + assert_eq!( + gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Spot 5 0 R /Red 5 0 R /Gray 5 0 R >> >>"), + ["The halftone for the colorant Spot has no transfer function"] + ); + assert!(gs("/A << /HT << /HalftoneType 5 /Default 5 0 R /Black 5 0 R /Spot << /HalftoneType 1 /TransferFunction 4 0 R >> >> >>").is_empty()); + // Private application data isn't graphics resources. + assert!( + gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private << /Resources << /ExtGState << /X << /TR 4 0 R >> >> >> >> >>").is_empty() + ); + // Nor is an object (8) only private data refers to; one the page refers to is read. + assert!(gs("/A << /RI /Perceptual >> >> /PieceInfo << /App << /Private 8 0 R >> >> /Z <<").is_empty()); + assert_eq!(gs("/A << /RI /Perceptual >> >> /Other 8 0 R /Z <<"), [tr]); + // A shading pattern holds one graphics state, not a dictionary of them. + let pattern = |gs: &str| format!("/A << /RI /Perceptual >> >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 2 >> /ExtGState {gs} >>"); + assert_eq!(gs(&pattern("<< /TR 4 0 R >>")), [tr]); + assert!(gs(&pattern("<< /HT << /HalftoneType 1 >> >>")).is_empty()); +} + +/// From the review of the 6.2.5 check: references that differ only in generation reach the same +/// object, which is read once. +#[test] +fn graphics_state_scan_reads_each_object_once_whatever_the_generation() { + let gens: String = (0..10).map(|g| format!("4 {g} R ")).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Same [{gens}] >>"); + let doc = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, "[1 2 3]"], ""); + // The catalog, the page tree, the page and object 4. + assert_eq!(gstate::reached(&doc), 4); +} + +/// PDF/A-2 6.1.3: the trailer `startxref` points to has a file identifier, two non-empty +/// strings; Save as PDF/A gives a file one. +#[test] +fn trailer_file_identifier_is_checked_and_added() { + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"]; + let id = |extra: &str| -> Vec { + let doc = build(&objs, extra); + verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.3").map(|i| i.message).collect() + }; + let missing = ["The trailer has no file identifier (ID)"]; + let malformed = ["The file identifier (ID) in the trailer isn't two non-empty strings"]; + assert!(id("/ID [<0011> (ab)]").is_empty()); + assert_eq!(id(""), missing); + assert_eq!(id("/ID null"), missing); + for wrong in ["/ID [<><>]", "/ID [<0011>]", "/ID [<0011> <0011> <0011>]", "/ID [1 2]", "/ID (ab)"] { + assert_eq!(id(wrong), malformed, "{wrong}"); + } + // An update without an /ID has none, whatever the trailer it replaces had. + let base = build(&objs, "/ID [<0011> <0011>]"); + let mut bytes = base.bytes().to_vec(); + let prev = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + let prev: usize = String::from_utf8_lossy(&bytes[prev + 10..]).lines().next().unwrap().trim().parse().unwrap(); + let at = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f \ntrailer\n<< /Size 3 /Root 1 0 R /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + let updated = Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(verify(&updated, Level::A2b).iter().any(|i| i.message == missing[0])); + // Save as PDF/A adds one, which a save keeps. + for extra in ["", "/ID [<><>]"] { + let mut doc = build(&objs, extra); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)"), "{extra}"); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + let saved = pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap(); + let saved = Document::open(std::sync::Arc::new(saved)).unwrap(); + assert!(verify(&saved, Level::A2b).iter().all(|i| i.clause != "6.1.3"), "{extra}"); + } + // A good one is kept as it is. + let mut doc = build(&objs, "/ID [<0011> <0022>]"); + let before = doc.trailer().get(b"ID").cloned(); + assert!(!convert(&mut doc, Level::A2b).unwrap().fixed.iter().any(|f| f.contains("identifier"))); + assert_eq!(doc.trailer().get(b"ID").cloned(), before); + // A malformed one keeps its non-empty first string, the permanent identifier (14.4). + let first = |doc: &Document| doc.trailer().get(b"ID").and_then(|id| id.as_array()?.first()?.as_string().map(|s| s.bytes.clone())); + let mut doc = build(&objs, "/ID [<0011> <>]"); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Completed the file identifier (ID)")); + assert_eq!(first(&doc), Some(vec![0x00, 0x11])); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.1.3")); + let mut doc = build(&objs, "/ID [<> <0011>]"); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc).map(|b| b.len()), Some(16)); + // A first string longer than a conforming file may hold (Annex C) is made afresh too. + let long = format!("/ID [<{}> <>]", "ab".repeat(32768)); + let mut doc = build(&objs, &long); + let report = convert(&mut doc, Level::A2b).unwrap(); + assert!(report.fixed.iter().any(|f| f == "Added a file identifier (ID)")); + assert_eq!(first(&doc).map(|b| b.len()), Some(16)); + let longest = format!("/ID [<{}> <>]", "ab".repeat(32767)); + let mut doc = build(&objs, &longest); + convert(&mut doc, Level::A2b).unwrap(); + assert_eq!(first(&doc).map(|b| b.len()), Some(32767)); +} + +/// PDF/A-2 6.5.1: the PDF 1.2 SetState and NOP actions aren't allowed, and a Named action may +/// only name NextPage, PrevPage, FirstPage or LastPage; Save as PDF/A removes the others. +#[test] +fn set_state_no_op_and_named_actions_are_checked() { + let doc_with = |action: &str| { + let outline = format!("<< /Title (x) /Parent 4 0 R /A {action} >>"); + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + &outline, + "<< /Type /Outlines /First 3 0 R /Last 3 0 R /Count 1 >>", + "/NextPage", + "/Named", + "/NOP", + ], + "", + ) + }; + let actions = |action: &str| -> Vec { + verify(&doc_with(action), Level::A2b).into_iter().filter(|i| i.clause == "6.5.1").map(|i| i.message).collect() + }; + assert_eq!(actions("<< /Type /Action /S /SetState /T [] >>"), ["A SetState action isn't allowed"]); + assert_eq!(actions("<< /S /NOP >>"), ["A NOP action isn't allowed"]); + assert_eq!(actions("<< /S /Named /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S /Named >>"), ["A Named action isn't allowed"]); + // Behind an allowed action, in its /Next. + assert_eq!(actions("<< /S /GoTo /D [0 /Fit] /Next << /S /NOP >> >>"), ["A NOP action isn't allowed"]); + for allowed in ["NextPage", "PrevPage", "FirstPage", "LastPage"] { + assert!(actions(&format!("<< /S /Named /N /{allowed} >>")).is_empty(), "{allowed}"); + } + // The name given by reference (object 5 is /NextPage). + assert!(actions("<< /S /Named /N 5 0 R >>").is_empty()); + // The type given by reference (object 6 is /Named, 7 is /NOP). + assert_eq!(actions("<< /S 6 0 R /N /Print >>"), ["A Named Print action isn't allowed"]); + assert_eq!(actions("<< /S 7 0 R >>"), ["A NOP action isn't allowed"]); + assert!(actions("<< /S 6 0 R /N 5 0 R >>").is_empty()); + // Save as PDF/A removes them. + for action in ["<< /S /SetState /T [] >>", "<< /S /NOP >>", "<< /S /Named /N /Print >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + assert!(verify(&doc, Level::A2b).iter().all(|i| i.clause != "6.5.1"), "{action}"); + } + // A permitted Named action survives it, also one naming its page move by reference. + for action in ["<< /S /Named /N /NextPage >>", "<< /S /Named /N 5 0 R >>"] { + let mut doc = doc_with(action); + convert(&mut doc, Level::A2b).unwrap(); + let outline = doc.get(ObjRef { num: 3, generation: 0 }); + let kept = outline + .as_dict() + .and_then(|d| d.get(b"A")) + .and_then(Object::as_dict) + .map(|a| (a.name(b"S").map(<[u8]>::to_vec), a.get(b"N").map(|n| doc.resolve(n)))); + let (s, n) = kept.unwrap_or_else(|| panic!("{action}: the action is gone")); + assert_eq!(s.as_deref(), Some(&b"Named"[..]), "{action}"); + assert_eq!(n.as_deref().and_then(Object::as_name), Some(&b"NextPage"[..]), "{action}"); + } +} + +/// The SetState, NOP and Named checks of 6.5.1 apply wherever an action can be: the catalog's +/// `/OpenAction`, an action standing alone as an object of its own (here one a page's `/AA` +/// refers to), and a link's `/A`, not only an outline item's. A Named action's `/N` given by +/// reference to a forbidden name is reported by that name. Save as PDF/A removes each one and +/// keeps a Named page move in the same places. +#[test] +fn set_state_no_op_and_named_actions_are_checked_wherever_actions_are() { + // Object 5 is the standalone action, 6 is /Print and 7 is /NextPage. + let doc_with = |open_action: &str, page_aa: &str, link_action: &str, standalone: &str| { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {open_action} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] {page_aa} >>"); + let link = format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 {link_action} >>"); + build(&[&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page, &link, standalone, "/Print", "/NextPage"], "") + }; + let goto = "<< /S /GoTo /D [3 0 R /Fit] >>"; + // Where the action is, as (open action, page /AA, link /A, object 5). + let placed = |action: &str| { + [ + ("an open action", doc_with(&format!("/OpenAction {action}"), "", "", goto)), + ("an open action by reference", doc_with("/OpenAction 5 0 R", "", "", action)), + ("a page's /AA", doc_with("", "/AA << /O 5 0 R >>", "", action)), + ("a link's /A", doc_with("", "", &format!("/A {action}"), goto)), + ] + }; + let forbidden = [ + ("<< /S /SetState /T [] >>", "A SetState action isn't allowed"), + ("<< /S /NOP >>", "A NOP action isn't allowed"), + ("<< /S /Named /N /Print >>", "A Named Print action isn't allowed"), + ("<< /S /Named /N 6 0 R >>", "A Named Print action isn't allowed"), + ]; + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + for (action, message) in forbidden { + for (place, mut doc) in placed(action) { + let issues = verify(&doc, Level::A2b); + let found: Vec<&str> = issues.iter().filter(|i| i.clause == "6.5.1").map(|i| i.message.as_str()).collect(); + assert_eq!(found, [message], "{action} in {place}: {issues:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + let left: Vec<_> = r.remaining.iter().filter(|i| i.clause.starts_with("6.5")).collect(); + assert!(left.is_empty(), "{action} in {place}: {left:#?}"); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + for kind in [&b"/SetState"[..], b"/NOP", b"/Named"] { + assert!(!bytes.windows(kind.len()).any(|w| w == kind), "{action} in {place}: {}", String::from_utf8_lossy(&bytes)); + } + } + } + // A Named page move, with its name given by reference, is allowed and kept outside /AA. + for (place, mut doc) in placed("<< /S /Named /N 7 0 R >>") { + let issues = verify(&doc, Level::A2b); + assert!(!issues.iter().any(|i| i.clause == "6.5.1"), "{place}: {issues:#?}"); + convert(&mut doc, Level::A2b).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + let kept = bytes.windows(6).any(|w| w == b"/Named"); + assert_eq!(kept, place != "a page's /AA", "{place}: {}", String::from_utf8_lossy(&bytes)); + } +} + +/// 6.2.10: without a PDF/A output intent, a page whose resources can paint transparently names its +/// blending colour space in its `/Group`. Transparency is found through graphics states, images, +/// transparency groups, nested form XObjects, Type 3 fonts, patterns and annotation appearances. +#[test] +fn a_page_with_transparency_and_no_output_intent_names_its_blending_space() { + // With `intent`, the catalog has a PDF/A output intent, its profile the last object. + let blending = |intent: bool, page_extra: &str, resources: &str, extra: &[&str]| -> Vec { + let profile = extra.len() + 4; + let intents = + format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) /DestOutputProfile {profile} 0 R >>]"); + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {} >>", if intent { intents.as_str() } else { "" }); + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> {page_extra} >>"); + let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + if intent { + objs.push( + "<< /N 3 /Length 0 >> +stream + +endstream", + ); + } + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]; + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + let gs = |entries: &str| format!("/ExtGState << /G << {entries} >> >>"); + for (what, resources, extra) in [ + ("fill alpha", gs("/ca 0.5"), vec![]), + ("stroke alpha", gs("/CA 0.25"), vec![]), + ("blend mode", gs("/BM /Darken"), vec![]), + ("blend mode array", gs("/BM [/Multiply /Normal]"), vec![]), + ("soft mask", gs("/SMask << /S /Luminosity /G 4 0 R >>"), vec![form("")]), + ( + "image soft mask", + "/XObject << /Im 4 0 R >>".to_owned(), + vec![ + "<< /Subtype /Image /Width 1 /Height 1 /SMask 5 0 R /Length 0 >>\nstream\n\nendstream".to_owned(), + "<< /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 0 >>\nstream\n\nendstream".to_owned(), + ], + ), + ( + "SMaskInData", + "/XObject << /Im 4 0 R >>".to_owned(), + vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 1 /Length 0 >>\nstream\n\nendstream".to_owned()], + ), + ("transparency group", "/XObject << /Fm 4 0 R >>".to_owned(), vec![form("/Group << /S /Transparency >>")]), + ( + "nested form", + "/XObject << /Fm 4 0 R >>".to_owned(), + vec![form("/Resources << /XObject << /In 5 0 R >> >>"), form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")], + ), + ( + "Type 3 font", + "/Font << /T3 4 0 R >>".to_owned(), + vec!["<< /Type /Font /Subtype /Type3 /Resources << /ExtGState << /G << /CA 0.5 >> >> >> >>".to_owned()], + ), + ("tiling pattern", "/Pattern << /P 4 0 R >>".to_owned(), vec![form("/PatternType 1 /Resources << /ExtGState << /G << /BM /Screen >> >> >>")]), + ("shading pattern", "/Pattern << /P << /PatternType 2 /ExtGState << /ca 0.5 >> >> >>".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(blending(false, "", &resources, &extra), said, "{what}"); + // A page group with a blending colour space, or a PDF/A output intent, satisfies the rule. + assert!( + blending(false, "/Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >>", &resources, &extra).is_empty(), + "{what}: page /CS" + ); + assert_eq!(blending(false, "/Group << /S /Transparency >>", &resources, &extra), said, "{what}: page group without /CS"); + assert!(blending(true, "", &resources, &extra).is_empty(), "{what}: output intent"); + } + // Annotation appearances, as one stream or a dictionary of streams by state. + let annot = |ap: &str| format!("<< /Type /Annot /Subtype /Widget /Rect [0 0 10 10] /AP << /N {ap} >> >>"); + assert_eq!(blending(false, "/Annots [4 0 R]", "", &[&annot("5 0 R"), &form("/Group << /S /Transparency >>")]), said, "appearance"); + assert_eq!( + blending(false, "/Annots [4 0 R]", "", &[&annot("<< /On 5 0 R /Off 6 0 R >>"), &form(""), &form("/Group << /S /Transparency >>")]), + said, + "appearance states" + ); + // Opaque painting needs no blending colour space: alpha 1, Normal or Compatible blending, no + // soft mask, an image without one, a form that is no transparency group. + for (what, resources, extra) in [ + ("opaque", gs("/CA 1 /ca 1.0 /BM /Normal /SMask /None"), vec![]), + ("compatible", gs("/BM [/Compatible /Darken]"), vec![]), + ( + "image", + "/XObject << /Im 4 0 R >>".to_owned(), + vec!["<< /Subtype /Image /Width 1 /Height 1 /SMaskInData 0 /Length 0 >>\nstream\n\nendstream".to_owned()], + ), + ( + "plain form, its resources cycling back to itself", + "/XObject << /Fm 4 0 R >>".to_owned(), + vec![form("/Resources << /XObject << /Me 4 0 R >> >>")], + ), + ( + "Type 1 font", + "/Font << /F 4 0 R >>".to_owned(), + vec!["<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Resources << /ExtGState << /G << /ca 0.5 >> >> >> >>".to_owned()], + ), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert!(blending(false, "", &resources, &extra).is_empty(), "{what}"); + } +} + +/// 6.2.10: a graphics state's blend mode, or each name of a blend mode array, is one ISO 32000-1 +/// 11.3.5 defines, whether the graphics state is an object of its own or written in a page's +/// resources. +#[test] +fn blend_modes_are_the_ones_iso_32000_1_defines() { + let modes = |resources: &str, extra: &[&str]| -> Vec { + let page = format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << {resources} >> >>" + ); + let mut objs: Vec<&str> = + vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let custom = ["A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define"]; + assert_eq!(modes("/ExtGState << /G 4 0 R >>", &["<< /BM /Custom >>"]), custom, "an object of its own"); + assert_eq!(modes("/ExtGState << /G << /BM /Custom >> >>", &[]), custom, "written in the resources"); + assert_eq!(modes("/ExtGState << /G << /BM [/Multiply /Custom] >> >>", &[]), custom, "in an array"); + let all = "/Normal /Compatible /Multiply /Screen /Overlay /Darken /Lighten /ColorDodge /ColorBurn /HardLight /SoftLight /Difference /Exclusion /Hue /Saturation /Color /Luminosity"; + assert!(modes(&format!("/ExtGState << /G << /BM [{all}] >> >>"), &[]).is_empty()); +} + +/// 6.2.10's blending colour space is one PDF/A accepts without an output intent: not null, and +/// not a device space unless the page gives it a default (6.2.4.3). An output intent stands in +/// for it only with a profile. +#[test] +fn a_blending_space_is_device_independent_and_an_intent_has_a_profile() { + let page_said = ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]; + let check = |catalog_extra: &str, page_extra: &str, extra: &[&str]| -> Vec { + let catalog = format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"); + let page = format!("<< /Type /Page /Parent 2 0 R {page_extra} >>"); + let mut objs: Vec<&str> = vec![&catalog, "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + objs.extend_from_slice(extra); + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect() + }; + let alpha = "/ExtGState << /G << /ca 0.5 >> >>"; + let page = |group: &str, colour_spaces: &str| format!("/Group << /S /Transparency {group} >> /Resources << {alpha} {colour_spaces} >>"); + assert_eq!(check("", &page("/CS null", ""), &[]), page_said, "a null /CS"); + assert_eq!(check("", &page("/CS 9 0 R", ""), &[]), page_said, "a /CS naming no object"); + // A device space needs a default of as many components (ISO 32000-1 8.6.5.6). + let cmyk_profile = "<< /N 4 /Length 0 >>\nstream\n\nendstream"; + let cal = |family: &str| format!("[/{family} << /WhitePoint [0.9505 1 1.089] >>]"); + for (device, default, space) in [ + ("DeviceGray", "DefaultGray", cal("CalGray")), + ("DeviceRGB", "DefaultRGB", cal("CalRGB")), + ("DeviceCMYK", "DefaultCMYK", "[/ICCBased 4 0 R]".to_owned()), + ] { + assert_eq!(check("", &page(&format!("/CS /{device}"), ""), &[cmyk_profile]), page_said, "{device}"); + let defaults = format!("/ColorSpace << /{default} {space} >>"); + assert!(check("", &page(&format!("/CS /{device}"), &defaults), &[cmyk_profile]).is_empty(), "{device} with {default}"); + let null = format!("/ColorSpace << /{default} null >>"); + assert_eq!(check("", &page(&format!("/CS /{device}"), &null), &[cmyk_profile]), page_said, "{device} with a null {default}"); + } + let wrong = format!("/ColorSpace << /DefaultGray {} >>", cal("CalRGB")); + assert_eq!(check("", &page("/CS /DeviceGray", &wrong), &[]), page_said, "DeviceGray with a CalRGB default"); + // Only CalGray, CalRGB and gray, RGB or CMYK ICCBased spaces blend (ISO 32000-1 11.3.4). + assert!(check("", &page("/CS [/CalGray << /WhitePoint [0.9505 1 1.089] >>]", ""), &[]).is_empty(), "CalGray"); + let icc = |n: u8, data: &str| format!("<< /N {n} /Length {} >>\nstream\n{data}\nendstream", data.len()); + assert!(check("", &page("/CS [/ICCBased 4 0 R]", ""), &[&icc(3, "")]).is_empty(), "an ICC profile of three components"); + for (what, cs, extra) in [ + ("Indexed", "/CS [/Indexed /DeviceRGB 1 <000000FFFFFF>]".to_owned(), vec![]), + ("CalRGB without its dictionary", "/CS [/CalRGB]".to_owned(), vec![]), + ("CalGray without a white point", "/CS [/CalGray << >>]".to_owned(), vec![]), + ("CalRGB with a short white point", "/CS [/CalRGB << /WhitePoint [0.9505 1] >>]".to_owned(), vec![]), + ("CalRGB with a zero white point", "/CS [/CalRGB << /WhitePoint [0 0 0] >>]".to_owned(), vec![]), + ("CalGray with a white point whose Y isn't 1", "/CS [/CalGray << /WhitePoint [0.9505 0.5 1.089] >>]".to_owned(), vec![]), + ("CalRGB with a non-number in its white point", "/CS [/CalRGB << /WhitePoint [0.9505 1 /Z 1.089] >>]".to_owned(), vec![]), + ("Lab", "/CS [/Lab << /WhitePoint [0.9505 1 1.089] >>]".to_owned(), vec![]), + ( + "Separation", + "/CS [/Separation /Spot /DeviceCMYK 4 0 R]".to_owned(), + vec!["<< /FunctionType 2 /Domain [0 1] /C1 [0 0 0 1] /N 1 >>".to_owned()], + ), + ("an ICC profile of two components", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(2, "")]), + ("a Lab ICC profile", "/CS [/ICCBased 4 0 R]".to_owned(), vec![icc(3, &format!("{}Lab ", "\0".repeat(16)))]), + ( + "an ICC profile of one component whose header says RGB", + "/CS [/ICCBased 4 0 R]".to_owned(), + vec![icc(1, &format!("{}RGB ", "\0".repeat(16)))], + ), + ("a pattern space", "/CS /Pattern".to_owned(), vec![]), + ] { + let extra: Vec<&str> = extra.iter().map(String::as_str).collect(); + assert_eq!(check("", &page(&cs, ""), &extra), page_said, "{what}"); + } + // A null soft mask is none: an otherwise opaque graphics state is not transparency. + assert!(check("", "/Resources << /ExtGState << /G << /SMask null >> >> >>", &[]).is_empty(), "/SMask null"); + let intent = |profile: &str| format!("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB) {profile} >>]"); + let opaque_group = format!("/Resources << {alpha} >>"); + assert_eq!(check(&intent("/DestOutputProfile null"), &opaque_group, &[]), page_said, "a null profile"); + assert_eq!(check(&intent(""), &opaque_group, &[]), page_said, "no profile"); + assert!(check(&intent("/DestOutputProfile 4 0 R"), &opaque_group, &["<< /N 3 /Length 0 >>\nstream\n\nendstream"]).is_empty(), "a profile"); + // Private data is no graphics state: a /BM there is not a blend mode. + assert!(check("/PieceInfo << /App << /Private << /BM /Bookkeeping >> >> >>", "", &[]).is_empty(), "/PieceInfo"); +} + +/// A form XObject first reached at the depth limit, where its resources go unread, is read again +/// when a shallower place names it, so what it holds is not missed. +#[test] +fn transparency_reached_first_too_deep_is_found_from_a_shallower_place() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + // A chain of 32 forms, objects 4 to 35, the last naming form 36, which holds a translucent + // graphics state; the page names the chain first and form 36 after it. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 4 0 R /B 36 0 R >> >> >>".to_owned(), + ]; + for n in 4..=35 { + objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1))); + } + objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said, ["Page 1 has transparency but no device-independent blending colour space or PDF/A output intent"]); +} + +/// 6.2.10: an unknown blend mode is reported on the page whose resources reach it, as the +/// transparency issue is, each page once. +#[test] +fn an_unknown_blend_mode_names_its_page() { + let page = "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G 5 0 R >> >> >>"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R >>", + page, + "<< /BM /Custom >>", + ]; + let found: Vec<(String, Option)> = + verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| (i.message, i.page)).collect(); + assert_eq!(found, [("A graphics state uses the blend mode Custom, which ISO 32000-1 doesn't define".to_owned(), Some(1))]); +} + +/// A `/BM` array of many unknown names gathers a bounded number of them. +#[test] +fn unknown_blend_mode_names_are_capped() { + let names: String = (0..1000).map(|k| format!("/Custom{k} ")).collect(); + let page = format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G << /BM [/Multiply {names}] >> >> >> >>" + ); + let objs = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", &page]; + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said.len(), 32); + assert_eq!(said.first().map(String::as_str), Some("A graphics state uses the blend mode Custom0, which ISO 32000-1 doesn't define")); +} + +/// Pages that share a form read it once: the next page takes what was found under it from +/// [`transparency::Shared`] in one read, and still reports it. +#[test] +fn pages_sharing_a_form_read_it_once() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + let pages = 50; + let first_form = 3 + pages; + let kids: String = (0..pages).map(|k| format!("{} 0 R ", 3 + k)).collect(); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 200 200] >>")]; + for _ in 0..pages { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Resources << /XObject << /F {first_form} 0 R >> >> >>")); + } + // A chain of ten forms, each with twenty graphics states, the last one translucent. + let gstates: String = (0..20).map(|k| format!("/G{k} << /CA 1 >> ")).collect(); + for n in 0..10 { + let next = if n < 9 { format!("/XObject << /X {} 0 R >>", first_form + n + 1) } else { "/ExtGState << /T << /ca 0.5 >> >>".to_owned() }; + objs.push(form(&format!("/Resources << /ExtGState << {gstates} >> {next} >>"))); + } + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&objs, ""); + let list = pdfcraft_model::pages(&doc); + let mut shared = transparency::Shared::default(); + let reads: Vec = list.iter().map(|p| transparency::page_reads(&doc, &p.dict, &mut shared)).collect(); + assert!(reads.first().is_some_and(|&n| n > 200), "the first page reads the chain: {reads:?}"); + assert!(reads.iter().skip(1).all(|&n| n == 2), "the others its resources and the form: {reads:?}"); + let said = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").count(); + assert_eq!(said, pages, "every page has transparency"); +} + +/// A form whose reading stopped at the depth limit on one page is not kept as read: a page that +/// names it from a shallower place reads what lies below it. +#[test] +fn a_form_cut_short_on_one_page_is_read_again_on_the_next() { + let form = |dict: &str| format!("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] {dict} /Length 0 >>\nstream\n\nendstream"); + // Page 1 (object 3) names a chain of 33 forms, objects 5 to 37, whose last holds a translucent + // graphics state beyond the depth limit; page 2 (object 4) names form 20, from which it is near. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 5 0 R >> >> >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /A 20 0 R >> >> >>".to_owned(), + ]; + for n in 5..=36 { + objs.push(form(&format!("/Resources << /XObject << /X {} 0 R >> >>", n + 1))); + } + objs.push(form("/Resources << /ExtGState << /G << /ca 0.5 >> >> >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let said: Vec = verify(&build(&objs, ""), Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(said, ["Page 2 has transparency but no device-independent blending colour space or PDF/A output intent"]); +} diff --git a/crates/preflight/src/trailer.rs b/crates/preflight/src/trailer.rs new file mode 100644 index 0000000000..7c9d134bec --- /dev/null +++ b/crates/preflight/src/trailer.rs @@ -0,0 +1,55 @@ +//! PDF/A-2 6.1.3, the file trailer: it has an `/ID`, a file identifier as ISO 32000-1 14.4 +//! defines it, an array of two byte strings. +//! +//! The trailer read is the one the file's last `startxref` points to: the latest update's, or a +//! linearized file's first-page trailer (ISO 32000-1 F.3.4). Trailers earlier in the chain don't +//! stand in for it. + +use pdfcraft_cos::{Document, Object}; + +/// The longest string a conforming file may hold (ISO 32000-1 Annex C, Table C.1). +const MAX_STRING: usize = 32767; + +/// What is wrong with the file identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdProblem { + /// The trailer has no `/ID`, or a null one. + Missing, + /// The `/ID` isn't an array of two non-empty strings. + Malformed, +} + +/// What 6.1.3 finds wrong with the file identifier of `doc`, if anything. +pub(crate) fn check(doc: &Document) -> Option { + let id = doc.trailer().get(b"ID").map(|id| doc.resolve(id)).filter(|id| !matches!(**id, Object::Null)); + let Some(id) = id else { return Some(IdProblem::Missing) }; + let strings = |parts: &[Object]| parts.iter().all(|p| doc.resolve(p).as_string().is_some_and(|s| !s.bytes.is_empty())); + match id.as_array() { + Some(parts) if parts.len() == 2 && strings(parts) => None, + _ => Some(IdProblem::Malformed), + } +} + +/// Gives `doc` a file identifier when it lacks a good one; what it fixed, if anything. A first +/// string a conforming file can keep (non-empty, within [`MAX_STRING`]) is the permanent +/// identifier (ISO 32000-1 14.4): it is kept, and only the second is made afresh. Otherwise both +/// are new, and the report says the identifier was added rather than completed. +pub(crate) fn fix(doc: &mut Document) -> Option { + check(doc)?; + let Object::Array(mut id) = pdfcraft_cos::new_file_id(doc.bytes(), 0) else { return None }; + let permanent = doc + .trailer() + .get(b"ID") + .map(|old| doc.resolve(old)) + .and_then(|old| old.as_array().and_then(|parts| parts.first().map(|p| doc.resolve(p)))) + .filter(|first| first.as_string().is_some_and(|s| (1..=MAX_STRING).contains(&s.bytes.len()))); + let kept = match (permanent, id.first_mut()) { + (Some(permanent), Some(slot)) => { + *slot = (*permanent).clone(); + true + } + _ => false, + }; + doc.trailer_mut().set(b"ID".to_vec(), Object::Array(id)); + Some(if kept { "Completed the file identifier (ID)" } else { "Added a file identifier (ID)" }.into()) +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs new file mode 100644 index 0000000000..605bf4dfae --- /dev/null +++ b/crates/preflight/src/transparency.rs @@ -0,0 +1,391 @@ +//! PDF/A-2 6.2.10: a graphics state's blend mode is one ISO 32000-1 defines, and a page that +//! contains transparency, in a file without a PDF/A output intent, names its blending colour +//! space: its `/Group` dictionary has a `/CS` entry, which as any colour space in such a file is +//! not a device space without a default (6.2.4.3). +//! +//! A page contains transparency when something its resources reach can paint transparently (ISO +//! 32000-1 11.6.4.4 to 11.6.6): a graphics state with a constant alpha (`/CA`, `/ca`) below 1, +//! a blend mode other than `Normal` or `Compatible`, or a soft mask; an image with a soft mask +//! (`/SMask`, or `/SMaskInData` above 0); or a transparency group XObject. The resources looked +//! through are the page's own, those of the form XObjects, tiling patterns and Type 3 fonts they +//! hold, and those of the appearance streams of the page's annotations. Whether the content uses +//! a resource is not asked: a resource a page holds is one it may paint with. Blend modes are read +//! from the same graphics states, so private data (`/PieceInfo`) is never taken for one. + +use std::collections::HashMap; +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +/// How many dictionaries one page's search reads, across its resources, XObjects, patterns, Type +/// 3 fonts and appearance streams. An object [`Shared`] holds counts as one read. +const MAX_VISITS: usize = 4096; +/// How deep resources nest (a form XObject in a form XObject, ...). +const MAX_DEPTH: usize = 32; +/// The resource categories that hold what a page paints with, read in this order. +const CATEGORIES: [&[u8]; 4] = [b"ExtGState", b"XObject", b"Pattern", b"Font"]; +/// How many unknown blend mode names one search gathers: enough to name them in the report, +/// without a `/BM` array of a million names comparing each with all the others. +const MAX_BLEND_NAMES: usize = 32; +/// How many objects [`Shared`] keeps what was found under. +const MAX_SHARED: usize = 1 << 16; + +/// What the graphics a page's resources reach paint with. +#[derive(Clone, Debug, Default)] +pub(crate) struct Painting { + /// Something paints transparently. + pub(crate) transparency: bool, + /// The blend modes graphics states name that ISO 32000-1 doesn't define. + pub(crate) unknown_blend_modes: Vec, +} + +impl Painting { + fn add(&mut self, other: &Painting) { + self.transparency |= other.transparency; + for name in &other.unknown_blend_modes { + self.add_blend_mode(name); + } + } + + /// Whether no more unknown blend mode names are gathered. + fn blend_modes_full(&self) -> bool { + self.unknown_blend_modes.len() >= MAX_BLEND_NAMES + } + + fn add_blend_mode(&mut self, name: &str) { + if !self.blend_modes_full() && !self.unknown_blend_modes.iter().any(|n| n == name) { + self.unknown_blend_modes.push(name.to_owned()); + } + } +} + +/// What was found under the indirect objects (resources, form XObjects, patterns, Type 3 fonts, +/// graphics states) that pages share, so the next page that names one takes it from here instead +/// of walking it again. Only an object read to the end goes in: none whose reading stopped below +/// it at the budget, at the depth limit, or at an object already read on that page (a cycle). +#[derive(Default)] +pub(crate) struct Shared(HashMap); + +/// An object read in place, or the object a reference names as the document holds it: either way +/// nothing is copied. +enum Held<'o> { + Direct(&'o Object), + Resolved(Arc), +} + +impl std::ops::Deref for Held<'_> { + type Target = Object; + + fn deref(&self) -> &Object { + match self { + Held::Direct(o) => o, + Held::Resolved(o) => o, + } + } +} + +/// `o`, or the object it refers to, without copying either ([`Document::resolve`] copies a direct +/// object). +fn held<'o>(doc: &Document, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => Held::Resolved(doc.resolve(o)), + o => Held::Direct(o), + } +} + +/// What the page `page` paints with: its resources, and the appearance streams of its +/// annotations. `shared` carries what earlier pages found under the objects they share. +pub(crate) fn page_painting(doc: &Document, page: &Dict, shared: &mut Shared) -> Painting { + walk_page(doc, page, shared).found +} + +/// How many reads the search of `page` took, each object taken from `shared` counting as one. +#[cfg(test)] +pub(crate) fn page_reads(doc: &Document, page: &Dict, shared: &mut Shared) -> usize { + walk_page(doc, page, shared).visits +} + +fn walk_page<'a>(doc: &'a Document, page: &Dict, shared: &'a mut Shared) -> Walk<'a> { + let mut walk = Walk { doc, shared, seen: HashMap::new(), visits: 0, cuts: 0, found: Painting::default() }; + if let Some(r) = page.get(b"Resources") { + walk.visit(r, 0, Walk::resources); + } + let annots = page.get(b"Annots").map(|a| held(doc, a)); + for a in annots.as_deref().and_then(Object::as_array).into_iter().flatten() { + let a = held(doc, a); + let Some(ap) = a.as_dict().and_then(|a| a.get(b"AP")).map(|ap| held(doc, ap)) else { continue }; + let Some(ap) = ap.as_dict() else { continue }; + // Each appearance (normal, rollover, down) is a stream, or a dictionary of streams by state. + for (_, v) in ap.iter() { + match &*held(doc, v) { + Object::Stream(_) => walk.visit(v, 0, Walk::xobject), + Object::Dict(states) => { + for (_, s) in states.iter() { + walk.visit(s, 0, Walk::xobject); + } + } + _ => {} + } + } + } + walk +} + +/// Whether `page` names a blending colour space PDF/A accepts without an output intent: its +/// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4: +/// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes +/// gray, RGB or CMYK; not Lab, Indexed, Pattern, Separation or DeviceN), and not a device colour +/// space unless the page's resources give that device space a default (`DefaultGray`, +/// `DefaultRGB`, `DefaultCMYK`) that is itself such a space with as many components (ISO +/// 32000-1 8.6.5.6), as PDF/A-2 6.2.4.3 asks of any device colour. +pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { + let group = page.get(b"Group").map(|g| doc.resolve(g)); + let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; + let (default, components): (&[u8], i64) = match &*cs { + Object::Name(n) if n == b"DeviceGray" => (b"DefaultGray", 1), + Object::Name(n) if n == b"DeviceRGB" => (b"DefaultRGB", 3), + Object::Name(n) if n == b"DeviceCMYK" => (b"DefaultCMYK", 4), + Object::Array(a) => return independent_blending_space(doc, a).is_some(), + _ => return false, + }; + let resources = page.get(b"Resources").map(|r| doc.resolve(r)); + let spaces = resources.as_deref().and_then(Object::as_dict).and_then(|r| r.get(b"ColorSpace")).map(|c| doc.resolve(c)); + let space = spaces.as_deref().and_then(Object::as_dict).and_then(|s| s.get(default)).map(|d| doc.resolve(d)); + space.as_deref().and_then(Object::as_array).and_then(|a| independent_blending_space(doc, a)) == Some(components) +} + +/// The number of components of the colour space array `a` when it is a device-independent +/// blending colour space: `CalGray` (1), `CalRGB` (3), or `ICCBased` with 1, 3 or 4 components +/// whose profile header, where it can be read (within the output intent's profile cap), says +/// gray, RGB or CMYK to match. +fn independent_blending_space(doc: &Document, a: &[Object]) -> Option { + let family = a.first().map(|f| doc.resolve(f)); + // A CalGray or CalRGB space needs its dictionary, with the required `/WhitePoint` [X Y Z]: X + // and Z positive, Y 1 (ISO 32000-1 Tables 63 and 64). + let calibrated = || { + let params = a.get(1).map(|p| doc.resolve(p)); + let white = params.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"WhitePoint")).map(|w| doc.resolve(w)); + let values: Option> = white.as_deref().and_then(Object::as_array).and_then(|w| w.iter().map(|v| doc.resolve(v).as_f64()).collect()); + matches!(values.as_deref(), Some(&[x, y, z]) if x > 0.0 && y == 1.0 && z > 0.0) + }; + match family.as_deref().and_then(Object::as_name)? { + b"CalGray" => calibrated().then_some(1), + b"CalRGB" => calibrated().then_some(3), + b"ICCBased" => { + let profile = a.get(1).map(|p| doc.resolve(p)); + let Some(Object::Stream(s)) = profile.as_deref() else { return None }; + let components = s.dict.get(b"N").and_then(|n| doc.resolve(n).as_int())?; + // ICC.1 header: the data colour space is bytes 16–19. + let header: Option = match s.decoded_within(crate::MAX_ICC_PROFILE).ok().and_then(|data| data.get(16..20).map(<[u8]>::to_vec)) { + Some(space) => match space.as_slice() { + b"GRAY" => Some(1), + b"RGB " => Some(3), + b"CMYK" => Some(4), + _ => return None, + }, + None => None, + }; + (matches!(components, 1 | 3 | 4) && header.is_none_or(|h| h == components)).then_some(components) + } + _ => None, + } +} + +struct Walk<'a> { + doc: &'a Document, + shared: &'a mut Shared, + /// The indirect objects already read on this page, each with the shallowest depth it was read + /// at: an object is read again only from a shallower place, so a cycle ends and a deep first + /// visit doesn't hide what a shallower one would read. + seen: HashMap, + visits: usize, + /// How many times reading stopped short (the budget, the depth limit, an object already read + /// on this page). An object with a stop below it doesn't go in [`Shared`]: what was found + /// under it may be incomplete. + cuts: usize, + found: Painting, +} + +impl Walk<'_> { + /// Read `o`, reached `depth` levels down, with `read`: from [`Shared`] when an earlier page + /// read it to the end, else when it is within the budget and the depth, and not an indirect + /// object read before on this page from as shallow a place. + fn visit(&mut self, o: &Object, depth: usize, read: fn(&mut Self, &Object, usize)) { + if self.visits >= MAX_VISITS || depth > MAX_DEPTH { + self.cuts = self.cuts.saturating_add(1); + return; + } + let Object::Ref(r) = o else { + self.visits += 1; + read(self, o, depth); + return; + }; + if let Some(found) = self.shared.0.get(r) { + self.visits += 1; + self.found.add(found); + return; + } + if self.seen.get(r).is_some_and(|&d| d <= depth) { + self.cuts = self.cuts.saturating_add(1); + return; + } + self.seen.insert(*r, depth); + self.visits += 1; + // Gather what this object holds on its own, to keep for the next page, then add it to what + // the page holds. + let outer = std::mem::take(&mut self.found); + let cuts = self.cuts; + read(self, o, depth); + let below = std::mem::replace(&mut self.found, outer); + self.found.add(&below); + if self.cuts == cuts && self.shared.0.len() < MAX_SHARED { + self.shared.0.insert(*r, below); + } + } + + fn resources(&mut self, r: &Object, depth: usize) { + let doc = self.doc; + let res = held(doc, r); + let Some(res) = res.as_dict() else { return }; + for category in CATEGORIES { + let entries = res.get(category).map(|d| held(doc, d)); + let Some(entries) = entries.as_deref().and_then(Object::as_dict) else { continue }; + for (_, v) in entries.iter() { + // Once the budget is spent nothing more is read, so nothing more is gathered either. + if self.visits >= MAX_VISITS { + self.cuts = self.cuts.saturating_add(1); + return; + } + match category { + b"ExtGState" => self.visit(v, depth, Self::ext_gstate), + b"XObject" => self.visit(v, depth, Self::xobject), + b"Pattern" => self.visit(v, depth, Self::pattern), + _ => self.visit(v, depth, Self::type3), + } + } + } + } + + /// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend + /// modes it names that ISO 32000-1 doesn't define. + fn ext_gstate(&mut self, g: &Object, _depth: usize) { + let doc = self.doc; + let g = held(doc, g); + let Some(g) = g.as_dict() else { return }; + let below_one = |key: &[u8]| g.get(key).and_then(|v| held(doc, v).as_f64()).is_some_and(|a| a < 1.0); + let bm = g.get(b"BM").map(|b| held(doc, b)); + let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); + // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). + let mask = g.get(b"SMask").map(|m| held(doc, m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); + self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; + if let Some(bm) = bm.as_deref() { + unknown_blend_modes(doc, bm, &mut self.found); + } + } + + /// An image with a soft mask, or a form XObject that is a transparency group or holds + /// transparency in its resources. + fn xobject(&mut self, x: &Object, depth: usize) { + let doc = self.doc; + let x = held(doc, x); + let Object::Stream(s) = &*x else { return }; + let d = &s.dict; + if d.name(b"Subtype") == Some(b"Image") { + let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_))); + let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0); + self.found.transparency |= soft_mask || in_data; + return; + } + let group = d.get(b"Group").map(|g| held(doc, g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); + self.found.transparency |= group; + if let Some(r) = d.get(b"Resources") { + self.visit(r, depth + 1, Self::resources); + } + } + + /// A tiling pattern's resources, or a shading pattern's graphics state. + fn pattern(&mut self, p: &Object, depth: usize) { + let p = held(self.doc, p); + let Some(d) = (match &*p { + Object::Stream(s) => Some(&s.dict), + Object::Dict(d) => Some(d), + _ => None, + }) else { + return; + }; + if let Some(g) = d.get(b"ExtGState") { + self.visit(g, depth + 1, Self::ext_gstate); + } + if let Some(r) = d.get(b"Resources") { + self.visit(r, depth + 1, Self::resources); + } + } + + /// A Type 3 font's resources, which its glyph procedures paint with. + fn type3(&mut self, f: &Object, depth: usize) { + let f = held(self.doc, f); + let Some(d) = f.as_dict() else { return }; + if d.name(b"Subtype") != Some(b"Type3") { + return; + } + if let Some(r) = d.get(b"Resources") { + self.visit(r, depth + 1, Self::resources); + } + } +} + +/// The blend modes of ISO 32000-1 11.3.5 (Tables 136 and 137), and `Compatible` (Table 58). +const BLEND_MODES: &[&[u8]] = &[ + b"Normal", + b"Compatible", + b"Multiply", + b"Screen", + b"Overlay", + b"Darken", + b"Lighten", + b"ColorDodge", + b"ColorBurn", + b"HardLight", + b"SoftLight", + b"Difference", + b"Exclusion", + b"Hue", + b"Saturation", + b"Color", + b"Luminosity", +]; + +/// Add to `found` the names in a `/BM` value (a name, or an array of them) that are not blend +/// modes ISO 32000-1 defines, up to [`MAX_BLEND_NAMES`] of them. +fn unknown_blend_modes(doc: &Document, bm: &Object, found: &mut Painting) { + let one = |found: &mut Painting, n: &[u8]| { + if !BLEND_MODES.contains(&n) { + found.add_blend_mode(&String::from_utf8_lossy(n)); + } + }; + match bm { + Object::Name(n) => one(found, n), + Object::Array(a) => { + for m in a { + if found.blend_modes_full() { + return; + } + if let Some(n) = held(doc, m).as_name() { + one(found, n); + } + } + } + _ => {} + } +} + +/// Whether a `/BM` value blends: a name other than `Normal` or `Compatible`, or an array whose +/// first name is not one of those. +fn blends(doc: &Document, b: &Object) -> bool { + let normal = |n: &[u8]| n == b"Normal" || n == b"Compatible"; + match b { + Object::Name(n) => !normal(n), + Object::Array(a) => a.iter().find_map(|m| doc.resolve(m).as_name().map(|n| !normal(n))).unwrap_or(false), + _ => false, + } +} diff --git a/crates/preflight/src/xmp_extension.rs b/crates/preflight/src/xmp_extension.rs new file mode 100644 index 0000000000..92b3afa38e --- /dev/null +++ b/crates/preflight/src/xmp_extension.rs @@ -0,0 +1,251 @@ +//! PDF/A-2 6.6.2.3.3, the extension schema container schema: a packet describes each schema it +//! extends XMP with in `pdfaExtension:schemas`, a bag of schema descriptions, and each description +//! is complete. +//! +//! The schemas, their entries and their prefixes are those of PDF Association TechNote 0009, "XMP +//! Extension Schemas in PDF/A-1" (2008), section 4, for PDF/A-2 6.6.2.3.3: +//! - a schema (`pdfaSchema`, 4.3) has `schema`, `namespaceURI` and `prefix`; its `property` and +//! `valueType` lists are optional. TechNote 0009, written for PDF/A-1, calls `schema` an +//! optional description; PDF/A-2 requires it, as its conformance tests show; +//! - a property (`pdfaProperty`, 4.4) has `name`, `valueType`, `category` and `description`; +//! - a value type (`pdfaType`, 4.5) has `type`, `namespaceURI`, `prefix`, `description` and +//! `field`. TechNote 0009 calls `field` optional; PDF/A-2 requires it, as its conformance tests +//! show; +//! - a field (`pdfaField`, 4.6) has `name`, `valueType` and `description`; +//! - each of the five namespaces is written with its own prefix (`pdfaExtension`, `pdfaSchema`, +//! `pdfaProperty`, `pdfaType`, `pdfaField`): "Unlike predefined XMP schemas, the namespace +//! prefixes are not only preferred, but required" (4.1). +//! +//! An entry counts as present written as an element or as an attribute of the description. An +//! entry that names something is empty when it holds no value: a namespace URI or prefix, a +//! property, value type or field name, a value type reference, a property category. TechNote 0009 +//! asks for names that are valid XML names (4.4, 4.6) and a category that is internal or external +//! (4.4), and an empty prefix or namespace binds nothing. The human-readable descriptions +//! (`pdfaSchema:schema` and the `description` entries) are Text and may be empty. + +use pdfcraft_cos::ObjRef; +use roxmltree::{Document, Node}; + +const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const EXTENSION: &str = "http://www.aiim.org/pdfa/ns/extension/"; +const SCHEMA: &str = "http://www.aiim.org/pdfa/ns/schema#"; +const PROPERTY: &str = "http://www.aiim.org/pdfa/ns/property#"; +const TYPE: &str = "http://www.aiim.org/pdfa/ns/type#"; +const FIELD: &str = "http://www.aiim.org/pdfa/ns/field#"; + +/// Each container namespace with its required prefix (TechNote 0009, 4.1). +const PREFIXES: [(&str, &str); 5] = + [(EXTENSION, "pdfaExtension"), (SCHEMA, "pdfaSchema"), (PROPERTY, "pdfaProperty"), (TYPE, "pdfaType"), (FIELD, "pdfaField")]; + +/// How many findings a document's packets are read for. A document past this many has plainly +/// failed the clause, and listing every one of a hostile packet's descriptions helps nobody. +const MAX_FINDINGS: usize = 1000; +/// How much of a name the packet gives (a namespace URI, a property) a finding repeats. +const MAX_SHOWN_CHARS: usize = 200; + +/// What the extension schema descriptions of a document break of 6.6.2.3.3, in the order the +/// metadata streams and their descriptions come. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Findings { + /// Entries descriptions lack. + pub(crate) missing: Vec, + /// Description namespaces written with another prefix, each once per stream and prefix. + pub(crate) prefixes: Vec, + /// `pdfaExtension:schemas` containers that aren't one `rdf:Bag` (TechNote 0009, 4.2: "bag + /// Schema"). + pub(crate) not_bag: Vec, +} + +/// A description without an entry it needs, or with the entry empty. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Missing { + /// The metadata stream. + pub(crate) object: ObjRef, + /// The schema the description is in: its `pdfaSchema:namespaceURI`, or `#n` for the n-th + /// schema of its container when it has none. + pub(crate) schema: String, + /// The property, value type or field description, when it is one of those and not the schema + /// description itself: its `pdfaProperty:name` or `pdfaType:type`, a field as `type/name`, or + /// `#n` for the n-th of its list when it has no name. + pub(crate) item: Option, + /// The entry it lacks or holds empty, as `prefix:name`. + pub(crate) entry: String, + /// Whether the entry is there but empty, rather than absent. + pub(crate) empty: bool, +} + +/// A description namespace written with another prefix than its own. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Prefix { + pub(crate) object: ObjRef, + pub(crate) namespace: &'static str, + /// The prefix it needs. + pub(crate) own: &'static str, + /// The prefix it is written with, empty when it is written as the default namespace. + pub(crate) written: String, +} + +/// A `pdfaExtension:schemas` container that isn't one `rdf:Bag`. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct NotBag { + pub(crate) object: ObjRef, + /// The elements it holds instead, by their names as the packet writes them, comma-separated; + /// empty when it holds none. + pub(crate) found: String, +} + +/// Where a description is: its stream, its schema and, below the schema, its own name. +struct Place { + object: ObjRef, + schema: String, + item: Option, +} + +impl Findings { + /// Adds what the packet `doc`, from the metadata stream `object`, breaks. + pub(crate) fn add(&mut self, object: ObjRef, doc: &Document<'_>) { + let containers = doc.descendants().filter(|n| is(n, EXTENSION, "schemas")); + for container in containers { + self.prefixes_in(object, doc, &container); + let children: Vec> = container.children().filter(Node::is_element).collect(); + if !matches!(children.as_slice(), [bag] if is(bag, RDF, "Bag")) && self.room() { + let found = children.iter().take(MAX_SHOWN_CHARS).map(|c| written_name(doc, c)).collect::>().join(", "); + self.not_bag.push(NotBag { object, found: shown(&found) }); + } + for (n, schema) in items(&container).iter().enumerate() { + let id = value(schema, SCHEMA, "namespaceURI").unwrap_or_else(|| position(n)); + let at = |item: Option| Place { object, schema: id.clone(), item }; + self.need(&at(None), schema, SCHEMA, "pdfaSchema", &["schema", "namespaceURI", "prefix"]); + let properties = entries(schema, SCHEMA, "property").flat_map(|e| items(&e)); + for (n, property) in properties.enumerate() { + let name = value(&property, PROPERTY, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(name)), &property, PROPERTY, "pdfaProperty", &["name", "valueType", "category", "description"]); + } + let value_types = entries(schema, SCHEMA, "valueType").flat_map(|e| items(&e)); + for (n, value_type) in value_types.enumerate() { + let name = value(&value_type, TYPE, "type").unwrap_or_else(|| position(n)); + self.need(&at(Some(name.clone())), &value_type, TYPE, "pdfaType", &["type", "namespaceURI", "prefix", "description", "field"]); + for (n, field) in entries(&value_type, TYPE, "field").flat_map(|e| items(&e)).enumerate() { + let field_name = value(&field, FIELD, "name").unwrap_or_else(|| position(n)); + self.need(&at(Some(format!("{name}/{field_name}"))), &field, FIELD, "pdfaField", &["name", "valueType", "description"]); + } + } + } + } + } + + /// Whether another finding may be recorded (see [`MAX_FINDINGS`]). + fn room(&self) -> bool { + self.missing.len().saturating_add(self.prefixes.len()).saturating_add(self.not_bag.len()) < MAX_FINDINGS + } + + /// Notes the entries of `ns` in `names` that the description `d`, at `at`, lacks, or holds + /// empty when they are entries that name something (see [`NAMING`]). + fn need(&mut self, at: &Place, d: &Node<'_, '_>, ns: &str, prefix: &str, names: &[&str]) { + for name in names { + let element = entry(d, ns, name); + let attribute = d.attribute((ns, *name)); + let empty = match (element, attribute) { + (None, None) => false, + (Some(e), _) => NAMING.contains(name) && is_empty(&e), + (None, Some(a)) => NAMING.contains(name) && a.trim().is_empty(), + }; + if (empty || (element.is_none() && attribute.is_none())) && self.room() { + let Place { object, schema, item } = at; + let entry = format!("{prefix}:{name}"); + self.missing.push(Missing { object: *object, schema: schema.clone(), item: item.clone(), entry, empty }); + } + } + } + + /// Notes container namespaces written in `container` (itself included) with another prefix + /// than their own. + fn prefixes_in(&mut self, object: ObjRef, doc: &Document<'_>, container: &Node<'_, '_>) { + let text = doc.input_text(); + for n in container.descendants().filter(Node::is_element) { + let element = (n.tag_name().namespace(), text.get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()); + let attributes = n.attributes().map(|a| (a.namespace(), text.get(a.range_qname()).unwrap_or_default())); + for (ns, qname) in std::iter::once(element).chain(attributes) { + let Some(&(namespace, own)) = PREFIXES.iter().find(|(uri, _)| Some(*uri) == ns) else { continue }; + let written = shown(written_prefix(qname)); + if written != own + && self.room() + && !self.prefixes.iter().any(|p| p.object == object && p.namespace == namespace && p.written == written) + { + self.prefixes.push(Prefix { object, namespace, own, written }); + } + } + } + } +} + +/// The entries that name something, which an empty value leaves without a name: namespace URIs +/// and prefixes, property, value type and field names, value type references and the property +/// category. +const NAMING: [&str; 6] = ["namespaceURI", "prefix", "name", "type", "valueType", "category"]; + +/// Whether the element entry `e` holds no value: no element, no text but white space, and no +/// `rdf:resource` or `rdf:value` attribute with a value. +fn is_empty(e: &Node<'_, '_>) -> bool { + let blank = |v: Option<&str>| v.is_none_or(|v| v.trim().is_empty()); + !e.children().any(|c| c.is_element() || (c.is_text() && c.text().is_some_and(|t| !t.trim().is_empty()))) + && blank(e.attribute((RDF, "resource"))) + && blank(e.attribute((RDF, "value"))) +} + +/// `#n` for the 0-based position `n`, counted from 1. +fn position(n: usize) -> String { + format!("#{}", n.saturating_add(1)) +} + +/// At most [`MAX_SHOWN_CHARS`] of `s`, cut at a char boundary, with `…` when cut. +fn shown(s: &str) -> String { + let mut out: String = s.chars().take(MAX_SHOWN_CHARS).collect(); + if out.len() < s.len() { + out.push('…'); + } + out +} + +/// The text of the entry `name` of `ns` in the description `d`, written as an element or an +/// attribute, trimmed and [`shown`]; `None` when it is absent or blank. +fn value(d: &Node<'_, '_>, ns: &str, name: &str) -> Option { + let text = entry(d, ns, name).and_then(|e| e.text()).or_else(|| d.attribute((ns, name)))?.trim(); + (!text.is_empty()).then(|| shown(text)) +} + +/// The name of the element `n` as the packet writes it, prefix included. +fn written_name<'i>(doc: &Document<'i>, n: &Node<'_, '_>) -> &'i str { + qualified_name(doc.input_text().get(n.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default()) +} + +/// The qualified name the text of a start tag (after its `<`) or of an attribute begins with. +fn qualified_name(text: &str) -> &str { + text.split(|c: char| c.is_whitespace() || c == '>' || c == '/' || c == '=').next().unwrap_or_default() +} + +/// The prefix of the qualified name `text` begins with, empty when it has none. +fn written_prefix(text: &str) -> &str { + qualified_name(text).split_once(':').map_or("", |(p, _)| p) +} + +fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name +} + +/// Every element entry `name` of `ns` in the description `d`: a list written twice is read twice. +fn entries<'a, 'i>(d: &Node<'a, 'i>, ns: &'a str, name: &'a str) -> impl Iterator> { + d.children().filter(move |c| is(c, ns, name)) +} + +/// The element entry `name` of `ns` in the description `d`. +fn entry<'a, 'i>(d: &Node<'a, 'i>, ns: &str, name: &str) -> Option> { + d.children().find(|c| is(c, ns, name)) +} + +/// The descriptions in the array `e` holds: each item's `rdf:parseType="Resource"` node or +/// `rdf:Description`. +fn items<'a, 'i>(e: &Node<'a, 'i>) -> Vec> { + let Some(array) = e.children().find(|c| is(c, RDF, "Bag") || is(c, RDF, "Seq") || is(c, RDF, "Alt")) else { return Vec::new() }; + array.children().filter(|i| is(i, RDF, "li")).map(|li| li.children().find(|c| is(c, RDF, "Description")).unwrap_or(li)).collect() +} diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs index 3563a671db..cd24e59a13 100644 --- a/crates/preflight/src/xmp_types.rs +++ b/crates/preflight/src/xmp_types.rs @@ -4,7 +4,10 @@ //! The types come from ISO 16684-1:2011 (XMP Part 1) clause 8 for the Dublin Core, XMP Basic and //! XMP Rights Management namespaces, and from XMP Specification Part 2 (Additional Properties) for //! the Media Management, Basic Job Ticket, Paged-Text, Dynamic Media, Adobe PDF, Photoshop and -//! Camera Raw namespaces, with the structures they use. A value is read as RDF/XML serializes it +//! Camera Raw namespaces, with the structures they use. The TIFF, EXIF and Additional EXIF (`aux`) +//! namespaces, which the current Part 2 leaves to CIPA DC-010, come from the September 2005 XMP +//! Specification that PDF/A-2 cites: "EXIF Schemas" (pages 58–70) and "EXIF Schema Value Types" +//! (pages 83–87). A value is read as RDF/XML serializes it //! (ISO 16684-1 7.9): a simple value as text or an attribute, an array as `rdf:Seq`, `rdf:Bag` or //! `rdf:Alt`, a structure as `rdf:parseType="Resource"`, an `rdf:Description` or attributes, and a //! qualified value through its `rdf:value` (element or attribute). Every metadata stream of the @@ -12,7 +15,8 @@ //! //! The simple types are checked by their lexical form (ISO 16684-1 8.2.1): Boolean is `True` or //! `False`; Integer an optionally signed run of digits; Real a decimal; Date one of the six forms -//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it. +//! of 8.2.1.2; Rational two integers around a `/`, as Part 2's `xmpDM:scale` example writes it and +//! the 2005 edition defines it (page 84); GPSCoordinate `DDD,MM,SSk` or `DDD,MM.mmk` (page 85). //! The other simple types are text. A field a structure doesn't define, and a property a //! namespace doesn't define, are left to the clauses about them. @@ -43,6 +47,8 @@ enum Ty { Boolean, Date, Rational, + /// `DDD,MM,SSk` or `DDD,MM.mmk`, `k` one of N, S, E, W (2005 edition, page 85). + GpsCoordinate, /// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4). LangAlt, Seq(&'static Ty), @@ -203,6 +209,19 @@ const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; /// Part 2 Table 32. const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)]; +const EXIF: &str = "http://ns.adobe.com/exif/1.0/"; + +/// Flash (2005 edition, page 85). +const FLASH: &[Field] = + &[(EXIF, "Fired", Boolean), (EXIF, "Return", Integer), (EXIF, "Mode", Integer), (EXIF, "Function", Boolean), (EXIF, "RedEyeMode", Boolean)]; +/// OECF/SFR (2005 edition, page 86). +const OECF_SFR: &[Field] = + &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Names", Ty::Seq(&Text)), (EXIF, "Values", Ty::Seq(&Rational))]; +/// CFAPattern (2005 edition, page 87). +const CFA_PATTERN: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Values", Ty::Seq(&Integer))]; +/// DeviceSettings (2005 edition, page 87). +const DEVICE_SETTINGS: &[Field] = &[(EXIF, "Columns", Integer), (EXIF, "Rows", Integer), (EXIF, "Settings", Ty::Seq(&Text))]; + const SCHEMAS: &[Schema] = &[ // ISO 16684-1 Table 4. Schema { @@ -452,6 +471,126 @@ const SCHEMAS: &[Schema] = &[ ("WhiteBalance", Text), ], }, + // 2005 edition, "EXIF Schema for TIFF Properties", pages 58–60. A Closed Choice of Integer is an + // Integer. + Schema { + uri: "http://ns.adobe.com/tiff/1.0/", + prefix: "tiff", + properties: &[ + ("ImageWidth", Integer), + ("ImageLength", Integer), + ("BitsPerSample", Ty::Seq(&Integer)), + ("Compression", Integer), + ("PhotometricInterpretation", Integer), + ("Orientation", Integer), + ("SamplesPerPixel", Integer), + ("PlanarConfiguration", Integer), + ("YCbCrSubSampling", Ty::Seq(&Integer)), + ("YCbCrPositioning", Integer), + ("XResolution", Rational), + ("YResolution", Rational), + ("ResolutionUnit", Integer), + ("TransferFunction", Ty::Seq(&Integer)), + ("WhitePoint", Ty::Seq(&Rational)), + ("PrimaryChromaticities", Ty::Seq(&Rational)), + ("YCbCrCoefficients", Ty::Seq(&Rational)), + ("ReferenceBlackWhite", Ty::Seq(&Rational)), + ("DateTime", Date), + ("ImageDescription", LangAlt), + ("Make", Text), + ("Model", Text), + ("Software", Text), + ("Artist", Text), + ("Copyright", LangAlt), + ], + }, + // 2005 edition, "EXIF Schema for EXIF-specific Properties", pages 60–69. + Schema { + uri: EXIF, + prefix: "exif", + properties: &[ + ("ExifVersion", Text), + ("FlashpixVersion", Text), + ("ColorSpace", Integer), + ("ComponentsConfiguration", Ty::Seq(&Integer)), + ("CompressedBitsPerPixel", Rational), + ("PixelXDimension", Integer), + ("PixelYDimension", Integer), + ("UserComment", LangAlt), + ("RelatedSoundFile", Text), + ("DateTimeOriginal", Date), + ("DateTimeDigitized", Date), + ("ExposureTime", Rational), + ("FNumber", Rational), + ("ExposureProgram", Integer), + ("SpectralSensitivity", Text), + ("ISOSpeedRatings", Ty::Seq(&Integer)), + ("OECF", Ty::Struct("OECF/SFR", OECF_SFR)), + ("ShutterSpeedValue", Rational), + ("ApertureValue", Rational), + ("BrightnessValue", Rational), + ("ExposureBiasValue", Rational), + ("MaxApertureValue", Rational), + ("SubjectDistance", Rational), + ("MeteringMode", Integer), + ("LightSource", Integer), + ("Flash", Ty::Struct("Flash", FLASH)), + ("FocalLength", Rational), + ("SubjectArea", Ty::Seq(&Integer)), + ("FlashEnergy", Rational), + ("SpatialFrequencyResponse", Ty::Struct("OECF/SFR", OECF_SFR)), + ("FocalPlaneXResolution", Rational), + ("FocalPlaneYResolution", Rational), + ("FocalPlaneResolutionUnit", Integer), + ("SubjectLocation", Ty::Seq(&Integer)), + ("ExposureIndex", Rational), + ("SensingMethod", Integer), + ("FileSource", Integer), + ("SceneType", Integer), + ("CFAPattern", Ty::Struct("CFAPattern", CFA_PATTERN)), + ("CustomRendered", Integer), + ("ExposureMode", Integer), + ("WhiteBalance", Integer), + ("DigitalZoomRatio", Rational), + ("FocalLengthIn35mmFilm", Integer), + ("SceneCaptureType", Integer), + ("GainControl", Integer), + ("Contrast", Integer), + ("Saturation", Integer), + ("Sharpness", Integer), + ("DeviceSettingDescription", Ty::Struct("DeviceSettings", DEVICE_SETTINGS)), + ("SubjectDistanceRange", Integer), + ("ImageUniqueID", Text), + ("GPSVersionID", Text), + ("GPSLatitude", Ty::GpsCoordinate), + ("GPSLongitude", Ty::GpsCoordinate), + ("GPSAltitudeRef", Integer), + ("GPSAltitude", Rational), + ("GPSTimeStamp", Date), + ("GPSSatellites", Text), + ("GPSStatus", Text), + ("GPSMeasureMode", Text), + ("GPSDOP", Rational), + ("GPSSpeedRef", Text), + ("GPSSpeed", Rational), + ("GPSTrackRef", Text), + ("GPSTrack", Rational), + ("GPSImgDirectionRef", Text), + ("GPSImgDirection", Rational), + ("GPSMapDatum", Text), + ("GPSDestLatitude", Ty::GpsCoordinate), + ("GPSDestLongitude", Ty::GpsCoordinate), + ("GPSDestBearingRef", Text), + ("GPSDestBearing", Rational), + ("GPSDestDistanceRef", Text), + ("GPSDestDistance", Rational), + ("GPSProcessingMethod", Text), + ("GPSAreaInformation", Text), + ("GPSDifferential", Integer), + ], + }, + // 2005 edition, "EXIF Schema for Additional EXIF Properties", page 69. + Schema { uri: "http://ns.adobe.com/exif/1.0/aux/", prefix: "aux", properties: &[("Lens", Text), ("SerialNumber", Text)] }, ]; /// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that @@ -535,8 +674,8 @@ fn written(qname: &str, local: &str) -> String { format!("{out}:{local}") } -/// `ty` as the XMP Specification writes value types: `Integer`, `Lang Alt`, `seq Rational`, a -/// structure by its type's name, such as `Dimensions`. +/// `ty` as the XMP Specification writes value types: `Integer`, `GPSCoordinate`, `Lang Alt`, +/// `seq Rational`, a structure by its type's name, such as `Flash`. fn named(ty: Ty) -> String { let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item)); match ty { @@ -547,6 +686,7 @@ fn named(ty: Ty) -> String { Boolean => "Boolean".into(), Date => "Date".into(), Rational => "Rational".into(), + Ty::GpsCoordinate => "GPSCoordinate".into(), LangAlt => "Lang Alt".into(), Ty::Seq(item) => array("seq", item), Ty::Bag(item) => array("bag", item), @@ -608,7 +748,7 @@ fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { return blank(e) && blank(&n) && simple(ty, v) && predefined_ok(&n, deeper); } match ty { - Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational => { + Ty::Text | Ty::Guid | Ty::Integer | Ty::Real | Ty::Boolean | Ty::Date | Ty::Rational | Ty::GpsCoordinate => { // `rdf:resource` makes the value a URI, which only a text type takes. // The element is then empty. if e.attribute((RDF, "resource")).is_some() { @@ -716,12 +856,25 @@ fn simple(ty: Ty, v: &str) -> bool { Ty::Boolean => matches!(v, "True" | "False"), Ty::Date => date(v), Ty::Rational => v.split_once('/').is_some_and(|(n, d)| integer(n) && integer(d)), + Ty::GpsCoordinate => gps_coordinate(v), Ty::Text | Ty::Guid => true, Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), _ => false, } } +/// `DDD,MM,SSk` or `DDD,MM.mmk`: degrees, minutes and seconds or minutes with any number of +/// fractional digits, then N, S, E or W (2005 edition, page 85). +fn gps_coordinate(v: &str) -> bool { + let Some(v) = v.strip_suffix(['N', 'S', 'E', 'W']) else { return false }; + let Some((degrees, rest)) = v.split_once(',') else { return false }; + let minutes_ok = match rest.split_once(',') { + Some((minutes, seconds)) => digits(minutes) && digits(seconds), + None => rest.split_once('.').is_some_and(|(m, f)| digits(m) && digits(f)), + }; + digits(degrees) && minutes_ok +} + fn digits(s: &str) -> bool { !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) } diff --git a/crates/preflight/tests/generation_repairs.rs b/crates/preflight/tests/generation_repairs.rs index c57ef141d2..68c2f2b663 100644 --- a/crates/preflight/tests/generation_repairs.rs +++ b/crates/preflight/tests/generation_repairs.rs @@ -4,7 +4,9 @@ use pdfcraft_preflight::{Level, convert}; use std::sync::Arc; fn image_file(damaged: bool) -> Arc> { - let mut bytes = b"%PDF-1.7\n".to_vec(); + // A PDF/A-2 header (6.1.2), so that Save as PDF/A has no header to rewrite and only the + // repaired references decide whether the next save is full. + let mut bytes = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); let bodies = [ "<< /Type /Catalog /Pages 2 0 R /HeaderImage 5 1 R /RowImage 5 0 R /StaleImage 5 2 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", diff --git a/crates/preflight/tests/known_answers.rs b/crates/preflight/tests/known_answers.rs index 23d503dc62..bb92e3dd83 100644 --- a/crates/preflight/tests/known_answers.rs +++ b/crates/preflight/tests/known_answers.rs @@ -22,7 +22,8 @@ use pdfcraft_preflight::{Level, convert, declared, icc, verify}; /// A document from hand-written objects and extra trailer entries (copied from the crate's unit /// tests). fn build(objs: &[&str], trailer_extra: &str) -> Document { - let mut out = b"%PDF-1.7\n".to_vec(); + // ISO 19005-2 6.1.2: the header line is followed by a comment of four bytes above 127. + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); @@ -33,7 +34,10 @@ fn build(objs: &[&str], trailer_extra: &str) -> Document { for o in offsets { out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + // ISO 19005-2 6.1.3: the trailer names a file identifier; a test about another clause gets one + // unless it gives its own. + let id = if trailer_extra.contains("/ID") { "" } else { "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]" }; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} {trailer_extra} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); Document::open(std::sync::Arc::new(out)).unwrap() } diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index fffab083af..027158df01 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -51,6 +51,10 @@ fn page_of(tok: &str, count: usize, labels: &Labels) -> Result() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), Ok(n) => Err(PrintError::OutOfRange(n, count)), @@ -176,6 +180,13 @@ mod tests { assert!(select_pages(0, Some("-"), &[], Subset::All, false).is_err()); } + #[test] + fn plus_sign_is_not_a_page() { + for r in ["+3", "+2-4", "2-+4"] { + assert!(matches!(select_pages(5, Some(r), &[], Subset::All, false), Err(PrintError::NotAPage(_))), "accepted {r}"); + } + } + #[test] fn open_ended_range_covers_all_pages() { assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]); diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 0ff960466e..1ac7cb6714 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -31,7 +31,7 @@ mod prune; use layers::strip_hidden_layers; use prune::{clip_out, extgstate_font, prune_form_resources}; -pub(crate) use prune::{prune, prune_patterns, prune_properties, resource_ops, unlink_doomed, unlink_pruned_patterns}; +pub(crate) use prune::{prune, prune_patterns, prune_properties, resource_ops, unlink_doomed, unlink_pruned_patterns, unlink_undrawn}; /// How deep this pass follows form XObjects. A form past it is removed rather than walked, which /// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would @@ -1097,7 +1097,9 @@ fn xobject( out.error = Some(error); return None; } - if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + // No page: annotation appearances draw through a page's resources, never through a + // form's. The form's own soft masks are still counted. + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content, None) { out.error = Some(error); return None; } diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 188af03abe..2bfae6391f 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,6 +1,6 @@ use super::{ - CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, - overlaps, page_streams, res_dict, + CosError, DecodeBudget, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, + is_vertical, num, overlaps, page_streams, res_dict, }; /// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, @@ -20,33 +20,231 @@ pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { } } -/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or -/// through a form that has no /Resources of its own (such a form draws through its caller's). -/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). -fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { - let joined = Pieces::join(content); - let ops = joined.parse().ops; - for op in ops.iter().filter(|op| op.is("Do")) { - let Some(n) = op.last_name() else { continue }; - if n == name { - return Some(true); - } - let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { - continue; - } - if depth >= MAX_DEPTH { - return None; - } - let Ok(data) = s.decoded() else { return None }; - match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { - Some(false) => {} - other => return other, +/// The XObject names of the resource dictionary `xo` that `content` draws: directly, or through +/// a form that has no /Resources of its own (such a form draws through its caller's). Each such +/// form is read once, however many names are asked about. The flag says whether some of what +/// `content` draws could not be read (a form that will not decode, or nesting past the limit), so +/// a name missing from the set may still be drawn. +fn names_content_draws(doc: &Document, content: &[Vec], xo: &Dict) -> (HashSet>, bool) { + let mut drawn = HashSet::new(); + let mut seen = HashSet::new(); + let mut pending = vec![(Pieces::join(content).parse().ops, 0)]; + // A form that will not decode makes the answer uncertain, but it does not stop the forms + // already queued from being read: a name drawn only inside one of those need not be unknown. + let mut unknown = false; + while let Some((ops, depth)) = pending.pop() { + for n in ops.iter().filter(|op| op.is("Do")).filter_map(Op::last_name) { + if !drawn.insert(n.to_vec()) { + continue; + } + let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + continue; + } + match s.decoded() { + Ok(data) if depth < MAX_DEPTH => pending.push((pdfcraft_content::parse(&data).ops, depth + 1)), + _ => unknown = true, + } } } - Some(false) + (drawn, unknown) +} + +/// Whether any of `drawn` (from [`names_content_draws`]) draws `name`: `None` when none is known to and +/// one could not be read in full. +fn any_draws(drawn: &[(HashSet>, bool)], name: &[u8]) -> Option { + if drawn.iter().any(|(names, _)| names.contains(name)) { + Some(true) + } else if drawn.iter().any(|(_, unknown)| *unknown) { + None + } else { + Some(false) + } +} + +/// Every page-tree node with a /Resources of its own, and the pages that draw through it (the +/// nearest such node above a page, or the page itself). Cycles are cut as in +/// `pdfcraft_model::pages`. The flag is false when a node was reached twice (listed under two +/// parents, or in a cycle) or the walk stopped early: a page may then draw through a holder it +/// isn't listed under. +fn resource_holders(doc: &Document) -> (Vec<(ObjRef, Vec)>, bool) { + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut whole = true; + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return (holders, whole) }; + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > 1_000_000 { + whole = false; + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + (holders, whole) +} + +/// Unlink the image and form XObjects nothing draws from the page-tree resource dictionaries +/// that still name them: Delete Image and Replace Image take an image off the page but leave it +/// listed there, and so in a full-rewrite save. A name stays when anything that draws through the +/// dictionary uses it ([`names_drawn`]), and a dictionary whose use can't be told is left as it +/// is, as is every one when a page is listed twice in the page tree. A dictionary that loses an +/// entry is replaced by a private copy on its node, as in [`unlink_doomed`]. Returns how many +/// names went. +pub(crate) fn unlink_undrawn(doc: &mut Document) -> Result { + let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let (holders, whole) = resource_holders(doc); + if !whole { + return Ok(0); + } + let mut n = 0; + for (node, users) in holders { + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; + let mut xo = res_dict(doc, &res, b"XObject"); + let listed: Vec> = xo + .iter() + .filter(|(_, v)| { + v.as_ref().is_some_and(|r| matches!(&*doc.get(r), Object::Stream(s) if matches!(s.dict.name(b"Subtype"), Some(b"Image" | b"Form")))) + }) + .map(|(name, _)| name.to_vec()) + .collect(); + if listed.is_empty() { + continue; + } + let mut pruned = false; + // A form that goes may have been all that drew another name: look again while names go. + for _ in 0..MAX_DEPTH { + let Some(drawn) = names_drawn(doc, &res, &users, &index) else { break }; + let mut gone = 0; + for name in listed.iter().filter(|name| !drawn.contains(*name)) { + gone += usize::from(xo.remove(name).is_some()); + } + if gone == 0 { + break; + } + n += gone; + pruned = true; + res.set(b"XObject".to_vec(), Object::Dict(xo.clone())); + } + if pruned { + doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + Ok(n) +} + +/// Every name used with `Do` by what draws through `res`, the resource dictionary of `pages`: +/// their content, their annotations' appearances, and every form, tiling pattern, Type 3 glyph +/// and soft mask those can reach, with /Resources of their own or not. A name such a stream +/// doesn't define is looked up in the resources of whatever draws it, up to the page's, so a `Do` +/// anywhere in there may draw from `res`. Going by name keeps more than it must, never less. +/// Each stream is read once, against one budget. `None` when that can't be told: a page or +/// stream that won't decode, or the budget spent. +fn names_drawn(doc: &Document, res: &Dict, pages: &[ObjRef], index: &HashMap) -> Option>> { + let mut drawn = HashSet::new(); + let mut note = |data: &[u8]| { + for op in pdfcraft_content::parse(data).ops.iter().filter(|op| op.is("Do")) { + if let Some(name) = op.last_name() { + drawn.insert(name.to_vec()); + } + } + }; + // Streams still to read, and resource dictionaries still to look through. + let mut streams: Vec = Vec::new(); + let mut dicts: Vec = vec![res.clone()]; + for page in pages { + let dict = doc.get(*page).as_dict().cloned().unwrap_or_default(); + let (_, content) = page_streams(doc, &dict, index.get(page).copied().unwrap_or(0)).ok()?; + // One stream may end with a name its `Do` begins the next with: read them as one. + note(Pieces::join(&content).data()); + let annots = dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for annot in annots { + let ap = doc.resolve(&annot).as_dict().and_then(|a| a.get(b"AP")).map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().cloned()); + // Each of /N, /R and /D is an appearance, or a dictionary of them by state. + for (_, look) in ap.unwrap_or_default().iter() { + match look.as_ref() { + Some(r) if matches!(&*doc.resolve(look), Object::Stream(_)) => streams.push(r), + _ => streams.extend(doc.resolve(look).as_dict().into_iter().flat_map(|states| states.iter().filter_map(|(_, v)| v.as_ref()))), + } + } + } + } + let mut budget = DecodeBudget::page(); + let mut read = HashSet::new(); + let mut looked = 0; + loop { + while let Some(dict) = dicts.get(looked) { + let (found, fonts) = drawn_through(doc, dict); + looked += 1; + streams.extend(found); + // Type 3 fonts' own resources, each look charged like a form so fonts naming each + // other can't go round for ever. + for font_res in fonts { + if !budget.enter_form() { + return None; + } + dicts.push(font_res); + } + } + let Some(r) = streams.pop() else { break }; + if !read.insert(r) { + continue; + } + let obj = doc.resolve(&Object::Ref(r)); + let Object::Stream(s) = &*obj else { continue }; + if !budget.enter_form() { + return None; + } + note(&budget.decode_strict(doc, s).ok()?); + if let Some(own) = s.dict.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) { + dicts.push(own); + } + } + Some(drawn) +} + +/// The content streams the resource dictionary `res` lists: its form XObjects, its tiling +/// patterns, its Type 3 fonts' glyph procedures and its soft masks' groups. Then the /Resources +/// of those Type 3 fonts, which their glyphs look names up in first. An object that holds only a +/// reference stands for what it names, as the renderer reads it. +fn drawn_through(doc: &Document, res: &Dict) -> (Vec, Vec) { + let is_stream = |r: &ObjRef| matches!(&*doc.resolve(&Object::Ref(*r)), Object::Stream(_)); + let mut found: Vec = Vec::new(); + let mut fonts: Vec = Vec::new(); + for (_, x) in res_dict(doc, res, b"XObject").iter() { + found.extend(x.as_ref().filter(|_| matches!(&*doc.resolve(x), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")))); + } + for (_, p) in res_dict(doc, res, b"Pattern").iter() { + found.extend(p.as_ref().filter(|_| matches!(&*doc.resolve(p), Object::Stream(s) if s.dict.int(b"PatternType") == Some(1)))); + } + for (_, f) in res_dict(doc, res, b"Font").iter() { + let font = doc.resolve(f); + let Some(font) = font.as_dict().filter(|f| f.name(b"Subtype") == Some(b"Type3")) else { continue }; + let procs = font.get(b"CharProcs").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()).unwrap_or_default(); + found.extend(procs.iter().filter_map(|(_, v)| v.as_ref()).filter(is_stream)); + fonts.extend(font.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned())); + } + for (_, g) in res_dict(doc, res, b"ExtGState").iter() { + let gs = doc.resolve(g); + let mask = gs.as_dict().and_then(|g| g.get(b"SMask")).map(|m| doc.resolve(m)); + found.extend(mask.as_ref().and_then(|m| m.as_dict()).and_then(|m| m.reference(b"G")).filter(is_stream)); + } + (found, fonts) } /// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource @@ -67,31 +265,7 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope return Ok(()); } let doomed: HashSet = doomed.iter().copied().collect(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; - // Every page-tree node with a /Resources of its own, and the pages that draw through it - // (the nearest such node above a page, or the page itself). Cycles are cut as in - // `pdfcraft_model::pages`. - let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let mut seen = HashSet::new(); - let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; - while let Some((node, mut holder)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - if d.contains(b"Resources") { - holders.push((node, Vec::new())); - holder = Some(holders.len() - 1); - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); - } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { - h.1.push(node); - } - } + let (holders, _) = resource_holders(doc); let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); for (node, users) in holders { @@ -112,15 +286,11 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_prope contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); } let mut pruned = false; + // What each page draws through this dictionary, worked out once for all the names. + let drawn: Vec<_> = + if names.is_empty() { Vec::new() } else { contents.iter().map(|content| names_content_draws(doc, content, &xo)).collect() }; for n in names { - let mut drawn = Some(false); - for content in &contents { - drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); - if drawn != Some(false) { - break; - } - } - match drawn { + match any_draws(&drawn, &n) { Some(true) => {} Some(false) => { xo.remove(&n); @@ -230,8 +400,12 @@ fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet], xo: &mut Dict, doomed_names: &[Vec]) -> Result { let mut pruned = false; + if doomed_names.is_empty() { + return Ok(false); + } + let drawn = [names_content_draws(doc, content, xo)]; for name in doomed_names { - match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { + match any_draws(&drawn, name) { Some(true) => {} Some(false) => pruned |= xo.remove(name).is_some(), None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index ce5ce01d9e..12cd3f6232 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -855,7 +855,7 @@ fn redact_page_content( let painted = if out.keeps_all_patterns { &out.patterns_seen } else { &out.patterns_painted }; report.patterns_removed += interp::prune_patterns(doc, &mut res, &out.patterns_seen, painted, pruned_patterns); retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 7661b83ddb..1ef9a1f6de 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -410,22 +410,153 @@ fn is_comment(subtype: &[u8]) -> bool { !matches!(subtype, b"FileAttachment" | b"Redact") && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) } -/// The layers that are off in the default configuration. +/// The layers that are off in the default configuration: listed off (or not listed on, under +/// `/BaseState /OFF`), or turned off on screen by one of its usage rules (`/AS`, ISO 32000-2, +/// 8.11.4.4): a rule for the View event in the View category hides a layer whose +/// `/Usage /View /ViewState` is OFF. Rules that turn a layer on, or apply only when printing or +/// exporting, leave it as the lists say. fn hidden_layers(doc: &Document) -> Vec { let Some((_, cat)) = catalog(doc) else { return Vec::new() }; let Some(oc) = sub(doc, &cat, b"OCProperties") else { return Vec::new() }; let Some(d) = sub(doc, &oc, b"D") else { return Vec::new() }; - let all: Vec = - oc.get(b"OCGs").and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default(); - let list = |k: &[u8]| -> Vec { - d.get(k).and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default() + let refs = |o: Option<&Object>| -> Vec { + o.and_then(|g| doc.resolve(g).as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default() }; - let (on, off) = (list(b"ON"), list(b"OFF")); - if d.name(b"BaseState") == Some(b"OFF") { - all.into_iter().filter(|g| !on.contains(g)).collect() + let all = refs(oc.get(b"OCGs")); + let (on, off) = (refs(d.get(b"ON")), refs(d.get(b"OFF"))); + let mut hidden: Vec = if d.name(b"BaseState") == Some(b"OFF") { + all.iter().copied().filter(|g| !on.contains(g)).collect() } else { off.into_iter().filter(|g| all.contains(g)).collect() + }; + // Each rule, layer list and layer is read once, however often the rules name them: many rules + // can share one long list. + let known: std::collections::HashSet = all.iter().copied().collect(); + let mut looked: std::collections::HashSet = hidden.iter().copied().collect(); + let mut read = std::collections::HashSet::new(); + let rules = d.get(b"AS").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + for rule in &rules { + if let Some(r) = rule.as_ref() + && !read.insert(r) + { + continue; + } + let Some(rule) = doc.resolve(rule).as_dict().cloned() else { continue }; + let categories = rule.get(b"Category").and_then(|c| doc.resolve(c).as_array().cloned()).unwrap_or_default(); + if rule.name(b"Event") != Some(b"View") || !categories.iter().any(|c| c.as_name() == Some(b"View")) { + continue; + } + if let Some(r) = rule.get(b"OCGs").and_then(Object::as_ref) + && !read.insert(r) + { + continue; + } + for g in refs(rule.get(b"OCGs")) { + if !known.contains(&g) || !looked.insert(g) { + continue; + } + let usage = doc.get(g).as_dict().and_then(|layer| sub(doc, layer, b"Usage")); + if usage.and_then(|u| sub(doc, &u, b"View")).is_some_and(|v| v.name(b"ViewState") == Some(b"OFF")) { + hidden.push(g); + } + } } + hidden +} + +/// The objects a layer cleanup has reached by reference, and whether the lists naming each keep it. +type Cleaned = std::collections::HashMap; + +/// `o` cleaned by `clean`: in its list when written there, and when written by reference, once and +/// where it is, however many lists name it (they keep the reference, or drop it when `clean` gives +/// `None`). A list that names one object many times costs one step per object, not one per path, +/// and a cycle stops where it began. +fn once( + doc: &mut Document, + o: &Object, + cleaned: &mut Cleaned, + clean: impl FnOnce(&mut Document, &Object, &mut Cleaned) -> Result, RedactError>, +) -> Result, RedactError> { + let Object::Ref(r) = o else { return clean(doc, o, cleaned) }; + if let Some(&kept) = cleaned.get(r) { + return Ok(kept.then(|| o.clone())); + } + cleaned.insert(*r, true); + let held: &Object = &doc.resolve(o); + let Some(new) = clean(doc, held, cleaned)? else { + cleaned.insert(*r, false); + return Ok(None); + }; + if new != *held { + doc.set_existing(*r, new)?; + } + Ok(Some(o.clone())) +} + +/// `o` without the entries `keep` refuses, through nested arrays (an `/Order` holds sub-lists, and +/// `/RBGroups` holds groups), each array written by reference cleaned [`once`]. `None` when `keep` +/// refuses `o` itself, or when `o` is a sub-list (`depth` above 0) that named a layer and names +/// none now: it goes, with the heading that labelled it. +fn filtered( + doc: &mut Document, + o: &Object, + keep: &impl Fn(&Object) -> bool, + depth: usize, + cleaned: &mut Cleaned, +) -> Result, RedactError> { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + if !keep(o) { + return Ok(None); + } + once(doc, o, cleaned, |doc, o, cleaned| { + let Object::Array(a) = o else { return Ok(Some(o.clone())) }; + let mut out = Vec::new(); + for x in a { + out.extend(filtered(doc, x, keep, depth + 1, cleaned)?); + } + // Anything but a text string (a heading) is a layer or a sub-list. + let names = |l: &[Object]| l.iter().any(|x| !matches!(x, Object::String(_))); + let emptied = depth > 0 && names(a.as_slice()) && !names(out.as_slice()); + Ok((!emptied).then_some(Object::Array(out))) + }) +} + +/// An optional-content configuration without the layers `keep` refuses, in every list that names +/// them: on, off, order, locked, radio-button groups and usage rules (a rule, group or sub-list left +/// with no layer goes). A removed layer still named in any of them would stay in the file, its name +/// included. +fn config_without(doc: &mut Document, mut d: Dict, keep: &impl Fn(&Object) -> bool, cleaned: &mut Cleaned) -> Result { + for k in [&b"ON"[..], b"OFF", b"Order", b"Locked", b"RBGroups"] { + if let Some(v) = d.get(k).cloned() { + let v = filtered(doc, &v, keep, 0, cleaned)?.unwrap_or_else(|| Object::Array(Vec::new())); + d.set(k.to_vec(), v); + } + } + let Some(rules) = d.get(b"AS").cloned() else { return Ok(d) }; + let rules = once(doc, &rules, cleaned, |doc, rules, cleaned| { + let Object::Array(rules) = rules else { return Ok(Some(rules.clone())) }; + let mut kept = Vec::new(); + for rule in rules { + kept.extend(once(doc, rule, cleaned, |doc, rule, cleaned| { + let Some(mut rule) = rule.as_dict().cloned() else { return Ok(None) }; + if let Some(layers) = rule.get(b"OCGs").cloned() { + let Some(layers) = filtered(doc, &layers, keep, 0, cleaned)? else { return Ok(None) }; + if doc.resolve(&layers).as_array().is_some_and(Vec::is_empty) { + return Ok(None); + } + rule.set(b"OCGs".to_vec(), layers); + } + Ok(Some(Object::Dict(rule))) + })?); + } + Ok(Some(Object::Array(kept))) + })?; + if let Some(rules) = rules { + d.set(b"AS".to_vec(), rules); + } + Ok(d) } /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). @@ -511,7 +642,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); - crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; + crate::type3::prune_type3_from_resources(doc, &mut res, &page_content, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -755,13 +886,19 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result Ok(Some(Object::Dict(config_without(doc, c.clone(), &keep, cleaned)?))), + None => Ok(None), + })?); } - oc.set(b"D".to_vec(), Object::Dict(d)); + oc.set(b"Configs".to_vec(), Object::Array(kept)); } cat.set(b"OCProperties".to_vec(), Object::Dict(oc)); } @@ -790,7 +927,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result