diff --git a/Cargo.lock b/Cargo.lock index ea3a743bf8..fd3935a659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4592,6 +4592,7 @@ dependencies = [ "pdfcraft-render", "proptest", "thiserror 2.0.21", + "thread-priority", "zune-jpeg", ] @@ -4698,6 +4699,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-geom", + "pdfcraft-testkit", "serde", "skrifa 0.44.0", "thiserror 2.0.21", @@ -6955,6 +6957,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread-priority" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "libc", + "log", + "rustversion", + "windows", +] + [[package]] name = "tiff" version = "0.11.3" 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 834e61cfd7..a15fb5d69f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -198,27 +198,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian -Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md). +Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from +`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md). | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | -| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | -| Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | -| French | `fr` | 99.3% | partial | -| Portuguese (Brazil) | `pt-br` | 100% of current union | partial | -| Indonesian | `id` | 0% | none | -| Japanese | `ja` | 99.1% | partial | -| German | `de` | 99.3% | partial | -| Korean | `ko` | 0% | none | -| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | +| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | +| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) | +| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) | +| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | +| French | `fr` | 98.4% | partial | +| Portuguese (Brazil) | `pt-br` | 98.4% | partial | +| Indonesian | `id` | 97.5% | partial | +| Japanese | `ja` | 89.6% | partial | +| German | `de` | 89.7% | partial | +| Korean | `ko` | 97.5% | partial (no Hangul UI face) | +| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) | Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian, -Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs** -(24 interface languages including English). +Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian, +Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs** +(35 interface languages including English). ## Upcoming @@ -239,6 +240,10 @@ 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-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. @@ -252,10 +257,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged. - **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. -- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged. +- **2026-11-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored. - **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 (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. @@ -443,6 +449,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 3118c901b4..86b31bafd3 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -8,6 +8,9 @@ pub(crate) fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) } +/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the +/// standards it declares, and the notes about damage repaired while opening it), with none of +/// its scripts run. pub(crate) fn info(args: &[String]) -> Result<(), CliError> { validate_options("info", args, INFO_OPTIONS)?; let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; @@ -18,24 +21,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> { let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; let pdf_pages = info.pages.len(); let acroform_fields = info.fields.len(); + // The document as the app opens it: the notes about damage repaired while opening, which the + // app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields. + // Inspection runs none of the document's scripts: an XFA form's initialize and calculate + // scripts, which the app runs on opening, are left alone, as with JavaScript turned off. + let mut session = pdfcraft_engine::Session::new(); + session.set_javascript(false); + let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id)); // Dynamic XFA forms: say what laying the template out gives (what the app shows). - let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) - .then(|| { - let mut s = pdfcraft_engine::Session::new(); - let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; - let d = s.get(id)?; - d.xfa.as_ref().map(|x| { - info = d.info.clone(); - // The in-memory layout is an appended revision, not the file being inspected. - info.file_size = bytes.len(); - serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) - }) + let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| { + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) }) - .flatten(); - // What the document declares as a standard, read the way the Standards panel reads it, so - // `info` and the app agree. A file that declares nothing leaves these null and empty. - let declared = - pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); + }); + // What the document declares as a standard, as the Standards panel reads it, and the notes + // Document Properties shows, which include what the app repaired after reading the objects (a + // page tree rebuilt from the pages found by scanning), both from the session's own reading. A + // file the app cannot open is read once more for what the object reader declares and repaired. + // A file that declares nothing leaves the standards null and empty. + let (declared, repairs) = match opened { + Some(d) => (d.standards(), d.repair_log()), + None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec())) + .unwrap_or_default(), + }; let json = serde_json::json!({ "file": path, "pdf_version": info.pdf_version, @@ -56,6 +68,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> { "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, + "repairs": repairs, }); stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; Ok(()) @@ -408,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-cli/tests/it/info_repairs.rs b/apps/pdfcraft-cli/tests/it/info_repairs.rs new file mode 100644 index 0000000000..72e8bed1a3 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/info_repairs.rs @@ -0,0 +1,75 @@ +//! `info` lists the notes about damage repaired while opening, as the app shows them in Document +//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a +//! page tree rebuilt from the pages found by scanning (a note the app adds after reading). + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and +/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`. +fn fixture(catalog: &str, pages: usize, bad_startxref: Option) -> Vec { + let content = "0 0 0 rg 10 10 40 20 re f"; + let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect(); + let mut objs = vec![ + catalog.to_string(), + format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string())); + 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{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for off in &offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + let start = bad_startxref.unwrap_or(xref); + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>"; + +fn info_of(bytes: &[u8], name: &str) -> serde_json::Value { + let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("f.pdf"); + std::fs::write(&path, bytes).unwrap(); + let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let json = serde_json::from_slice(&out.stdout).expect("info prints JSON"); + let _ = std::fs::remove_dir_all(&dir); + json +} + +fn repairs(json: &serde_json::Value) -> Vec { + json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect() +} + +#[test] +fn info_lists_a_rebuilt_cross_reference_index() { + // `startxref` points into the middle of an object, so the index is rebuilt from the headers. + let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref"); + assert_eq!(json["pages"], serde_json::json!(1), "{json}"); + let lines = repairs(&json); + assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}"); +} + +#[test] +fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() { + // The catalog names a page tree object the file does not have (#1656). + let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree"); + let lines = repairs(&json); + assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}"); +} + +#[test] +fn info_lists_no_repairs_for_a_sound_file() { + let json = info_of(&fixture(CATALOG, 1, None), "sound"); + assert_eq!(json["repairs"], serde_json::json!([]), "{json}"); +} diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs index 30a6e25882..7b7292fe45 100644 --- a/apps/pdfcraft-cli/tests/it/main.rs +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -8,6 +8,7 @@ mod check; mod edit; mod extract; mod help; +mod info_repairs; mod info_standards; mod mcp_compact; mod options; diff --git a/apps/pdfcraft-cli/tests/it/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs index 1b5ff7bc9d..4d5df67535 100644 --- a/apps/pdfcraft-cli/tests/it/xfa.rs +++ b/apps/pdfcraft-cli/tests/it/xfa.rs @@ -7,6 +7,11 @@ use std::sync::Arc; const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); fn fixture(test: &str) -> PathBuf { + fixture_with(test, "") +} + +/// The two-page form, with `event` inside its `name` field. +fn fixture_with(test: &str, event: &str) -> PathBuf { let xdp = r#" "#; +"# + .replace("{event}", event); let objects = [ "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), @@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() { assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); } +/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening +/// and which here would change the field's value, does not run. +#[test] +fn info_runs_none_of_the_forms_scripts() { + let script = r#""#; + let path = fixture_with("info-script", script); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}"); + let warnings = info["warnings"].as_array().unwrap(); + assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}"); + assert_eq!(info["repairs"], serde_json::json!([]), "{info}"); +} + #[test] fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { let path = fixture("text"); @@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() { assert_eq!(info["file_size"], original.len()); assert_eq!(std::fs::read(path).unwrap(), original); } + +/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it, +/// as Document Properties does after the form is laid out as one more revision. +#[test] +fn info_lists_the_repairs_of_a_laid_out_form() { + let path = fixture("info-damaged"); + let bytes = std::fs::read(&path).unwrap(); + let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let mut damaged = bytes[..at].to_vec(); + damaged.extend_from_slice(b"20\n%%EOF\n"); + std::fs::write(&path, &damaged).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}"); + let repairs = info["repairs"].as_array().unwrap(); + assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}"); +} diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index bef27de169..4c0f39e85c 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -102,25 +102,12 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). -/// -/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run -/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years -/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the -/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and -/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 -/// is `num_days_from_ce` 719_163. +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_engine::civil_from_days`, +/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second +/// count over 86_400, at most about 2.1e14, so it always fits an `i64`. fn civil_from_days(days: u64) -> (i64, u32, u32) { - use chrono::Datelike; - let (cycles, rest) = (days / 146_097, days % 146_097); - // `rest + 719_163` is 719_163..865_259, always a valid CE day. - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) - } - None => (1970, 1, 1), // unreachable - } + // The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`. + pdfcraft_engine::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX)) } fn numbered(dir: &Path, n: usize) -> PathBuf { 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/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 0fa31ccbb1..235ba4b452 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -37,9 +37,16 @@ struct Walker<'a> { visits: usize, /// Per page: the text each MCID shows (read once per page). texts: HashMap>, + /// What is left of [`MAX_TITLE_SOURCE_BYTES`]. + title_left: usize, out: Vec, } +/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all. +/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and +/// each would otherwise be decoded in full first. +const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20; + /// The headings, in document order. Headings without text or a page are left out. pub fn headings(doc: &Document) -> Vec { let Some(root) = tree_root(doc) else { return Vec::new() }; @@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec { seen: HashSet::new(), visits: 0, texts: HashMap::new(), + title_left: MAX_TITLE_SOURCE_BYTES, out: Vec::new(), }; if let Some(k) = root.get(b"K") { @@ -131,17 +139,34 @@ impl Walker<'_> { self.marks(kids, pg, depth + 1, &mut marks); } let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return }; - let doc = self.doc; - let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty()); - let title = match attr(b"ActualText") { + let title = match self.attr(d, b"ActualText") { Some(t) => Some(t), - None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")), + None => match self.marked_text(&marks, page) { + Some(t) => Some(t), + None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")), + }, }; let Some(title) = title else { return }; let title: String = title.split_whitespace().collect::>().join(" ").chars().take(MAX_TITLE).collect(); self.out.push(Heading { obj, level, title, page }); } + /// The non-blank text of the string `key` holds, charged against the title allowance by the + /// longer of its raw and decoded size (a string that does not fit is not decoded). + fn attr(&mut self, d: &Dict, key: &[u8]) -> Option { + let o = self.doc.resolve(d.get(key)?); + let s = o.as_string()?; + // A text string decodes to at most three bytes per raw byte, so one whose worst case does + // not fit the remaining allowance is never decoded: the raw check alone let a string at + // the raw limit decode to three times it before the allowance was reduced. + if s.bytes.len().saturating_mul(3) > self.title_left { + return None; + } + let text = s.to_text(); + self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len())); + Some(text).filter(|t| !t.trim().is_empty()) + } + /// The text the marked content shows (`page`: where marks without a page of their own are). fn marked_text(&mut self, marks: &[(Option, i64)], page: usize) -> Option { let mut parts = Vec::new(); diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index d8566eddf1..66ae06fce4 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1559,6 +1559,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() { assert_eq!(titles, ["Chapter One", "Chapter Two"]); } +#[test] +fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() { + let count = 100; + let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::>().join(" "); + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 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] /Contents 4 0 R >>".to_string(), + stream("BT ET"), + "<< /Type /StructTreeRoot /K 6 0 R >>".to_string(), + format!("<< /S /Document /P 5 0 R /K [{kids}] >>"), + format!("({})", "a".repeat(512 << 10)), + ]; + objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string())); + let found = headings(&pdf(&objects, "")); + assert!(!found.is_empty() && found.len() < count, "{}", found.len()); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); diff --git a/crates/annot/README.md b/crates/annot/README.md index 5c62eece5a..6ab3736ed2 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -6,7 +6,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, - **Builders** for the comment types Acrobat's commenting tools create: sticky note (`Text` + `Popup`), highlight, underline, strikethrough, squiggly, rectangle (`Square`), oval (`Circle`), - line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`). + line and arrow (`Line` with `/LE`), freehand drawing (`Ink`), text box and callout + (`FreeText`), polygon (including cloudy borders), polyline, caret, rubber and custom stamp, + and file attachment. - **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`). A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as @@ -47,8 +49,8 @@ deterministic; the engine supplies them. - Unknown keys are kept. Inline annotation dictionaries are promoted to indirect objects only when an edit needs a reference to them. -- `set_style` refuses (and changes nothing) when it cannot redraw the comment, e.g. stamps, - cloudy borders or callouts, instead of leaving a stale appearance. +- `set_style` refuses when it cannot redraw the comment, e.g. a stamp + whose appearance cannot be regenerated, instead of leaving a stale appearance. - Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but never paint a second icon. - Resizing a stamp changes its `/Rect` and preserves its original appearance and resources; @@ -63,7 +65,9 @@ deterministic; the engine supplies them. (vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is drawn. -## Not yet +## Rich text and remaining limits -Callouts, polygons and polylines, clouds, carets (insert/replace text), stamps, file -attachments, rich text (`/RC`, `/DS`), and the shared named-appearance cache Acrobat writes. +Text-box text and colour edits regenerate `/RC` (XHTML) and `/DS` from the plain +`/Contents`, `/DA` and `/Q`, so rich-text editors show the same text and style. These edits +replace imported rich-text formatting. The shared named-appearance cache Acrobat writes +is not implemented. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index dafc4b01b1..7996567f04 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -205,11 +205,24 @@ fn quads(context: &AppearanceContext<'_>) -> Option> { (!q.is_empty()).then_some(q) } -/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK. -fn color(d: &Dict, key: &[u8]) -> Option> { +/// `/C`-style colour arrays (ISO 32000-2 Table 166): none (transparent), gray, RGB or CMYK. +/// The entries should all be numbers; a four-entry array with a non-number in it, such as +/// `[1 0 0 null]`, is still read as CMYK with that entry 0, as PDFium, MuPDF and Poppler read it. +/// A reference is not such an entry: it stands for a value this builder can't see (§7.3.10), so +/// the colour is unreadable, as the other colour and number readers here treat one. +pub(crate) fn color(d: &Dict, key: &[u8]) -> Option> { let c = match d.get(key) { None => return Some(None), - Some(o) => o.as_array()?.iter().map(|o| o.as_f64()).collect::>>()?, + Some(o) => match o.as_array()? { + a if a.len() == 4 => a + .iter() + .map(|o| match o { + Object::Ref(_) => None, + o => Some(o.as_f64().unwrap_or(0.0)), + }) + .collect::>>()?, + a => a.iter().map(|o| o.as_f64()).collect::>>()?, + }, }; Some(match c.as_slice() { [] => None, diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 8af76cb2ed..c4913e7ab8 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -105,7 +105,10 @@ pub fn summaries(doc: &Document) -> Vec { let r = nums(b"Rect"); let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; let c = nums(b"C"); - let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + // Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry + // read as 0), like `props`; three numbers, some given by reference, are still read. + let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) }; if subtype == "FreeText" && let Some(da) = text_value(doc, d, b"DA") { @@ -244,8 +247,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let obj = doc.resolve(&entry); let d = obj.as_dict()?; let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); - let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + 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(|| { if subtype == "FreeText" { if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } @@ -275,16 +277,32 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { }) } -/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. +/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. Grey and CMYK fills are converted. fn fill_of(d: &Dict) -> Option { let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; - let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - let rgb = match c.as_slice() { - [g] => [*g; 3], - [r, g, b] => [*r, *g, *b], - [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], - _ => return None, - }; + drawn_color(d, key) +} + +/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference. +/// Only an array of at most four entries is copied out of the reference. +fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> { + let rgb = match d.get(b"C") { + Some(o @ Object::Ref(_)) => { + let held = doc.resolve(o); + let entries = held.as_array().filter(|a| a.len() <= 4)?; + let mut direct = Dict::new(); + direct.set(b"C".to_vec(), Object::Array(entries.clone())); + drawn_color(&direct, b"C") + } + _ => drawn_color(d, b"C"), + }?; + Some(rgb.map(|x| x as f32)) +} + +/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are +/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0. +fn drawn_color(d: &Dict, key: &[u8]) -> Option { + let rgb = appearance::color(d, key).flatten()?; finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) } 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 60e97f6a72..6fbb0f35c3 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -94,7 +94,7 @@ pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotE if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - let doomed = with_replies_and_popups(doc, &list, seed(target)); + let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it // answers either. Pop-ups follow their comment. for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { @@ -115,15 +115,28 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result< let p = page_ref(doc, page)?; let list = annots(doc, p); let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let doomed = with_replies_and_popups(doc, &list, seed(target)); + let doomed = with_replies_and_popups(doc, &list, seed(doc, target)); let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); Ok(set_annots(doc, p, kept)?) } /// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline -/// comment (which has none to collect) the pop-up it names, which still goes. -fn seed(target: &Object) -> Option { - target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))) +/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a +/// Replace Text proposal also takes the caret it is grouped under, so the two go together +/// whichever is deleted. +fn seed(doc: &Document, target: &Object) -> Vec { + let own = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); + own.into_iter().chain(replaced_by(doc, target)).collect() +} + +/// The caret a Replace Text strikeout is grouped under (`/IT /StrikeOutTextEdit`, `/RT /Group`, +/// `/IRT` to a caret): the proposal's primary, which holds the replacement. +fn replaced_by(doc: &Document, strike: &Object) -> Option { + let obj = doc.resolve(strike); + let d = obj.as_dict()?; + let edit = d.name(b"Subtype") == Some(b"StrikeOut") && d.name(b"IT") == Some(b"StrikeOutTextEdit") && d.name(b"RT") == Some(b"Group"); + let caret = d.reference(b"IRT").filter(|_| edit)?; + (annot_dict(doc, caret).name(b"Subtype") == Some(b"Caret")).then_some(caret) } /// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is @@ -713,9 +726,61 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { /// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and /// redraw it. +/// +/// A Replace Text proposal is one look: colour and opacity are the group's (`/C` is read from +/// the primary, ISO 32000-2 §12.5.6.2), so restyling either the caret or its strikeout restyles +/// and redraws the other too, in the same edit. The strikeout takes no line width (a text markup +/// annotation has no border) and neither part has line endings. All or nothing: if one member +/// can't be restyled, none is. pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { - let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; + let others = replace_text_partners(doc, page, r)?; + // Check every partner before changing anything. + for &o in &others { + unlocked(doc, o)?; + } + let before: Vec<(ObjRef, Dict)> = others.iter().map(|&o| (o, annot_dict(doc, o))).collect(); + let mut done: Vec<(ObjRef, Dict)> = vec![(r, annot_dict(doc, r))]; + style_one(doc, r, change.clone(), meta)?; + for (o, d) in before { + let caret = d.name(b"Subtype") == Some(b"Caret"); + let part = StyleChange { color: change.color, opacity: change.opacity, width: change.width.filter(|_| caret), endings: None }; + let unchanged = part.color.is_none() && part.opacity.is_none() && part.width.is_none(); + if unchanged { + continue; + } + if let Err(e) = style_one(doc, o, part, meta) { + // Put the members already restyled back, the target included. + for (o, d) in done.into_iter().rev() { + doc.set(o, Object::Dict(d)); + } + return Err(e); + } + done.push((o, d)); + } + Ok(()) +} + +/// The other members of the Replace Text proposal `r` belongs to, on `page`: for its caret, the +/// strikeouts grouped under it; for a strikeout, its caret and any other strikeout under it. +/// Empty for anything else. +fn replace_text_partners(doc: &Document, page: usize, r: ObjRef) -> Result, AnnotError> { + let own = Object::Ref(r); + let caret = match replaced_by(doc, &own) { + Some(c) => c, + None if annot_dict(doc, r).name(b"Subtype") == Some(b"Caret") => r, + None => return Ok(Vec::new()), + }; + let list = annots(doc, page_ref(doc, page)?); + let strikes = list.iter().filter(|e| replaced_by(doc, e) == Some(caret)).filter_map(Object::as_ref); + // `/Annots` may list one annotation twice: restyle each once. + let mut seen: HashSet = HashSet::from([r]); + Ok(std::iter::once(caret).chain(strikes).filter(|&o| seen.insert(o)).collect()) +} + +/// [`set_style`] on one annotation. +fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; unlocked(doc, r)?; let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); @@ -994,8 +1059,13 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { } /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the -/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they -/// move, list and delete as one. Returns the strikeout's index. +/// struck text holding `replacement`, the two grouped so they move, list and delete as one. +/// Returns the strikeout's index. +/// +/// The caret is the group's primary (`/IT /Replace`) and the strikeout its subordinate (`/IRT` to +/// the caret, `/RT /Group`, `/IT /StrikeOutTextEdit`): a group's text is read from its primary, +/// and the subordinates' `Contents` are ignored (ISO 32000-2 §12.5.6.2), so the replacement has +/// to be on the primary. /// /// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies /// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that @@ -1016,7 +1086,6 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> meta, )?; let (_, sr) = annot_ref(doc, page, s)?; - doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; // The caret sits on the baseline at the end of the last line, its point at the text. let xs = [last[0], last[2], last[4], last[6]]; let ys = [last[1], last[3], last[5], last[7]]; @@ -1031,10 +1100,14 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_> )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { - d.set(b"IRT".to_vec(), Object::Ref(sr)); - d.set(b"RT".to_vec(), Object::name("Group")); + d.set(b"IT".to_vec(), Object::name("Replace")); d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); })?; + doc.update_dict(sr, |d| { + d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")); + d.set(b"IRT".to_vec(), Object::Ref(cr)); + d.set(b"RT".to_vec(), Object::name("Group")); + })?; Ok(s) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 0c9ac7ed5a..bfb780dabb 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] @@ -1264,22 +1458,36 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let strike = &a[i]; assert_eq!((strike.name(b"Subtype"), strike.name(b"IT")), (Some(&b"StrikeOut"[..]), Some(&b"StrikeOutTextEdit"[..]))); let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); - assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); - assert!(caret.reference(b"IRT").is_some()); + // The caret is the group's primary: a group's text is read from its primary and the + // subordinates' Contents are ignored (ISO 32000-2 §12.5.6.2), so the replacement is on it. + assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"IT")), ("new words", Some(&b"Replace"[..]))); + assert!(caret.get(b"IRT").is_none() && caret.get(b"RT").is_none(), "a primary has no /IRT: {caret:?}"); + assert_eq!(strike.name(b"RT"), Some(&b"Group"[..])); + assert!(text(strike, b"Contents").is_empty()); // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the // pair does not survive an export and import. assert_eq!(text(caret, b"NM").as_str(), "r-caret"); assert_eq!(text(strike, b"NM").as_str(), "r"); assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); - let irt = caret.reference(b"IRT").unwrap(); - assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); + let irt = strike.reference(b"IRT").unwrap(); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r-caret", "the strikeout is grouped under the caret"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); - // Deleting the strikeout takes the caret with it (the group). + // Deleting either one takes the other with it (the proposal is one comment). + let marks = |doc: &Document| list(doc, 0).iter().filter(|d| matches!(d.name(b"Subtype"), Some(b"Caret" | b"StrikeOut"))).count(); + let mut by_strike = doc.clone(); + delete_annotation(&mut by_strike, 0, i).unwrap(); + assert_eq!(marks(&by_strike), 0, "deleting the strikeout deletes the caret"); let mut doc = doc; - delete_annotation(&mut doc, 0, i).unwrap(); - assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); + let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); + // A locked caret keeps the strikeout too: the pair can't be broken by deleting the other. + let mut locked = doc.clone(); + set_locked(&mut locked, 0, c, true).unwrap(); + assert!(deletable(&locked, 0, i).is_err() && delete_annotation(&mut locked, 0, i).is_err(), "a locked caret refuses its strikeout's deletion"); + assert_eq!(marks(&locked), 2); + delete_annotation(&mut doc, 0, c).unwrap(); + assert_eq!(marks(&doc), 0, "deleting the caret deletes the strikeout"); assert!( add_text_replacement( &mut doc, @@ -1298,6 +1506,55 @@ fn replace_text_groups_a_strikeout_and_a_caret() { ); } +/// Review on #5689 (CodeRabbit): clicking a Replace Text strikeout selects its caret, the +/// group's primary, so a restyle from Comment Properties lands on the caret. Colour and opacity +/// are the proposal's, so the strikeout is restyled and redrawn in the same edit, whichever +/// member is restyled; it takes no border width. A locked member refuses the whole change. +#[test] +fn restyling_a_replace_text_pair_restyles_both_members() { + let mut doc = fixture(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let tr = TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &red, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }; + let s = add_text_replacement(&mut doc, tr).unwrap(); + let c = list(&doc, 0).iter().position(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); + let color = |doc: &Document, i: usize| { + list(doc, 0)[i].get(b"C").and_then(|o| o.as_array().cloned()).unwrap().iter().filter_map(Object::as_f64).collect::>() + }; + let green = [0.0, 0.5, 0.0]; + + // Restyled through the caret (what a click on either part selects). + let mut by_caret = doc.clone(); + set_style(&mut by_caret, 0, c, StyleChange::new(Some(green), Some(0.5), Some(3.0), None), &meta("")).unwrap(); + let by_caret = reopen(&by_caret); + assert_eq!(color(&by_caret, c), green.to_vec()); + assert_eq!(color(&by_caret, s), green.to_vec(), "the strikeout takes the proposal's colour"); + let strike = &list(&by_caret, 0)[s]; + assert_eq!(strike.get(b"CA").and_then(Object::as_f64), Some(0.5), "and its opacity"); + assert!(strike.get(b"BS").is_none(), "a strikeout has no border to widen: {strike:?}"); + assert!(ap_content(&by_caret, strike).contains("0 0.5 0 RG"), "its appearance is redrawn: {}", ap_content(&by_caret, strike)); + + // Restyled through the strikeout: the caret follows. + let mut by_strike = doc.clone(); + set_style(&mut by_strike, 0, s, StyleChange::new(Some(green), None, None, None), &meta("")).unwrap(); + assert_eq!(color(&by_strike, c), green.to_vec(), "the caret takes the proposal's colour"); + + // A locked strikeout refuses the change, and nothing moves. + let mut locked = doc.clone(); + set_locked(&mut locked, 0, s, true).unwrap(); + assert!(set_style(&mut locked, 0, c, StyleChange::new(Some(green), None, None, None), &meta("")).is_err()); + assert_eq!(color(&locked, c), red.color.to_vec(), "the caret keeps its colour"); +} + #[test] fn files_attach_as_comments() { let mut doc = fixture(); @@ -2786,6 +3043,71 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { check(&reopen(&doc)); } +/// PDFBOX-2699: a `/C` written `[1 0 0 null]` (Table 166 asks for numbers) is still a four-entry +/// colour, CMYK with the null as 0 as PDFium, MuPDF and Poppler read it, so a shape without an +/// appearance is drawn instead of dropped. `/IC` is read the same way, and three numbers stay RGB. +/// The properties and the comment list report the colours as drawn. +#[test] +fn a_four_entry_colour_with_a_null_is_cmyk_with_zero() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 null] /IC [0 0 1 null] >>", + b"<< /Type /Annot /Subtype /Square /Rect [55 5 95 35] /C [1 0 0] >>", + ]); + let display = with_missing_appearances(&doc).expect("both squares get a fallback appearance"); + let squares = list(&display, 0); + let cmyk = ap_content(&display, &squares[0]); + assert!(cmyk.contains("0 1 1 RG") && cmyk.contains("1 1 0 rg"), "CMYK (1,0,0,0) stroke and (0,0,1,0) fill: {cmyk}"); + let rgb = ap_content(&display, &squares[1]); + assert!(rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}"); + // The properties read the stroke and the fill as they are drawn. + let p = props(&doc, 0, 0).expect("the square has properties"); + assert_eq!((p.color, p.fill), (Some([0.0, 1.0, 1.0]), Some([1.0, 1.0, 0.0]))); + assert_eq!(props(&doc, 0, 1).and_then(|p| p.color), Some([1.0, 0.0, 0.0])); + // The comment list reports the same colours. + let colors: Vec> = summaries(&doc).iter().map(|s| s.color).collect(); + assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([1.0, 0.0, 0.0])]); +} + +/// A `/C` given by reference is followed for the comment list, as three numbers were, and read +/// like a direct one: `[0 0 1 null]` is CMYK yellow, and a referenced grey level is grey. +#[test] +fn a_referenced_colour_is_listed_like_a_direct_one() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C 6 0 R >>", + b"<< /Type /Annot /Subtype /Circle /Rect [55 5 95 35] /C 7 0 R >>", + b"[0 0 1 null]", + b"[0.5]", + ]); + let colors: Vec> = summaries(&doc).iter().map(|s| s.color).collect(); + assert_eq!(colors, [Some([1.0, 1.0, 0.0]), Some([0.5, 0.5, 0.5])]); +} + +/// 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. +#[test] +fn a_four_entry_colour_with_a_reference_is_not_read_as_zero() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 40] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [5 5 45 35] /C [1 0 0 6 0 R] >>", + 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 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"); +} + /// The `y` of every `1 0 0 1 x y Tm` in `content`: where each piece of text sits. fn baselines(content: &str) -> Vec { content.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect() diff --git a/crates/audit/README.md b/crates/audit/README.md index cfa2979c21..8b487db8d3 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -3,7 +3,7 @@ Layer L4 (may use `edit`): verification tooling, the faux-redaction audit. ```rust -let findings: Vec = audit_redactions(&doc)?; // page, kind, rect, covered_text +let findings: Vec = audit_redactions(&doc)?; // page, kind, rect, covered_text ``` It reports two kinds of finding: diff --git a/crates/automation/README.md b/crates/automation/README.md index 921c0354e3..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. @@ -288,7 +299,14 @@ Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1); `width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye (default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true. -The model is framed to fill the image and shaded by a headlight on white. U3D models are refused +The model is framed to fill the image and shaded by a headlight on white. `orbit`, instead of +`direction`, frames it as a viewer's orbit control would: `{view, up, front, turn, zoom, +zoom_at, pan}`, all optional. `view` is `iso` (default), `front`, `right`, `top` or `back` +about the `up` axis (default `+z`) and the `front` axis (perpendicular to `up`); `turn` is +`[yaw, pitch]` in degrees, pitch down; `zoom` is a positive factor, the result held within 0.05 to 50, at `zoom_at` `[right, up]` +in picture heights from the centre, where the point under it stays still; `pan` `[right, up]` +moves the target, in model radii, so the model moves the other way. Every view frames the model +at the same size, whatever its direction. U3D models are refused with an error, and the annotation's own views, lighting and render modes are not applied yet. ## Viewer navigation history 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 5267716e85..71ae6b3b91 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -8,6 +8,8 @@ use super::json::Args; use super::paths::read_input; use super::{Arc, Automation, Path, PathBuf}; +const MAX_BATES_BYTES: u64 = 512 * 1024 * 1024; + struct FilePlan { input: String, source: PathBuf, @@ -26,19 +28,23 @@ impl Automation { } let start = match args.0.get("start_number") { None | Some(Value::Null) => 1, - Some(value) => value - .as_u64() - .filter(|n| *n >= 1) - .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?, + Some(value) => { + value.as_u64().filter(|n| *n >= 1).ok_or_else(|| ToolError::InvalidArgs("start_number must be an integer of at least 1".into()))? + } }; let digits = args.opt_int("digits")?.unwrap_or(6); let digits = u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?; let prefix = args.opt_str("prefix")?.unwrap_or(""); let suffix = args.opt_str("suffix")?.unwrap_or(""); - // `#` separates the token's fields and `>>` ends it: either would cut the token short. - if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) { - return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into())); + if prefix.chars().count() > 128 + || suffix.chars().count() > 128 + || prefix.contains('#') + || suffix.contains('#') + || prefix.contains(">>") + || suffix.contains(">>") + { + return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 characters and cannot contain # or >>".into())); } let dry_run = args.opt_bool("dry_run")?.unwrap_or(false); @@ -70,16 +76,7 @@ impl Automation { targets.push((output.to_string(), target)); passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned)); } - let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect(); - let mut output_paths = std::collections::HashSet::new(); - for (name, path) in &targets { - if input_paths.contains(path) { - return Err(failed(format!("output {name} names an input file; choose a separate output path"))); - } - if !output_paths.insert(path.clone()) { - return Err(failed(format!("two files use the same output path: {name}"))); - } - } + check_unique_outputs(&targets, dry_run)?; let mut plans = Vec::with_capacity(files.len()); let mut next = start; @@ -88,13 +85,13 @@ impl Automation { let size = std::fs::metadata(input_path).map_err(failed)?.len(); input_bytes = input_bytes .checked_add(size) - .filter(|total| *total <= 512 * 1024 * 1024) + .filter(|total| *total <= MAX_BATES_BYTES) .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; let bytes = read_input(input_path)?; input_bytes = input_bytes .saturating_sub(size) .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) - .filter(|total| *total <= 512 * 1024 * 1024) + .filter(|total| *total <= MAX_BATES_BYTES) .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let password = passwords.get(index).and_then(Option::as_deref); @@ -106,7 +103,8 @@ impl Automation { return Err(failed(format!("{input_name} has no pages to number"))); } let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?; - let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; + let last_offset = count.checked_sub(1).ok_or_else(|| failed(format!("{input_name} has no pages to number")))?; + let last = next.checked_add(last_offset).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; plans.push(FilePlan { input: input_name.clone(), source: input_path.clone(), @@ -116,7 +114,7 @@ impl Automation { first: next, last, }); - if index + 1 < sources.len() { + if index + 1 < files.len() { next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?; } } @@ -163,12 +161,14 @@ impl Automation { let result = applied.and_then(|()| self.session.save_full_bytes(id)); match result { Ok(bytes) => { - let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); - if total > 512 * 1024 * 1024 { - results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); - } else { + let candidate = output_bytes + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .filter(|total| *total <= MAX_BATES_BYTES); + if let Some(total) = candidate { output_bytes = total; staged.push((plan.output.clone(), bytes)); + } else { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); } } Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})), @@ -198,6 +198,123 @@ impl Automation { } } +fn check_unique_outputs(targets: &[(String, PathBuf)], dry_run: bool) -> Result<()> { + if targets.len() < 2 { + return Ok(()); + } + + // Probe exact basenames in a directory under each canonical parent, so the filesystem applies its own name rules. + let mut groups: std::collections::HashMap)> = std::collections::HashMap::new(); + for (name, target) in targets { + let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {name}")))?; + let identity = parent.canonicalize().map_err(failed)?; + let probe_name = output_probe_name(target).map_err(failed)?; + groups.entry(identity.clone()).or_insert_with(|| (identity, Vec::new())).1.push((name.clone(), probe_name)); + } + + if dry_run { + // Dry runs cannot probe the volume: Windows uses a conservative case-insensitive comparison, + // while Unix preserves case, so unusual case-sensitive Windows or case-insensitive Unix volumes can differ. + for (_, (_, names)) in groups { + for (index, (name, candidate)) in names.iter().enumerate() { + if names.iter().take(index).any(|(_, prior)| same_output_name(prior, candidate)) { + return Err(failed(format!("two files use the same output path: {name}"))); + } + } + } + return Ok(()); + } + + for (_, (parent, names)) in groups { + match tempfile::tempdir_in(&parent) { + Ok(probes) => { + for (name, probe_name) in names { + let probe = probes.path().join(probe_name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) { + Ok(file) => drop(file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(failed(format!("two files use the same output path: {name}"))); + } + Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))), + } + } + } + #[cfg(windows)] + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => probe_files_for_resolved_outputs(&parent, names)?, + Err(error) => return Err(failed(error)), + } + } + Ok(()) +} + +#[cfg(windows)] +fn probe_files_for_resolved_outputs(parent: &Path, names: Vec<(String, std::ffi::OsString)>) -> Result<()> { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000; + let mut probes = Vec::with_capacity(names.len()); + for (name, probe_name) in names { + let probe = parent.join(probe_name); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true).share_mode(0).custom_flags(FILE_FLAG_DELETE_ON_CLOSE); + match options.open(&probe) { + Ok(file) => probes.push(file), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(failed(format!("two files use the same output path: {name}"))); + } + Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))), + } + } + drop(probes); + Ok(()) +} + +fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool { + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + fn key(name: &std::ffi::OsStr) -> Vec { + std::char::decode_utf16(name.encode_wide()) + .map(|decoded| match decoded { + Ok(character) => { + let mut uppercase = character.to_uppercase(); + match (uppercase.next(), uppercase.next()) { + (Some(single), None) => single as u32, + _ => character as u32, + } + } + Err(error) => 0x11_0000 + u32::from(error.unpaired_surrogate()), + }) + .collect() + } + key(left) == key(right) + } + #[cfg(not(windows))] + { + left == right + } +} + +fn output_probe_name(path: &Path) -> std::io::Result { + let name = path.file_name().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "output has no file name"))?; + #[cfg(windows)] + { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + let is_verbatim = path.components().any(|component| matches!(component, std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) | std::path::Prefix::VerbatimDisk(_)))); + let mut wide: Vec = name.encode_wide().collect(); + if !is_verbatim { + while wide.last().is_some_and(|unit| *unit == u16::from(b'.') || *unit == u16::from(b' ')) { + wide.pop(); + } + } + Ok(std::ffi::OsString::from_wide(&wide)) + } + #[cfg(not(windows))] + { + Ok(name.to_os_string()) + } +} + /// Publish a PDF atomically while refusing a target created by another process after preflight. pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?; @@ -212,3 +329,104 @@ pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { } }) } + +#[cfg(test)] +mod tests { + use super::check_unique_outputs; + + #[test] + fn dry_run_path_check_creates_no_probe_files() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("same.pdf"); + let targets = vec![("first.pdf".into(), path.clone()), ("second.pdf".into(), path)]; + assert!(check_unique_outputs(&targets, true).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn dry_run_detects_output_parent_aliases_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let alias = dir.path().join("alias"); + std::os::unix::fs::symlink(dir.path(), &alias).unwrap(); + let first = dir.path().join("same.pdf"); + let second = alias.join("same.pdf"); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, true).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + + #[test] + fn long_output_names_fit_in_probe_directory() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join(format!("{}.pdf", "a".repeat(220))); + let second = dir.path().join(format!("{}.pdf", "b".repeat(220))); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, false).is_ok()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn output_probe_paths_preserve_non_utf8_names() { + use std::os::unix::ffi::OsStringExt; + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xff])); + let second = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xfe])); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, false).is_ok()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(windows)] + #[test] + fn windows_probe_names_trim_only_non_verbatim_paths() { + use std::ffi::OsString; + use std::path::Path; + + let normal = Path::new(r"C:\root\report.pdf. "); + assert_eq!(super::output_probe_name(normal).unwrap(), OsString::from("report.pdf")); + + let verbatim = Path::new(r"\\?\C:\root\report.pdf. "); + assert_eq!(super::output_probe_name(verbatim).unwrap(), OsString::from("report.pdf. ")); + } + + #[cfg(windows)] + #[test] + fn dry_run_detects_case_only_output_collisions_without_writing() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("Case.pdf"); + let second = dir.path().join("CASE.pdf"); + let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)]; + assert!(check_unique_outputs(&targets, true).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[cfg(windows)] + #[test] + fn permission_denied_child_directory_uses_delete_on_close_file_probes() { + use std::process::Command; + + let dir = tempfile::tempdir().unwrap(); + let identity = Command::new("whoami.exe").args(["/user", "/fo", "csv", "/nh"]).output().unwrap(); + assert!(identity.status.success()); + let identity = String::from_utf8(identity.stdout).unwrap(); + let sid = identity.split('"').nth(3).unwrap(); + let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap(); + assert!(acl.success()); + + // 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); + std::fs::remove_file(&direct_probe).unwrap(); + + let targets = vec![("first.pdf".into(), dir.path().join("first.pdf")), ("second.pdf".into(), dir.path().join("second.pdf"))]; + assert!(check_unique_outputs(&targets, false).is_ok()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 78f4cefbe0..390e3b4d1a 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -490,8 +490,12 @@ impl Automation { if contents.trim().is_empty() { return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into())); } + // `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the + // caret carries the colour too. Only the colour: opacity and the rest are the + // caret's own. + let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] }); + caret.color = style.color; let strike = style; - let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] }); let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?; out["comment"] = json!({ "page": page + 1, "type": "replace" }); return Ok(out); 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/file_access.rs b/crates/automation/src/file_access.rs index dab2760f5d..dfc33b4e7d 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -5,10 +5,13 @@ //! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path //! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through: //! -//! - The path is made absolute against the working directory, as the file system reads it. Its -//! links are followed: for a file that doesn't exist yet, the links of its nearest existing -//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so -//! is a link that leads nowhere). +//! - The path is made absolute against the working directory, then followed the way the file +//! system reads it, one name at a time from the top, its links followed. A name is looked up +//! only while the folder it is in is the root, inside it, or one of the root's own parent +//! folders: a path that leaves those is refused before anything beyond is touched (looking up +//! `/net/` mounts it), and every such refusal reads the same, so it can't tell what exists +//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken +//! as plain names (`..` below a missing folder is refused, and so is a link that leads nowhere). //! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`) //! than the root's is refused by name, before anything is resolved: resolving it would contact //! the host. @@ -20,7 +23,6 @@ //! folders in between (swapping in a link) can still win that race. The roots keep an agent to the //! files it was given; they don't guard against other programs on the computer. -use std::ffi::OsString; use std::path::{Component, Path, PathBuf}; use std::sync::Arc; @@ -100,11 +102,10 @@ impl AutomationRoots { Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")), }; }; - let absolute = absolute(shown, target)?; - // Another network share or a device is refused by name, as `--root` refuses it: even - // resolving the path would contact the host (#4003). - if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) { - return Ok(()); + match follow(shown, &absolute(shown, target)?, folder) { + Toward::Inside => return Ok(()), + Toward::Refused(why) => return Err(why), + Toward::Outside => {} } let root = root_shown.display(); match access { @@ -141,42 +142,66 @@ fn absolute(shown: &str, path: &Path) -> Result { std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}")) } -/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing -/// folder's links followed and the rest appended. -fn resolve(shown: &str, absolute: &Path) -> Result { - // The names below the nearest existing folder, last first. - let mut missing: Vec = vec![]; - let mut at = absolute; - loop { - match std::fs::canonicalize(at) { - Ok(mut found) => { - found.extend(missing.iter().rev()); - return Ok(found); +/// Where a followed path ends up. +pub(crate) enum Toward { + /// Inside the root, with its links followed (a missing tail taken as names). + Inside, + /// Anywhere else. Said the same way whatever is out there. + Outside, + /// Inside the root, but it can't be followed, and why. + Refused(String), +} + +/// Follow the absolute `path` the way the file system reads it, one name at a time from the top, +/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own +/// parent folders (all with their links followed already, so `..` means what it means to the file +/// system). +pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward { + // Another network share or a device is refused by name, as `--root` refuses it: even looking + // it up would contact the host (#4003). + if foreign_share(path, root) { + return Toward::Outside; + } + let near = |at: &Path| inside(at, root) || inside(root, at); + let mut parts = path.components().peekable(); + let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect(); + let mut at = match std::fs::canonicalize(&top) { + Ok(at) if near(&at) => at, + _ => return Toward::Outside, + }; + while let Some(part) = parts.next() { + let name = match part { + Component::Normal(name) => name, + Component::CurDir => continue, + Component::ParentDir => { + at.pop(); + continue; } - Err(e) => match std::fs::symlink_metadata(at) { - Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")), - // There, yet its real path can't be had (no permission): nothing to compare. - Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")), - }, - } - let parent = at.parent(); - match (at.components().next_back(), parent) { - (Some(Component::Normal(name)), Some(parent)) => { - missing.push(name.to_os_string()); - at = parent; - } - (Some(Component::ParentDir), _) => { - return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}")); - } - // Down to the root (or drive) and it doesn't exist either. - _ => { - let e = std::io::Error::from(std::io::ErrorKind::NotFound); - return Err(format!("automation path rejected: cannot resolve {shown}: {e}")); + Component::Prefix(_) | Component::RootDir => return Toward::Outside, + }; + let next = at.join(name); + match std::fs::canonicalize(&next) { + Ok(found) if near(&found) => at = found, + Ok(_) => return Toward::Outside, + // Not even there yet: only inside the root does it matter why. + Err(_) if !inside(&at, root) => return Toward::Outside, + Err(e) => { + return match std::fs::symlink_metadata(&next) { + Ok(m) if m.file_type().is_symlink() => Toward::Refused(format!("automation path rejected: a link that leads nowhere: {shown}")), + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) { + return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}")); + } + Toward::Inside + } + Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")), + }; } } } + if inside(&at, root) { Toward::Inside } else { Toward::Outside } } /// Is `path` (resolved) `root` or inside it, component by component (without regard to case on @@ -306,6 +331,62 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// Outside the root, the refusal comes before anything out there is looked up, so it can't + /// tell whether a folder out there exists. + #[test] + fn paths_out_of_the_root_are_refused_alike_without_looking() { + let (base, roots) = folders("follow"); + let sep = std::path::MAIN_SEPARATOR; + let root = s(base.join("in")); + let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, ""); + // Through a folder outside that exists, and through one that doesn't. + let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf"); + let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf"); + assert_eq!(refusal(&there), refusal(&missing)); + assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing)); + // Out through a missing folder and back in. (Windows folds `..` by name when it makes a path + // absolute, as it does when it opens one: that path is `in\a.pdf` there.) + #[cfg(not(windows))] + assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root")); + // A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it. + #[cfg(windows)] + { + let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}"); + assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf"))); + assert!( + refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"), + "{}", + refusal(&verbatim(r"..\nowhere\x.pdf")) + ); + } + let _ = std::fs::remove_dir_all(base); + } + + /// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on + /// macOS, where the temporary folder is reached through a link). + #[test] + fn a_root_may_be_named_as_given_or_as_resolved() { + let (base, roots) = folders("given"); + let resolved = std::fs::canonicalize(base.join("in")).unwrap(); + assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + /// A link beside the root, in one of its parent folders, is followed: to the root it leads in, + /// anywhere else it leads out. + #[cfg(unix)] + #[test] + fn links_beside_the_root_are_followed() { + let (base, roots) = folders("beside"); + std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap(); + assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(())); + assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + #[test] fn relative_paths_resolve_against_the_working_directory() { let (base, roots) = folders("relative"); @@ -393,7 +474,8 @@ mod tests { fn tools_do_not_touch_the_file_system_without_resolving_the_path() { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every - // discovered file again before `process` reads it or `publish` writes it. + // discovered file again before `process` reads it or `publish` writes it. `doc_bates_multi` + // resolves its paths before checking output collisions and using the probe helpers. const RESOLVED_BY_CALLER: &[&str] = &[ "write_atomic", "write_atomic_with", @@ -405,6 +487,8 @@ mod tests { "linked", "walk", "publish", + "check_unique_outputs", + "probe_files_for_resolved_outputs", ]; let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut files = vec![]; 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 6132ac7416..594a129d8b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -48,7 +48,6 @@ use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; -use file_access::foreign_share; use text_cache::TextCache; mod assemble; @@ -71,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/model_render.rs b/crates/automation/src/model_render.rs index dbfabc6708..b69c5a6371 100644 --- a/crates/automation/src/model_render.rs +++ b/crates/automation/src/model_render.rs @@ -2,7 +2,9 @@ use pdfcraft_engine::model3d::Model3dKind; use pdfcraft_prc::PrcFile; -use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render}; +use pdfcraft_prc_render::nav::{Axis, NamedView, Orbit, Upright, reach}; +use pdfcraft_prc_render::{Bounds, Camera, RenderOptions, WorldMesh, assemble, render}; +use serde_json::Value; use super::Automation; use super::dispatch::Content; @@ -22,7 +24,6 @@ impl Automation { let page = self.page(a)?; let width = side(a, "width")?; let height = side(a, "height")?; - let direction = direction(a)?; let perspective = a.opt_bool("perspective")?.unwrap_or(true); let doc = self.doc(a)?; let found = doc.model_3d_annotations(page).map_err(failed)?; @@ -42,7 +43,14 @@ impl Automation { let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?; let meshes = assemble(&prc).map_err(failed)?; let aspect = f64::from(width) / f64::from(height); - let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?; + let camera = match a.get("orbit") { + Some(_) if a.get("direction").is_some() => return Err(Args::wrong("orbit", "left out when direction is given")), + Some(moves) => orbit_camera(moves, &meshes, perspective, aspect)?, + None => { + let direction = direction(a)?; + Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)? + } + }; let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?; let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?; Ok(vec![Content::Png { data, width: image.width, height: image.height }]) @@ -71,6 +79,65 @@ fn direction(a: &Args) -> Result<[f64; 3]> { Ok(out) } +/// The keys `orbit` takes. +const ORBIT_KEYS: [&str; 7] = ["view", "up", "front", "turn", "zoom", "zoom_at", "pan"]; + +/// The camera `orbit` names: a named view about an up axis, then a turn, a zoom and a pan, as a +/// viewer's orbit control would apply them. +fn orbit_camera(moves: &Value, meshes: &[WorldMesh], perspective: bool, aspect: f64) -> Result { + let wrong = |what: &str| Args::wrong("orbit", what); + let moves = moves.as_object().ok_or_else(|| wrong("an object"))?; + if let Some(key) = moves.keys().find(|k| !ORBIT_KEYS.contains(&k.as_str())) { + return Err(wrong(&format!("an object without {key}; its keys are {}", ORBIT_KEYS.join(", ")))); + } + let token = |key: &str| moves.get(key).map(|v| v.as_str().ok_or_else(|| wrong(&format!("{key} as a string")))); + let axis = |key: &str| token(key).map(|t| Axis::from_token(t?).ok_or_else(|| wrong(&format!("{key} as one of +x, +y, +z, -x, -y, -z")))); + let mut upright = Upright::default(); + if let Some(up) = axis("up") { + upright = upright.with_up(up?); + } + if let Some(front) = axis("front") { + let front = front?; + if !upright.up().perpendicular(front) { + return Err(wrong("front perpendicular to up")); + } + upright = upright.with_front(front); + } + let view = match token("view") { + None => NamedView::Isometric, + Some(t) => NamedView::from_token(t?).ok_or_else(|| wrong("view as one of iso, front, right, top, back"))?, + }; + let mut orbit = Orbit::named(view, perspective, upright); + let bounds = Bounds::of(meshes).ok_or_else(|| failed("the model has no vertex"))?; + let reach = reach(meshes, &bounds); + if let Some(v) = moves.get("turn") { + let [yaw, pitch] = pair(v).ok_or_else(|| wrong("turn as [yaw, pitch] in degrees"))?; + orbit.turn(yaw.to_radians(), pitch.to_radians()); + } + if let Some(v) = moves.get("zoom") { + let factor = v.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| wrong("zoom as a positive number"))?; + let at = match moves.get("zoom_at") { + None => [0.0, 0.0], + Some(v) => pair(v).ok_or_else(|| wrong("zoom_at as [right, up] in picture heights"))?, + }; + orbit.zoom_at(&bounds, reach, aspect, factor, at); + } else if moves.contains_key("zoom_at") { + return Err(wrong("an object with zoom when it has zoom_at")); + } + if let Some(v) = moves.get("pan") { + orbit.pan_by(pair(v).ok_or_else(|| wrong("pan as [right, up] in model radii"))?); + } + orbit.camera(&bounds, reach, aspect).map_err(failed) +} + +/// Two finite numbers. +fn pair(v: &Value) -> Option<[f64; 2]> { + match v.as_array()?.as_slice() { + [a, b] => Some([a.as_f64().filter(|f| f.is_finite())?, b.as_f64().filter(|f| f.is_finite())?]), + _ => None, + } +} + /// Z is up, as CAD models are drawn, unless the view looks along it. fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] { if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] } 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/paths.rs b/crates/automation/src/paths.rs index e13d0a1452..425cb3792e 100644 --- a/crates/automation/src/paths.rs +++ b/crates/automation/src/paths.rs @@ -1,5 +1,5 @@ use super::error::{Result, failed}; -use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; +use super::{Automation, Path, PathBuf, StagingName, file_access, staging_suffixes}; impl Automation { // ---- paths ------------------------------------------------------------------------------- @@ -39,7 +39,13 @@ impl Automation { let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); let joined = lexical(&root.join(p)).ok_or_else(outside)?; - if foreign_share(&joined, root) { + // Followed one name at a time first: nothing outside the root or its own parent folders is + // looked up, not even to see whether it exists. What can't be followed inside the root is + // said below. Made absolute the way Windows reads it first: it drops a folder name's + // trailing dot (`in.` is `in`), but `follow` looks names up below `\\?\` folders, where it + // doesn't. + let as_read = std::path::absolute(&joined).map_err(|_| outside())?; + if matches!(file_access::follow(path, &as_read, root), file_access::Toward::Outside) { return Err(outside()); } let mut existing = joined.as_path(); diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 9a1c1a6b7c..2f1cf5e136 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -134,6 +134,18 @@ impl Automation { "shrink" => Layout::Size(SizeMode::Shrink), "custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)), "multiple" => { + // Custom pages per sheet: `cols` across by `rows` down, both or neither. + let grid = match (a.opt_int("cols")?, a.opt_int("rows")?) { + (None, None) => None, + (Some(cols), Some(rows)) => { + if a.opt_int("per_sheet")?.is_some() { + return Err(bad("give per_sheet, or cols and rows, not both")); + } + let grid = usize::try_from(cols).ok().zip(usize::try_from(rows).ok()).and_then(|(cols, rows)| Layout::grid(cols, rows)); + Some(grid.ok_or_else(|| bad(format!("cols and rows must each be 1 to {}", print::MAX_GRID)))?) + } + _ => return Err(bad("a custom grid needs both cols and rows")), + }; let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize; let order = match a.opt_str("order")?.unwrap_or("horizontal") { "horizontal" => PageOrder::Horizontal, @@ -143,14 +155,15 @@ impl Automation { "cut-stack" => PageOrder::CutStack, o => return Err(bad(format!("unknown order {o:?}"))), }; - match Layout::multiple(per) { - Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple { + match grid.unwrap_or_else(|| Layout::multiple(per)) { + Layout::Multiple { cols, rows, per_sheet, custom, .. } => Layout::Multiple { cols, rows, per_sheet, order, border: a.opt_bool("border")?.unwrap_or(false), auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true), + custom, }, l => l, } @@ -172,6 +185,9 @@ impl Automation { l => return Err(bad(format!("unknown layout {l:?}"))), }; let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; + if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { + return Err(bad("cols and rows need layout multiple")); + } if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cut-stack order needs layout multiple")); } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index ad0aa039dc..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(); @@ -219,6 +321,7 @@ fn names_from_documents_become_one_plain_file_name() { #[cfg(windows)] #[test] fn other_shares_and_device_paths_are_refused_by_name() { + use crate::file_access::foreign_share; let local = Path::new(r"\\?\C:\work\root"); for p in [ r"\\host\share\x.pdf", @@ -242,6 +345,28 @@ fn other_shares_and_device_paths_are_refused_by_name() { assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared)); } +/// Windows drops a trailing dot from a folder name when it opens a path (`root.` is `root`), so +/// with a root, a path that names the root or a folder above it that way still reaches the file +/// inside. A dotted name outside stays outside. +#[cfg(windows)] +#[test] +fn a_trailing_dot_on_the_root_or_above_it_names_the_folder_windows_opens() { + let base = std::env::temp_dir().join(format!("pdfcraft-automation-dot-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join("root")).unwrap(); + std::fs::create_dir_all(base.join("outside")).unwrap(); + std::fs::write(base.join("root").join("a.pdf"), b"x").unwrap(); + std::fs::write(base.join("outside").join("b.pdf"), b"x").unwrap(); + let a = Automation::new().with_root(base.join("root")).unwrap(); + let shown = base.to_string_lossy().into_owned(); + for p in [format!(r"{shown}\root.\a.pdf"), format!(r"{shown}.\root\a.pdf")] { + let resolved = a.resolve(&p, false); + assert!(resolved.is_ok(), "{p}: {resolved:?}"); + } + assert!(a.resolve(&format!(r"{shown}\outside.\b.pdf"), false).is_err()); + let _ = std::fs::remove_dir_all(&base); +} + /// A fresh, empty folder for one staging test. fn staging_dir(test: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id())); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e0d8dda2f3..27eaabca1b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -140,8 +140,8 @@ pub fn tools() -> Vec { .with(schema(json!({ "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}}, "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."}, - "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."}, - "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."} + "prefix": {"type":"string","maxLength":128,"description":"Bates prefix, up to 128 characters; cannot contain # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Bates suffix, up to 128 characters; cannot contain # or >>."}, + "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false). Output collisions are checked conservatively: Windows compares case-insensitively, while Unix preserves case, so case-sensitive Windows and case-insensitive Unix volumes can differ from normal runs."} }), &["files"])), t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ @@ -195,12 +195,21 @@ pub fn tools() -> Vec { "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), - t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") + t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. orbit, in place of direction, frames the model as a viewer's orbit control does (the same size from every side) and applies, in order: view (iso, front, right, top or back; default iso) about up (+x, +y, +z, -x, -y or -z; default +z) with the Front view looking along front (an axis perpendicular to up; default +y, or -z when y is up), then turn [yaw, pitch] in degrees (pitch looks down and stops just short of the up axis), then zoom (a factor, the result held within 0.05 to 50) about the picture point zoom_at [right, up] in picture heights from the centre (default the centre), then pan [right, up] in model radii (it moves the target, so the model moves the other way). perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 }, "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 }, - "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, - "perspective": { "type": "boolean" } + "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3, "description": "The view direction from the eye towards the model (default [-1, 1, -1]). Mutually exclusive with orbit." }, + "perspective": { "type": "boolean" }, + "orbit": { "type": "object", "description": "A viewer's orbit moves, applied in the order of these keys; all optional. Mutually exclusive with direction.", "properties": { + "view": { "type": "string", "enum": ["iso", "front", "right", "top", "back"], "description": "The view to start from (default iso)." }, + "up": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The model axis that appears upward (default +z)." }, + "front": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The axis the Front view looks along, perpendicular to up (default +y, or -z when y is up)." }, + "turn": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[yaw, pitch] in degrees, pitch looking down (default [0, 0])." }, + "zoom": { "type": "number", "exclusiveMinimum": 0, "description": "A factor on the fitted view (default 1); the result is held within 0.05 to 50." }, + "zoom_at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "The picture point the zoom keeps still, [right, up] in picture heights from the centre (default [0, 0]). Requires zoom." }, + "pan": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "Moves the target [right, up] in model radii, so the model moves the other way (default [0, 0]); each held within 1000." } + }, "additionalProperties": false } }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), @@ -237,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( @@ -698,7 +708,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ @@ -709,6 +719,8 @@ pub fn tools() -> Vec { "layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] }, "scale": { "type": "number", "exclusiveMinimum": 0 }, "per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 }, + "cols": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: columns across the sheet; give rows too, and no per_sheet." }, + "rows": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: rows down the sheet; give cols too, and no per_sheet." }, "order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] }, "border": { "type": "boolean" }, "auto_rotate": { "type": "boolean" }, @@ -801,7 +813,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.") @@ -809,22 +821,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"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 16e059efc9..edbf2f0080 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3129,6 +3129,15 @@ fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failu {"input":"a.pdf","output":"a-numbered.pdf"} ]); let mut a = auto(&dir); + for invalid_start in [json!(0), json!(-1), json!(1.5), json!("17")] { + let error = err(&mut a, "doc_bates_multi", json!({"files":files,"start_number":invalid_start})); + assert!(matches!(error, ToolError::InvalidArgs(_))); + } + let unicode = ok(&mut a, "doc_bates_multi", json!({"files":files,"prefix":"é".repeat(65),"dry_run":true})); + assert_eq!(unicode["files"].as_array().unwrap().len(), 2); + assert!(matches!(err(&mut a, "doc_bates_multi", json!({"files":files,"prefix":"CASE>>"})), ToolError::InvalidArgs(_))); + assert!(matches!(err(&mut a, "doc_bates_multi", json!({"files":files,"suffix":"A>>B"})), ToolError::InvalidArgs(_))); + let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true})); assert_eq!(allocation["files"][0]["first"], 50); assert_eq!(allocation["files"][0]["last"], 51); @@ -3147,15 +3156,24 @@ fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failu let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX})); assert!(overflow.to_string().contains("overflows")); assert!(!dir.join("overflow.pdf").exists()); - // A range may end at u64::MAX itself. - let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true})); - assert_eq!(at_max["files"][0]["last"], u64::MAX); - for bad in - [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})] + std::fs::write(dir.join("one.pdf"), fixture(1)).unwrap(); + let max_number = + ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"one.pdf","output":"one-numbered.pdf"}],"start_number":u64::MAX,"dry_run":true})); + assert_eq!(max_number["files"][0]["first"], u64::MAX); + assert_eq!(max_number["files"][0]["last"], u64::MAX); + + #[cfg(windows)] { - let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true}); - args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); - assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}"); + let collision = err( + &mut a, + "doc_bates_multi", + json!({"files":[ + {"input":"b.pdf","output":"Case.pdf"}, + {"input":"a.pdf","output":"CASE.pdf"} + ]}), + ); + assert!(collision.to_string().contains("same output path")); + assert!(!dir.join("Case.pdf").exists()); } use pdfcraft_engine::{Changes, Edit, Protection, Session}; @@ -6343,6 +6361,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. @@ -6370,6 +6462,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"); @@ -6943,11 +7069,26 @@ fn drawing_comments_through_tools() { a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "polygon", "points": [[1, 1]] })), Err(ToolError::Failed(_) | ToolError::InvalidArgs(_)) )); - // Replace Text: a strikeout over the found text and a grouped caret. - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page" })); + // Replace Text: a strikeout over the found text, grouped under a caret holding the + // replacement (the group's primary, ISO 32000-2 §12.5.6.2). + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2", "contents": "second page", "color": "green" })); let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 2 })); - let strike = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "StrikeOut").unwrap().clone(); - assert_eq!(strike["replies"][0]["contents"], "second page", "the caret threads under the strikeout"); + let caret = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); + assert_eq!(caret["contents"], "second page", "the replacement is on the caret"); + assert_eq!(caret["replies"][0]["type"], "StrikeOut", "the strikeout is grouped under the caret"); + // Review on #5689 (CodeRabbit): the requested colour is the group's, so the primary has it. + assert_eq!(caret["replies"][0]["color"], caret["color"], "one colour for the pair: {caret}"); + assert_eq!(caret["color"], json!("#2E9E5C"), "the requested green, not the caret's default blue: {caret}"); + // Restyling the caret restyles the proposal, its strikeout too, as one undo step. + let pair = |a: &mut Automation| { + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 2 })); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Caret").unwrap().clone(); + (c["color"].clone(), c["replies"][0]["color"].clone()) + }; + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 2, "index": caret["index"], "color": "purple" })); + assert_eq!(pair(&mut a), (json!("#8A3FD1"), json!("#8A3FD1")), "the strikeout follows its caret"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(pair(&mut a), (json!("#2E9E5C"), json!("#2E9E5C")), "one undo puts both back"); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 2, "type": "replace", "find": "page 2" })), Err(ToolError::InvalidArgs(_)))); // A file attached as a comment. std::fs::write(dir.join("notes.txt"), b"remember").unwrap(); @@ -8387,6 +8528,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"); @@ -8681,6 +8838,119 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; assert_eq!(paragraphs.len(), 2, "{xml}"); } +/// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 +/// and so on, each in its built-in encoding. A font given `bfchar` pairs (" ") reads +/// its codes through that ToUnicode map, as a symbol font Word embeds does. +fn standard_fonts_pdf(content: &str, fonts: &[(&str, Option<&str>)]) -> Vec { + let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut maps = Vec::new(); + for (font, map) in fonts { + let to_unicode = map.map_or(String::new(), |map| { + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> endcodespacerange \ +1 beginbfchar {map} endbfchar endcmap CMapName currentdict /CMap defineresource pop end end" + ); + maps.push(format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len())); + format!(" /ToUnicode {} 0 R", fonts.len() + 4 + maps.len()) + }); + objs.push(format!("<< /Type /Font /Subtype /Type1 /BaseFont /{font}{to_unicode} >>")); + } + objs.extend(maps); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// The text of each paragraph that holds any in `word/document.xml` of the .docx at `path`. +fn word_paragraph_texts(path: &Path) -> Vec { + let zip = std::fs::read(path).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + xml.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect::() + }) + .filter(|text| !text.is_empty()) + .collect() +} + +/// #4158: a sentence that wraps onto the next lines after a bold name and a slanted one reaches +/// Word as one paragraph, though reading splits each of its lines where the style changes. +#[test] +fn exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph() { + let dir = workdir("wrapped-sentence-word"); + let content = "BT /F1 10 Tf 72 700 Td (The parties to this lease are the owner, ) Tj /F2 10 Tf (Harbour Lane Properties) Tj \ +/F1 10 Tf (, of 4 Quay Street, and the) Tj ET \ +BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch Studio) Tj \ +/F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ +BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ +BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; + let fonts = [("Helvetica", None), ("Helvetica-Bold", None), ("Helvetica-Oblique", None)]; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &fonts)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); +} + +/// A bulleted list drawn as Word draws one, each bullet in the Symbol font (read as U+F0B7, as +/// Word maps it) a tab stop before its item's text, reaches Word as one paragraph per item and +/// not as a two-column table. +#[test] +fn exporting_to_word_keeps_a_bulleted_list_as_paragraphs() { + let dir = workdir("bulleted-list-word"); + let mut content = String::from("BT /F1 10 Tf 72 700 Td (The tenant looks after:) Tj ET\n"); + for (n, item) in ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].iter().enumerate() { + let y = 686 - 14 * n; + content.push_str(&format!("BT /F2 10 Tf 90 {y} Td (\\267) Tj ET BT /F1 10 Tf 108 {y} Td ({item}) Tj ET\n")); + } + content.push_str("BT /F1 10 Tf 72 630 Td (Rent is paid monthly in advance.) Tj ET"); + std::fs::write(dir.join("list.pdf"), standard_fonts_pdf(&content, &[("Helvetica", None), ("Symbol", Some(" "))])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "list.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "list.docx" })); + assert_eq!( + word_paragraph_texts(&dir.join("list.docx")), + [ + "The tenant looks after:", + "\u{F0B7} the stairs and the landing;", + "\u{F0B7} the shared yard and its gate;", + "\u{F0B7} the bins, every week.", + "Rent is paid monthly in advance." + ] + ); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). @@ -8929,6 +9199,49 @@ fn cut_stack_printing_through_tools() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn custom_pages_per_sheet_prints_the_grid_as_given() { + // Two narrow pages side by side on portrait A4: 2 columns by 1 row (#6551). + let dir = workdir("custom-grid"); + std::fs::write(dir.join("narrow.pdf"), fixture(4)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "narrow.pdf"}))["doc"].as_u64().unwrap(); + for orientation in ["auto", "portrait"] { + let r = ok( + &mut a, + "doc_print", + json!({"doc": doc, "layout": "multiple", "cols": 2, "rows": 1, "paper": "A4", "orientation": orientation, "path": "grid.pdf"}), + ); + assert_eq!(r["sheets"], 2, "{orientation}"); + let printed = ok(&mut a, "doc_open", json!({"path": "grid.pdf"}))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({"doc": printed})); + let sheet = &info["pages"][0]; + assert!(sheet["width"].as_f64().unwrap() < sheet["height"].as_f64().unwrap(), "portrait sheet with {orientation}: {sheet}"); + // Side by side: the second page level with the first, to its right. + let rows = ok(&mut a, "text_extract", json!({"doc": printed, "rect": [0, 0, 600, 850]})); + let rows: Vec<&str> = rows["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap().trim()).collect(); + assert_eq!(rows, ["Page 1 Page 2", "Page 3 Page 4"], "{orientation}"); + ok(&mut a, "doc_close", json!({"doc": printed})); + } + for bad in [ + json!({"cols": 2}), + json!({"rows": 1}), + json!({"cols": 0, "rows": 1}), + json!({"cols": 2, "rows": 100}), + json!({"cols": -1, "rows": 2}), + json!({"cols": 2, "rows": 1, "per_sheet": 2}), + json!({"cols": 2, "rows": 1, "layout": "fit"}), + ] { + let mut args = json!({"doc": doc, "layout": "multiple", "path": "refused.pdf"}); + for (k, v) in bad.as_object().unwrap() { + args[k] = v.clone(); + } + assert!(matches!(a.call("doc_print", &args), Err(ToolError::InvalidArgs(_))), "{bad}"); + } + assert!(!dir.join("refused.pdf").exists()); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn measurements_calibrate_draw_save_reopen_and_export() { let dir = workdir("measurements"); @@ -12136,6 +12449,49 @@ fn comments_copy_and_paste_between_documents_through_tools() { assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched"); } +/// A bitmap Type 3 font drawn with a flipped font matrix under a flipped text matrix (as TeX's +/// bitmap fonts are): `text_lines` boxes a line by its glyphs' `d1` boxes (ISO 32000-2 §9.6.4), +/// as high as the ink, not a 0.09-point sliver. +#[test] +fn text_lines_box_a_flipped_type3_line_by_its_glyphs() { + let content = "BT /F1 1 Tf 0.15 0 0 -0.15 10 20 Tm (ab) Tj ET"; + let glyph_a = "46 0 3 -1 42 57 d1 3 -1 39 58 re f"; + let glyph_b = "42 0 2 -18 40 38 d1 2 -18 38 56 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 50] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type3 /FontMatrix [1 0 0 -1 0 0] /FontBBox [-4 -18 51 57] /FirstChar 97 /LastChar 98 \ + /Widths [46 42] /Encoding << /Differences [97 /a /b] >> /CharProcs << /a 6 0 R /b 7 0 R >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()), + format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("type3-text-lines"); + std::fs::write(dir.join("type3.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "type3.pdf" }))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })); + // Glyph space y -18..57, turned over twice: 57 × 0.15 above the baseline at y 20 (8.55) and + // 18 × 0.15 below it (2.7). Top-left origin on a 50-point page: 21.45 down to 32.7. + assert_eq!(lines["count"], 1, "{lines}"); + assert_eq!(lines["lines"][0]["text"], "ab"); + assert_eq!(lines["lines"][0]["rect"], json!([10.0, 21.45, 23.2, 32.7]), "{lines}"); +} + #[test] fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/automation/tests/it/model_render.rs b/crates/automation/tests/it/model_render.rs index 615a5346a9..c7dcca1af6 100644 --- a/crates/automation/tests/it/model_render.rs +++ b/crates/automation/tests/it/model_render.rs @@ -110,3 +110,171 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() { let doc = open(&mut a, "prc.pdf"); assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok()); } + +#[test] +fn an_orbit_frames_the_model_from_a_named_view_and_its_moves() { + let (_dir, mut a) = setup("orbit"); + let doc = open(&mut a, "prc.pdf"); + let draw = |a: &mut Automation, orbit: Value| orbit_picture(a, doc, false, &orbit); + // The triangle lies in z = 0: the Top view looks down on its face, the Front view along its edge. + let top = draw(&mut a, json!({ "view": "top" })); + assert_eq!(rgb(&top, 32, 24), [190, 192, 200], "the top view sees the face head on"); + let front = draw(&mut a, json!({ "view": "front" })); + assert!((0..48).all(|y| rgb(&front, 32, y) == [255, 255, 255]), "the front view sees only its edge"); + let tipped = draw(&mut a, json!({ "view": "front", "turn": [0, 30] })); + assert_ne!(rgb(&tipped, 32, 24), [255, 255, 255], "tipped down from the front, the face is in the middle"); + // With y up the Front view looks along -z, onto the face. + let y_up = draw(&mut a, json!({ "view": "front", "up": "+y" })); + assert_eq!(rgb(&y_up, 32, 24), [190, 192, 200]); + // Turning the front view down by 90° stops just short of the top view and sees the face. + let turned = draw(&mut a, json!({ "view": "front", "turn": [0, 90] })); + assert_ne!(rgb(&turned, 32, 24), [255, 255, 255]); + // A pan moves the target right, so the model moves left; three radii take it out of the picture. + let (left, right) = extent(&top, 24); + let (moved_left, moved_right) = extent(&draw(&mut a, json!({ "view": "top", "pan": [0.5, 0] })), 24); + assert!(moved_left < left && moved_right < right, "{left}..{right} moved to {moved_left}..{moved_right}"); + let panned = draw(&mut a, json!({ "view": "top", "pan": [3, 0] })); + assert!((0..64).all(|x| rgb(&panned, x, 24) == [255, 255, 255]), "the model has left the picture"); + // Zooming in about the centre keeps the face under it and leaves no background at the corner. + let zoomed = draw(&mut a, json!({ "view": "top", "zoom": 8 })); + assert_eq!(rgb(&zoomed, 32, 24), [190, 192, 200]); + assert_ne!(rgb(&zoomed, 0, 0), [255, 255, 255], "zoomed in, the face fills the corner too"); +} + +/// The first and last columns on row `y` that are not background. +fn extent(data: &[u8], y: usize) -> (usize, usize) { + let drawn: Vec = (0..64).filter(|&x| rgb(data, x, y) != [255, 255, 255]).collect(); + (*drawn.first().expect("the model is on the row"), *drawn.last().expect("the model is on the row")) +} + +/// The first and last rows on column `x` that are not background. +fn rows(data: &[u8], x: usize) -> (usize, usize) { + let drawn: Vec = (0..48).filter(|&y| rgb(data, x, y) != [255, 255, 255]).collect(); + (*drawn.first().expect("the model is on the column"), *drawn.last().expect("the model is on the column")) +} + +/// A 64 by 48 picture of the model through `orbit`. +fn orbit_picture(a: &mut Automation, doc: u64, perspective: bool, orbit: &Value) -> Vec { + let out = + a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "perspective": perspective, "orbit": orbit })).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + data.clone() +} + +#[test] +fn an_empty_orbit_frames_the_isometric_view_in_perspective() { + let (_dir, mut a) = setup("orbit-default"); + let doc = open(&mut a, "prc.pdf"); + let default = orbit_picture(&mut a, doc, false, &json!({})); + assert_ne!(rgb(&default, 32, 24), [255, 255, 255], "the model is centred"); + let (left, right) = extent(&default, 24); + let (high, low) = rows(&default, 32); + assert!(left > 0 && right < 63 && high > 0 && low < 47, "inside the picture: {left}..{right}, {high}..{low}"); + assert!(right - left > 16, "and fills it: {left}..{right}"); + assert_eq!(default, orbit_picture(&mut a, doc, false, &json!({ "view": "iso", "up": "+z", "front": "+y" })), "the defaults"); + let unset = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "orbit": {} })).unwrap(); + let Content::Png { data: unset, .. } = &unset[0] else { panic!("expected a PNG") }; + assert_eq!(*unset, orbit_picture(&mut a, doc, true, &json!({})), "perspective is the default"); + assert_ne!(*unset, default, "and shows"); +} + +#[test] +fn zoom_at_keeps_the_point_under_it_still() { + let (_dir, mut a) = setup("orbit-zoom-at"); + let doc = open(&mut a, "prc.pdf"); + let (left, right) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2 })), 24); + // A point 0.2 picture heights off centre stays there, so what was at the centre moves + // 0.2 x (2 - 1) x 48 = 9.6 pixels the other way. + for (at, sign) in [(0.2, -1.0), (-0.2, 1.0)] { + let (l, r) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2, "zoom_at": [at, 0] })), 24); + for (was, now) in [(left, l), (right, r)] { + let moved = now as f64 - was as f64; + assert!((moved - sign * 9.6).abs() <= 1.5, "at {at}: {was} moved to {now}"); + } + } +} + +#[test] +fn the_zoom_is_held_within_its_range() { + let (_dir, mut a) = setup("orbit-zoom-range"); + let doc = open(&mut a, "prc.pdf"); + let far = orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.05 })); + assert_eq!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.001 })), "the far end"); + assert_ne!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.2 }))); + // Panned to just inside the near edge, so a nearer view would move the edge in the picture. + let close = |z: f64| json!({ "view": "top", "zoom": z, "pan": [0, -0.7] }); + let near = orbit_picture(&mut a, doc, false, &close(50.0)); + assert_eq!(near, orbit_picture(&mut a, doc, false, &close(1000.0)), "the near end"); + assert_ne!(near, orbit_picture(&mut a, doc, false, &close(25.0))); +} + +#[test] +fn an_orbit_draws_in_perspective_at_the_same_scale() { + let (_dir, mut a) = setup("orbit-perspective"); + let doc = open(&mut a, "prc.pdf"); + let tipped = json!({ "view": "front", "turn": [0, 30] }); + let deep = orbit_picture(&mut a, doc, true, &tipped); + assert_ne!(rgb(&deep, 32, 24), [255, 255, 255]); + assert_ne!(deep, orbit_picture(&mut a, doc, false, &tipped), "foreshortened"); + let top = json!({ "view": "top" }); + let (left, right) = extent(&orbit_picture(&mut a, doc, true, &top), 24); + let (flat_left, flat_right) = extent(&orbit_picture(&mut a, doc, false, &top), 24); + assert!(left.abs_diff(flat_left) <= 2 && right.abs_diff(flat_right) <= 2, "{left}..{right} against {flat_left}..{flat_right}"); +} + +#[test] +fn an_orbit_front_yaw_and_upward_pan_each_move_the_picture() { + let (_dir, mut a) = setup("orbit-moves"); + let doc = open(&mut a, "prc.pdf"); + let mut draw = |orbit: Value| orbit_picture(&mut a, doc, false, &orbit); + // Yawing the Front view by 90 degrees is the Right view, which looks along -x; +x is its opposite. + let right_view = draw(json!({ "view": "right", "turn": [0, 30] })); + assert_eq!(right_view, draw(json!({ "view": "front", "turn": [90, 30] })), "yaw"); + assert_eq!(right_view, draw(json!({ "view": "front", "front": "-x", "turn": [0, 30] })), "front -x"); + assert_ne!(right_view, draw(json!({ "view": "front", "turn": [0, 30] }))); + assert_eq!(draw(json!({ "view": "right", "turn": [180, 30] })), draw(json!({ "view": "front", "front": "+x", "turn": [0, 30] })), "front +x"); + // From above, the triangle's apex is up the picture; a quarter yaw lays it along the row, + // twice as wide there. + let top = draw(json!({ "view": "top" })); + let (left, right) = extent(&top, 24); + let (yaw_left, yaw_right) = extent(&draw(json!({ "view": "top", "turn": [90, 0] })), 24); + assert!(yaw_right - yaw_left > (right - left) * 3 / 2, "{left}..{right} yawed to {yaw_left}..{yaw_right}"); + // A pan up moves the target up, so the model moves down. + let (high, low) = rows(&top, 32); + let (moved_high, moved_low) = rows(&draw(json!({ "view": "top", "pan": [0, 0.25] })), 32); + assert!(moved_high > high && moved_low >= low, "{high}..{low} moved to {moved_high}..{moved_low}"); +} + +#[test] +fn a_wrong_orbit_is_a_clear_error() { + let (_dir, mut a) = setup("orbit-bad"); + let doc = open(&mut a, "prc.pdf"); + for (bad, reason) in [ + (json!({ "orbit": { "view": "side" } }), "view as one of"), + (json!({ "orbit": { "view": 1 } }), "view as a string"), + (json!({ "orbit": { "up": "z" } }), "up as one of"), + (json!({ "orbit": { "up": 1 } }), "up as a string"), + (json!({ "orbit": { "front": "y" } }), "front as one of"), + (json!({ "orbit": { "up": "+z", "front": "-z" } }), "front perpendicular to up"), + (json!({ "orbit": { "turn": [1] } }), "turn as [yaw, pitch]"), + (json!({ "orbit": { "turn": [1, 2, 3] } }), "turn as [yaw, pitch]"), + (json!({ "orbit": { "turn": 1 } }), "turn as [yaw, pitch]"), + (json!({ "orbit": { "zoom": 0 } }), "zoom as a positive number"), + (json!({ "orbit": { "zoom": -2 } }), "zoom as a positive number"), + (json!({ "orbit": { "zoom": "a" } }), "zoom as a positive number"), + (json!({ "orbit": { "zoom_at": [0.1, 0] } }), "with zoom when it has zoom_at"), + (json!({ "orbit": { "zoom": 2, "zoom_at": [1] } }), "zoom_at as [right, up]"), + (json!({ "orbit": { "zoom": 2, "zoom_at": [1, 2, 3] } }), "zoom_at as [right, up]"), + (json!({ "orbit": { "pan": [1, "a"] } }), "pan as [right, up]"), + (json!({ "orbit": { "pan": [1] } }), "pan as [right, up]"), + (json!({ "orbit": { "pan": [1, 2, 3] } }), "pan as [right, up]"), + (json!({ "orbit": { "spin": 1 } }), "without spin"), + (json!({ "orbit": 1 }), "an object"), + (json!({ "orbit": {}, "direction": [0, 0, -1] }), "left out when direction is given"), + ] { + let mut args = json!({ "doc": doc, "page": 1, "width": 16, "height": 16 }); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + let err = a.call("model_render", &args).unwrap_err().to_string(); + assert!(err.contains("orbit must be") && err.contains(reason), "{bad}: {err}"); + } +} 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/README.md b/crates/cos/README.md index 9f5fb56725..a40e022e29 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -20,8 +20,8 @@ let doc = Document::open(bytes)?; // Arc> let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files let root = doc.root(); // Option — the catalog -let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc -let real = doc.resolve(&obj); // follow one indirect reference +let obj = doc.get(ObjRef::new(12, 0)); // load (and cache): Arc +let real = doc.resolve(&obj); // follow a bounded reference chain let d = doc.dict(&obj); // Option, resolving first let data = stream.decoded()?; // filters applied on demand let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 1a99885944..56286ce4a0 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -209,6 +209,11 @@ impl ImageBudget { self.refused } + /// Charges `work` steps paid for out of another allowance, by a thread working on its own. + pub fn charge(&mut self, work: u64) { + self.work = self.work.saturating_sub(work); + } + /// The steps of decoding still allowed. pub fn work_left(&self) -> u64 { self.work @@ -748,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 32a6f6d8b3..9e9b6a8764 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -25,12 +25,12 @@ mod writer; pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; -pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream}; +pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars}; 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, pdf_date, reachable_objects, serialize, write_full, write_incremental}; +pub use writer::{SaveOptions, civil_from_days, 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 68e8b1383e..f40f175d69 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -57,19 +57,90 @@ impl PdfString { /// non-ASCII bytes (written by some producers) is read as UTF-8 rather than as /// PDFDocEncoding mojibake, and stray leading U+FEFF (doubled BOMs) are removed. pub fn to_text(&self) -> String { - let s = match self.bytes.as_slice() { + self.text_chars().collect() + } + + /// The characters [`PdfString::to_text`] decodes, one at a time, without building a string. + pub fn text_chars(&self) -> TextChars<'_> { + let decode = match self.bytes.as_slice() { [0xFE, 0xFF, rest @ ..] => { - let units: Vec = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); - String::from_utf16_lossy(&units) + let units: fn(&[u8; 2]) -> u16 = |c| u16::from_be_bytes(*c); + Decode::Utf16(char::decode_utf16(rest.as_chunks::<2>().0.iter().map(units))) } - [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + [0xEF, 0xBB, 0xBF, rest @ ..] => Decode::Utf8Lossy { chunks: rest.utf8_chunks(), valid: "".chars(), replace: false }, b => match std::str::from_utf8(b) { // Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch. - Ok(utf8) if !b.is_ascii() => utf8.to_owned(), - _ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(), + Ok(utf8) if !b.is_ascii() => Decode::Utf8(utf8.chars()), + _ => Decode::PdfDoc(b.iter()), }, }; - if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s } + TextChars { decode, leading: true } + } + + /// Whether the string decodes to `s` ([`PdfString::to_text`]), compared without allocating. + pub fn text_eq(&self, s: &str) -> bool { + self.text_chars().eq(s.chars()) + } +} + +/// The characters of a text string: [`PdfString::text_chars`]. +pub struct TextChars<'a> { + decode: Decode<'a>, + /// Still at the start, where stray U+FEFF (doubled BOMs) are dropped. + leading: bool, +} + +/// The UTF-16BE code units after a BOM (an odd last byte is left out). +type Utf16Units<'a> = std::iter::Map, fn(&[u8; 2]) -> u16>; + +enum Decode<'a> { + Utf16(std::char::DecodeUtf16>), + /// After a UTF-8 BOM: each invalid sequence is one U+FFFD, as `String::from_utf8_lossy` has it. + Utf8Lossy { + chunks: std::str::Utf8Chunks<'a>, + valid: std::str::Chars<'a>, + replace: bool, + }, + Utf8(std::str::Chars<'a>), + PdfDoc(std::slice::Iter<'a, u8>), +} + +impl Iterator for Decode<'_> { + type Item = char; + + fn next(&mut self) -> Option { + match self { + Decode::Utf16(units) => units.next().map(|u| u.unwrap_or(char::REPLACEMENT_CHARACTER)), + Decode::Utf8Lossy { chunks, valid, replace } => loop { + if let Some(c) = valid.next() { + return Some(c); + } + if *replace { + *replace = false; + return Some(char::REPLACEMENT_CHARACTER); + } + let chunk = chunks.next()?; + *valid = chunk.valid().chars(); + *replace = !chunk.invalid().is_empty(); + }, + Decode::Utf8(chars) => chars.next(), + Decode::PdfDoc(bytes) => bytes.next().map(|&c| pdfcraft_crypt::pdfdoc_char(c)), + } + } +} + +impl Iterator for TextChars<'_> { + type Item = char; + + fn next(&mut self) -> Option { + loop { + let c = self.decode.next()?; + if self.leading && c == '\u{FEFF}' { + continue; + } + self.leading = false; + return Some(c); + } } } @@ -319,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( @@ -594,6 +673,33 @@ mod tests { assert_eq!(PdfString::literal(Vec::new()).to_text(), ""); } + #[test] + fn text_eq_compares_what_to_text_decodes() { + let utf16 = |s: &str| [0xFE, 0xFF].into_iter().chain(s.encode_utf16().flat_map(u16::to_be_bytes)).collect::>(); + // Each invalid UTF-8 sequence is one U+FFFD, as String::from_utf8_lossy has it. + let lossy = [0xEF, 0xBB, 0xBF, 0xC3, 0x28, b'x', 0xF0, 0x9F, 0x92, 0xFF]; + assert_eq!(PdfString::literal(lossy.to_vec()).to_text(), String::from_utf8_lossy(&lossy[3..])); + let cases: Vec> = vec![ + Vec::new(), + b"Plain".to_vec(), + vec![b'C', b'a', b'f', 0xE9, 0x18], + "目录".as_bytes().to_vec(), + utf16("第一章"), + vec![0xFE, 0xFF, 0xD8, 0x00, 0x00, b'A'], + vec![0xFE, 0xFF, 0xFE, 0xFF, 0x00, b'A', 0xFE, 0xFF], + lossy.to_vec(), + ]; + for bytes in cases { + let s = PdfString::literal(bytes); + let text = s.to_text(); + assert!(s.text_eq(&text), "{text:?}"); + assert!(!s.text_eq(&format!("{text}x")), "{text:?}"); + assert!(text.is_empty() || !s.text_eq(&text[..text.len() - text.chars().last().map_or(0, char::len_utf8)]), "{text:?}"); + } + // A U+FEFF after the first character is text and stays. + assert!(PdfString::literal(utf16("A\u{FEFF}")).text_eq("A\u{FEFF}")); + } + #[test] fn pdfdoc_encoding_high_range() { assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A"); 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 75d49dbd76..0d6ccba7d2 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(); @@ -919,14 +946,14 @@ pub fn pdf_date(unix_secs: i64) -> String { format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } -/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). Total for any `i64` count: +/// dates past `NaiveDate`'s ±262143-year range keep their proleptic Gregorian year instead of +/// falling back to the epoch. /// -/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, -/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every -/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back -/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar -/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. -fn civil_from_days(days: i64) -> (i64, u32, u32) { +/// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day +/// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't +/// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163. +pub fn civil_from_days(days: i64) -> (i64, u32, u32) { use chrono::Datelike; let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); // `rest + 719_163` is 719_163..865_259, always a valid CE day. @@ -1110,6 +1137,12 @@ mod tests { // its sign, exactly as before). assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + assert_eq!(crate::civil_from_days(0), (1970, 1, 1)); + assert_eq!(crate::civil_from_days(-1), (1969, 12, 31)); + assert_eq!(crate::civil_from_days(11_016), (2000, 2, 29)); + assert_eq!(crate::civil_from_days(19_782), (2024, 2, 29)); + assert_eq!(crate::civil_from_days(i64::MAX), (25_252_734_927_768_524, 7, 27)); + assert_eq!(crate::civil_from_days(i64::MIN), (-25_252_734_927_764_585, 6, 7)); } #[test] 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/create/src/extract.rs b/crates/create/src/extract.rs index 863ad2da0a..46b203a071 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -302,9 +302,14 @@ thread_local! { } /// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary -/// says: an image of one pixel can hold a header of 500 megapixels. +/// says: an image of one pixel can hold a header of 500 megapixels. `None`, as for a JPEG that +/// can't be read, when its metadata would take the decoder quadratic time +/// ([`decodable_metadata`](crate::upright_image::decodable_metadata)). fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> { use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions}; + if !crate::upright_image::decodable_metadata(data) { + return None; + } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; @@ -329,6 +334,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), _ => {} } + // Past the decoder's metadata bound, drop the Extended XMP chunks rather than the mask: + // decoded as it stands, an over-chunked JPEG is written as is, without its /SMask. + let stripped; + let data = if crate::upright_image::decodable_metadata(data) { + data + } else { + stripped = crate::upright_image::without_extended_xmp(data); + &stripped[..] + }; let img = jpeg_of_size(data, w, h)?; Some(match space { Space::Gray => (img.into_luma8().into_raw(), Space::Gray), @@ -340,6 +354,15 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec Option> { use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + // Past the decoder's metadata bound, decode the image without its Extended XMP chunks + // rather than not at all: written out as stored, a CMYK JPEG shows inverted colours. + let stripped; + let data = if crate::upright_image::decodable_metadata(data) { + data + } else { + stripped = crate::upright_image::without_extended_xmp(data); + &stripped[..] + }; let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 88a6e05ac9..9d5a70ffcd 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -941,6 +941,43 @@ fn damaged_masks_and_sizes_never_panic() { } } +#[test] +fn a_jpeg_with_thousands_of_extended_xmp_chunks_keeps_its_mask() { + // Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha. A JPEG + // whose Extended XMP chunks would take the decoder quadratic time is decoded without them, + // which is linear in what is left, so the mask survives; a JPEG mask past the same bound is + // still left out, without reaching the decoder. + let flood = crate::upright_image::tests::with_extended_xmp_chunks(&real_jpeg(), 5_000); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0; 8]))); + let mut d = image_dict(4, 2, "DeviceGray", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(d, flood.clone()) + }); + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + let out = extract_images(&doc, &[0], 0); + assert_eq!(out.images[0].extension, "png"); + assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 1); + // The chunks themselves are gone, so the decoder never saw them. + assert!(!out.images[0].data.windows(5).any(|w| w == b"http:")); + + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut md = image_dict(4, 2, "DeviceGray", 8); + md.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let mask = doc.add(Object::Stream(Stream::from_raw(md, flood.clone()))); + let mut d = image_dict(4, 2, "DeviceRGB", 8); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(d, vec![10; 24]) + }); + assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]); + assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 0); +} + #[test] fn indexed_image_export_clamps_samples_to_hival() { for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] { @@ -2788,3 +2825,30 @@ fn proportions_up_to_the_limit_scale_as_they_did() { let mask = noise(20, 10); assert_eq!(crate::extract::scaled(&mask, 20, 10), mask); } + +/// A CMYK JPEG past the decoder's Extended XMP bound is still decoded (its chunks left out) and +/// exported with the colours the page shows, not written as stored, which inverts them. +#[test] +fn a_cmyk_jpeg_flooded_with_extended_xmp_is_exported_in_its_colours() { + let jpeg = crate::upright_image::tests::with_extended_xmp_chunks(&flat_four_component_jpeg([255, 0, 0, 0], 0), 5_000); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(8, 8, "DeviceCMYK", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let m = doc.add(Object::Stream(Stream::from_raw(image_dict(8, 8, "DeviceGray", 8), vec![255; 64]))); + d.set(b"SMask".to_vec(), Object::Ref(m)); + Stream::from_raw(d, jpeg.clone()) + }); + let (_, rgba) = exported_rgba(&doc); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[0] <= 6 && p[1] >= 249 && p[2] >= 249), "{:?}", &rgba[..4]); +} + +/// Stripping leaves everything but the Extended XMP chunks, byte for byte. +#[test] +fn stripping_extended_xmp_leaves_the_rest_of_the_jpeg() { + let plain = flat_four_component_jpeg([255, 0, 0, 0], 0); + let flooded = crate::upright_image::tests::with_extended_xmp_chunks(&plain, 3); + assert_ne!(flooded, plain); + // The helper puts a stray byte before the chunks, which the decoder skips: it stays. + assert_eq!(crate::upright_image::without_extended_xmp(&flooded), [&plain[..2], b"\0".as_slice(), &plain[2..]].concat()); +} diff --git a/crates/create/src/upright_image.rs b/crates/create/src/upright_image.rs index b89c517190..d193a38bfb 100644 --- a/crates/create/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -30,7 +30,7 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { } fn turned(bytes: &[u8]) -> Option> { - if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS { + if !bytes.starts_with(&[0xFF, 0xD8]) || !decodable_metadata(bytes) { return None; } #[cfg(test)] @@ -65,10 +65,41 @@ fn turned(bytes: &[u8]) -> Option> { /// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number. const MAX_EXTENDED_XMP_CHUNKS: usize = 1024; +/// Whether JPEG `bytes` may be handed to a decoder (`image`'s or `zune-jpeg`'s, which `image` uses), +/// headers included: not when it carries more than [`MAX_EXTENDED_XMP_CHUNKS`]. Every path that +/// decodes a JPEG from a file checks this first: image import here, and image export. +pub(crate) fn decodable_metadata(bytes: &[u8]) -> bool { + extended_xmp_chunks(bytes) <= MAX_EXTENDED_XMP_CHUNKS +} + /// How many Extended XMP chunks (APP1 segments) come before the image data. fn extended_xmp_chunks(bytes: &[u8]) -> usize { + let mut chunks = 0usize; + each_extended_xmp(bytes, |_| chunks += 1); + chunks +} + +/// JPEG `bytes` without their Extended XMP chunks, which nothing here reads: what is left decodes +/// in time linear in its size however many chunks the file carried. +pub(crate) fn without_extended_xmp(bytes: &[u8]) -> Vec { + // Only the bytes that are kept: a CMYK JPEG can be mostly Extended XMP, and reserving the + // whole file for the little that remains aborts the export under a tight address-space limit. + let mut removed = 0usize; + each_extended_xmp(bytes, |r| removed = removed.saturating_add(r.len())); + let mut out = Vec::with_capacity(bytes.len().saturating_sub(removed)); + let mut at = 0; + each_extended_xmp(bytes, |r| { + out.extend_from_slice(bytes.get(at..r.start).unwrap_or_default()); + at = r.end; + }); + out.extend_from_slice(bytes.get(at..).unwrap_or_default()); + out +} + +/// Calls `f` with the byte range of each Extended XMP chunk (APP1 segment) before the image data. +fn each_extended_xmp(bytes: &[u8], mut f: impl FnMut(std::ops::Range)) { const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; - let (mut i, mut chunks) = (2usize, 0usize); + let mut i = 2usize; while let Some(&byte) = bytes.get(i) { // Stray bytes between segments, which the decoder skips too. if byte != 0xFF { @@ -86,14 +117,16 @@ fn extended_xmp_chunks(bytes: &[u8]) -> usize { _ => { let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break }; let len = usize::from(u16::from_be_bytes([hi, lo])); - if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) { - chunks += 1; + let end = i.saturating_add(2).saturating_add(len.max(2)); + // The prefix must start the APP1 segment's own payload: a zero-length header + // followed by prefix bytes is not a chunk. + if marker == 0xE1 && bytes.get(i.saturating_add(4)..end.min(bytes.len())).is_some_and(|seg| seg.starts_with(PREFIX)) { + f(i..end.min(bytes.len())); } - i = i.saturating_add(2).saturating_add(len.max(2)); + i = end; } } } - chunks } /// The resolution in a JFIF header, if the file has one in inches or centimetres. @@ -120,7 +153,7 @@ fn jfif_density(bytes: &[u8]) -> Option { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::tests::phone_jpeg; @@ -138,22 +171,24 @@ mod tests { assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } + /// `jpeg` with `n` empty chunks of one never-complete Extended XMP packet after its SOI. + pub(crate) fn with_extended_xmp_chunks(jpeg: &[u8], n: usize) -> Vec { + let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); + seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID + seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length + seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset + let mut chunk = vec![0xFF, 0xE1]; + chunk.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + chunk.extend_from_slice(&seg); + [&jpeg[..2], b"\0".as_slice(), &chunk.repeat(n), &jpeg[2..]].concat() + } + #[test] fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() { // Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every // chunk so far after each marker, quadratic in their number. - let chunk = |n: usize| { - let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); - seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID - seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length - seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset - let mut out = vec![0xFF, 0xE1]; - out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); - out.extend_from_slice(&seg); - out.repeat(n) - }; let photo = phone_jpeg(6, 144); - let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat(); + let with = |n: usize| with_extended_xmp_chunks(&photo, n); DECODES.with(|c| c.set(0)); assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo"); assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_))); diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index e697bc8553..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); @@ -302,23 +338,40 @@ impl SecurityHandler { } } - /// Algorithm 1 (§7.6.3.3): the key for one object. AES-256 uses the file key directly. + /// Algorithm 1 (§7.6.3.2): the key for one object. AES-256 uses the file key directly + /// (Algorithm 1.A, §7.6.3.3). + /// + /// For an RC4 crypt filter under `/V 4`, a file key shorter than 16 bytes is first extended + /// to 16 bytes with zeros. The standard doesn't say so (Algorithm 1 takes the n-byte key as + /// it is), but that is how Acrobat writes and reads such files: a `/V 4` RC4 crypt filter + /// with a 40- or 48-bit key (pdf.js issue 19484). The renderer's `hayro-syntax` reads them + /// the same way, so a page it shows is one this crate can read and save. AES-128 keys are + /// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only + /// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already. fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec { if method == Method::Aes256 { return self.key.clone(); } + let mut key = self.key.clone(); + if method == Method::Rc4 && self.dict.v == 4 && key.len() < 16 { + key.resize(16, 0); + } let mut h = Md5::new(); - h.update(&self.key); + h.update(&key); h.update(&num.to_le_bytes()[..3]); h.update(generation.to_le_bytes()); if method == Method::Aes128 { h.update(b"sAlT"); } let digest = h.finalize(); - digest[..(self.key.len() + 5).min(16)].to_vec() + digest[..(key.len() + 5).min(16)].to_vec() } 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/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 09a88ed129..a9745d7777 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -280,6 +280,69 @@ proptest::proptest! { } } +/// A `/V 4` RC4 crypt filter with a 40- or 48-bit key, as Acrobat writes it (pdf.js issue 19484): +/// the file key is extended to 16 bytes with zeros before Algorithm 1, so each object's key is +/// all 16 bytes of MD5(key, zeros, object, generation). Everything else keeps Algorithm 1 as the +/// standard has it, n + 5 bytes (at most 16) of MD5(key, object, generation[, "sAlT"]): a `/V 2` +/// file with the same short key, a full 128-bit `/V 4` RC4 key, and AESV2, short (88 bits) or +/// full. Each object key is computed here by hand; data written with it must decrypt. +#[test] +fn a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros() { + let text = b"Page tree in an object stream".to_vec(); + let cases = + [(4, 40, Method::Rc4), (4, 48, Method::Rc4), (2, 40, Method::Rc4), (4, 128, Method::Rc4), (4, 88, Method::Aes128), (4, 128, Method::Aes128)]; + for (v, bits, method) in cases { + let mut d = EncryptDict { + filter: b"Standard".to_vec(), + v, + r: if v == 4 { 4 } else { 3 }, + length_bits: bits, + o: vec![7; 32], + p: -3392, + encrypt_metadata: v != 4, + ..Default::default() + }; + if v == 4 { + d.crypt_filters = vec![(b"StdCF".to_vec(), method)]; + d.stm_f = b"StdCF".to_vec(); + d.str_f = b"StdCF".to_vec(); + } + let key = file_key_r4(&d, ID, b""); + assert_eq!(key.len(), (bits / 8) as usize); + d.u = u_value_r4(&d, ID, &key); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let (num, generation) = (13u32, 0u16); + let extended = v == 4 && method == Method::Rc4 && key.len() < 16; + let mut md5 = Md5::new(); + md5.update(&key); + if extended { + md5.update(vec![0u8; 16 - key.len()]); + } + md5.update(&num.to_le_bytes()[..3]); + md5.update(generation.to_le_bytes()); + if method == Method::Aes128 { + md5.update(b"sAlT"); + } + let digest = md5.finalize(); + let n = if extended { 16 } else { key.len() }; + let object_key = digest[..(n + 5).min(16)].to_vec(); + let case = format!("V{v}, {bits}-bit key, {method:?}"); + let written = if method == Method::Rc4 { + rc4(&object_key, &text) + } else { + let iv = [3u8; 16]; + [iv.to_vec(), aes_cbc_encrypt(&object_key, &iv, &text, true)].concat() + }; + assert_eq!(h.decrypt_string(num, generation, &written), text, "{case}"); + assert_eq!(h.decrypt_stream(num, generation, &written, StreamKind::Normal), text, "{case}"); + let saved = h.encrypt_stream(num, generation, &text, StreamKind::Normal); + if method == Method::Rc4 { + assert_eq!(saved, written, "saved as it was read: {case}"); + } + assert_eq!(h.decrypt_stream(num, generation, &saved, StreamKind::Normal), text, "{case}"); + } +} + #[test] fn owner_authentication_recovers_the_user_password() { for alg in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index d1be9ef521..a6a55224d9 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -54,16 +54,21 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); + // Widths add up (the standard-14 widths have no kerning), so each word is measured once + // and appended in place: a line that never fills (negative character spacing) stays + // linear instead of being copied and measured again for every word. for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - let candidate_w = - if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + let word_w = measure(word); + let candidate_w = if line.is_empty() { word_w } else { line_w + space + word_w }; if !line.is_empty() && candidate_w > width { out.push((std::mem::take(&mut line), rtl, index)); - line = word.to_string(); - line_w = measure(word); + line.push_str(word); + line_w = word_w; } else { - line = candidate; + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); line_w = candidate_w; } } diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index 3555910f04..a5b53581fc 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -328,8 +328,15 @@ pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result< /// names them so), so they go when the item is replaced or deleted. fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() }; - let Ok(data) = s.decoded() else { return Vec::new() }; - data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect() + // The stream is one this writer put there, so its decode is bounded by the stream itself: the + // page limit alone would refuse an item larger than a page's content, and its fonts would stay + // on the page's /Font, which `write` and `delete_content` are supposed to take them off. + let limit = pdfcraft_cos::MAX_PAGE_DECODED.max(s.raw.len().saturating_mul(16)); + let Ok(data) = s.decoded_within(limit) else { return Vec::new() }; + // Each name once: a stream that repeats a name is not copied out once per use. + let names: std::collections::BTreeSet<&[u8]> = + data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).collect(); + names.into_iter().map(<[u8]>::to_vec).collect() } fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec]) { diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index ef86d6c67a..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 { @@ -183,6 +362,19 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result pdfcraft_model::Page { + let mut dict = page.dict.clone(); + if let Some(own) = doc.get(page.obj).as_dict() { + for (k, v) in own.iter() { + dict.set(k.clone(), v.clone()); + } + } + pdfcraft_model::Page { obj: page.obj, dict } +} + fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result { let all = page_list(doc); check(pages, all.len())?; @@ -205,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result Result Result doc.set(r, Object::Array(kept)), None => doc.update_dict(page.obj, |d| { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 1c4829b418..6bf35331db 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -577,6 +577,83 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() { case("/BE << /S /C /I 2 >> /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] @@ -1521,6 +1598,45 @@ fn text_page(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +/// ISO 32000-2 §9.6.4: an Edit-text line set in a Type 3 bitmap font is as high as the boxes its +/// glyphs declare with `d1`, whatever its `/FontBBox` says and whichever way its font matrix +/// turns glyph space, so two lines 8 points apart are two lines with 7-point boxes, not slivers. +#[test] +fn edit_lines_in_a_type3_font_are_as_high_as_their_glyphs() { + // Glyph `a` is drawn 60 units wide, from 20 below the baseline to 50 above it, at 0.1 Tf: + // 7 points high, 2 below the baseline. + for (matrix, flip) in [("1 0 0 1 0 0", 1), ("1 0 0 -1 0 0", -1)] { + for bbox in ["[0 0 0 0]", "[0 0 1 1]"] { + let mut doc = text_page(&format!("BT /T3 0.1 Tf 1 0 0 {flip} 72 700 Tm (aa) Tj 1 0 0 {flip} 72 692 Tm (aa) Tj ET")); + let glyph = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"60 0 0 -20 60 50 d1 0 -20 60 70 re f".to_vec()))); + let font = doc.add( + pdfcraft_cos::parse_indirect( + format!( + "1 0 obj << /Type /Font /Subtype /Type3 /FontMatrix [{matrix}] /FontBBox {bbox} /FirstChar 97 /LastChar 97 /Widths [60] /Encoding << /Differences [97 /a] >> /CharProcs << /a {} 0 R >> >> endobj", + glyph.num + ) + .as_bytes(), + 0, + &|_| None, + ) + .unwrap() + .1, + ); + let p = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(p, |d| { + d.set( + b"Resources".to_vec(), + Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"T3".to_vec(), Object::Ref(font))])))])), + ) + }) + .unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let rects: Vec<_> = lines.iter().map(|l| (l.text.as_str(), l.rect.map(|v| (v * 100.0).round() / 100.0))).collect(); + assert_eq!(rects, [("aa", [72.0, 698.0, 84.0, 705.0]), ("aa", [72.0, 690.0, 84.0, 697.0])], "{matrix} {bbox}"); + } + } +} + #[test] fn tracked_text_keeps_words_in_paragraphs_and_objects() { let expected = "TRACKED SAMPLE TEXT"; @@ -9748,6 +9864,20 @@ fn added_text_is_wrapped_once_per_write() { assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines"); } +/// Negative character spacing keeps a line from ever filling its box. Wrapping then holds every +/// word on one line, appended and measured once each rather than copied and measured again for +/// each word (which took minutes for this text); the widths still decide the breaks. +#[test] +fn added_text_that_never_fills_its_line_wraps_in_linear_time() { + let words = vec!["word"; 200_000].join(" "); + let packed = AddedText { rect: [0.0, 0.0, 100.0, 100.0], text: words.clone(), char_spacing: -10.0, ..AddedText::default() }; + let start = std::time::Instant::now(); + assert_eq!(added::lines(&packed), [words]); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + let plain = AddedText { rect: [0.0, 0.0, 60.0, 100.0], text: "one two three four".into(), size: 10.0, ..AddedText::default() }; + assert_eq!(added::lines(&plain), ["one two", "three four"]); +} + /// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item /// reopens with it; an item written without it reads 0. #[test] @@ -10854,6 +10984,116 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } +/// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to +/// the line takes a positioning gap for its spaces, not code 32 at width 0, which would run the words together. +#[test] +fn a_space_in_a_subset_that_gives_it_no_width_is_a_gap_not_code_32() { + for subtype in ["TrueType", "Type1"] { + // Codes 32..=98: the space and everything else 0 wide, but a and b (97, 98). + let widths: Vec = (32..=98).map(|c| if c == 97 || c == 98 { "500" } else { "0" }.to_string()).collect(); + let font = format!( + "<< /Type /Font /Subtype /{subtype} /BaseFont /ABCDEF+Sample /FirstChar 32 /LastChar 98 /Widths [{}] /Encoding /WinAnsiEncoding >>", + widths.join(" ") + ); + let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET"; + let objs = [ + "<< /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 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!( + "<< /Length {} >> +stream +{content} +endstream", + content.len() + ), + font, + ]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{o} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let x = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offs { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{x} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + let mut doc = Document::open(Arc::new(out)).unwrap(); + assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba").unwrap().substituted, None, "{subtype}"); + // Every byte the text-showing operators draw (strings inside `TJ` arrays too) and every + // `TJ` adjustment, read from the saved content rather than matched as text. + let ops = pdfcraft_content::parse(&page_content_bytes(&reopen(&doc), 0)).ops; + let mut shown: Vec = Vec::new(); + let mut adjustments: Vec = Vec::new(); + // How many bytes had been drawn when each adjustment came: the gap must sit between the words. + let mut positions: Vec = Vec::new(); + for op in ops.iter().filter(|o| matches!(o.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")) { + for operand in &op.operands { + match operand { + Object::String(s) => shown.extend_from_slice(&s.bytes), + Object::Array(items) => { + for item in items { + match item { + Object::String(s) => shown.extend_from_slice(&s.bytes), + number => { + if let Some(adjustment) = number.as_f64() { + adjustments.push(adjustment); + positions.push(shown.len()); + } + } + } + } + } + _ => {} + } + } + } + assert_eq!(shown, b"abba", "{subtype}: the words are drawn and no code 32 at width 0 between them"); + assert!(matches!(adjustments.as_slice(), [gap] if *gap < 0.0), "{subtype}: one adjustment moves the second word right: {adjustments:?}"); + assert_eq!(positions, [2], "{subtype}: the gap is between the words, after \"ab\""); + } +} + #[test] fn image_exclusive_scope_resets_path_on_scope_exit() { // An earlier scope has a path operation closed by Q (with 'h' closepath). @@ -11116,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] @@ -13757,3 +14029,84 @@ fn editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_aft assert_eq!(page_font_programs(&doc), fonts, "no replacement font program may be added"); } } + +/// The Unicode row breaks are invisible moves, not glyphs. A draft carrying them (text pasted +/// from a browser or Word) breaks the row on both rewrite paths, rather than being refused +/// because no fallback face has an outline for U+2028 (#6272). The paragraph is one wide row, so +/// a break that was dropped would leave the words on one row instead of two. A one-line edit has +/// no next row, so there each break is written as a space. +#[test] +fn unicode_row_breaks_break_the_row_instead_of_needing_a_glyph() { + let red = text::RangeStyle { + range: 0..3, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + for separator in ['\u{2028}', '\u{2029}', '\u{0085}', '\u{000B}', '\u{000C}', '\u{001C}', '\u{001D}', '\u{001E}'] { + let draft = format!("one{separator}two"); + for style in [ + text::BlockStyle::default(), + text::BlockStyle { ranges: vec![red.clone()], ..Default::default() }, + text::BlockStyle { ranges: vec![text::RangeStyle { range: 0..draft.len(), ..red.clone() }], ..Default::default() }, + text::BlockStyle { bold: Some(true), ..Default::default() }, + ] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator))); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two"], "U+{:04X}", u32::from(separator)); + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::replace_line(&mut doc, 0, 0, &draft).unwrap_or_else(|e| panic!("U+{:04X}: {e}", u32::from(separator))); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one two"], "a one-line edit keeps U+{:04X} on its line", u32::from(separator)); + } +} + +/// Thai text on the styled path has its own coverage checks and row splits. A Unicode row break +/// in a Thai draft breaks the row there too, whether the paragraph keeps its legacy subset, takes +/// the bundled Thai face by choice or is completed by the matching one, instead of being refused +/// for a glyph no face has (#6272). +#[test] +fn thai_paragraphs_break_the_row_on_unicode_row_breaks() { + let rows = |doc: &Document| -> Vec { text::text_lines(&reopen(doc), 0).unwrap().into_iter().map(|l| l.text).collect() }; + let thai_face = pdfcraft_fonts::document_thai_font().is_some(); + if !thai_face { + eprintln!("thai_paragraphs_break_the_row_on_unicode_row_breaks: built without craft-fonts, so only the legacy subset is checked"); + } + for separator in ['\u{2028}', '\u{001C}'] { + let draft = format!("กข{separator}ค"); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &legacy_thai_program(), LEGACY_THAI_UNICODE); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &style).unwrap_or_else(|e| panic!("legacy U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "legacy U+{:04X}", u32::from(separator)); + if !thai_face { + continue; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let chosen = BlockStyle { resource: Some("builtin:thai".into()), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &chosen).unwrap_or_else(|e| panic!("chosen U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "chosen U+{:04X}", u32::from(separator)); + let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET"); + let cmap = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <00> endcodespacerange 1 beginbfchar <41> <0E19> endbfchar", + ))); + doc.update_dict(pdfcraft_cos::ObjRef::new(5, 0), |f| { + f.set(b"BaseFont".to_vec(), Object::name("ABCDEF+NotoSansThai-Regular")); + f.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + }) + .unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &BlockStyle::default()) + .unwrap_or_else(|e| panic!("matching U+{:04X}: {e}", u32::from(separator))); + assert_eq!(rows(&doc), ["กข", "ค"], "matching U+{:04X}", u32::from(separator)); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2e73a5a075..a3ee880a64 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -36,13 +36,13 @@ mod interp; mod lines; mod rewrite; -use blocks::paragraph_separator; #[cfg(test)] use blocks::wrap; 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}; pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; use editor::{builtin_thai_choice, layout_draft}; #[cfg(test)] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index dcebb050dd..f2da128071 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -344,6 +344,15 @@ pub(crate) fn paragraph_separator(c: char) -> bool { c.is_whitespace() && c != '\u{3000}' } +/// Characters that end a row rather than draw a glyph: the Unicode mandatory breaks (UAX #14 +/// class BK: vertical tab, form feed, U+2028 LINE SEPARATOR, U+2029 PARAGRAPH SEPARATOR), the +/// next line (U+0085), the information separators (bidi class B) and CR/LF. Text pasted from a +/// browser or Word carries these, so a rewrite moves to the next row instead of asking a font +/// for an outline no face has (#6272). +pub(crate) fn row_break(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{001C}'..='\u{001E}' | '\u{2028}' | '\u{2029}') +} + /// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so 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/rewrite.rs b/crates/edit/src/text/rewrite.rs index 4816271e30..692c5e68de 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,7 @@ -use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; +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}; @@ -42,7 +42,7 @@ fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet< /// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result { - let text = text.replace(['\n', '\r'], " "); + let text = text.replace(row_break, " "); let lines = text_lines(doc, page)?; let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; target.ensure_editable()?; @@ -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)); @@ -240,7 +249,7 @@ fn choose_rewrite_path( } let visual_indic = members.iter().any(|line| line.visual_order); let has_special_text = text.is_some_and(|draft| { - draft.contains(['\n', '\r']) + draft.contains(row_break) || pdfcraft_fonts::source_shaping::required(draft) || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() }); @@ -259,14 +268,14 @@ fn choose_rewrite_path( } /// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed -/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are -/// breaks too; other white space is written as one space. +/// U+0020 space: a run of them is not folded into one (#4199, #3797). Every [`row_break`] is a break +/// too; other white space is written as one space. fn paragraph_text(text: &str) -> String { text.replace("\r\n", "\n") .chars() .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', + c if row_break(c) => '\n', + c if paragraph_separator(c) => ' ', c => c, }) .collect() @@ -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(()) } diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 98de481444..ab9a055429 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -3,9 +3,9 @@ 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, 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, + 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, }; #[path = "text_regions/grouping.rs"] diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 69b3a16850..1dcb9a9373 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -366,10 +366,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { if !block.rows.is_empty() { block.editor_text.push('\n'); } + // The run placed before this one in the row, tracked rather than searched for: rows + // don't share runs, so it is the last one this loop pushed. + let mut prev_in_row: Option<&TextLine> = None; for &index in row { let Some(line) = lines.get(index) else { continue }; // Some producers position separate words without encoding a space. - if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) + if let Some(prev) = prev_in_row && !row_text.ends_with(char::is_whitespace) && !line.text.starts_with(char::is_whitespace) && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 @@ -383,6 +386,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { row_text.push_str(&line.text); block.invisible &= line.origin.state.render_mode == 3; block.lines.push(index); + prev_in_row = Some(line); } if !block.text.is_empty() { if block.text.ends_with('-') { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e6d786e8b7..d1e0738a49 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -3,7 +3,7 @@ use super::grouping::{across, is_centred, row_rect}; 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, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, + needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, row_break, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs, }; @@ -181,8 +181,8 @@ impl CharacterPreparation<'_> { "the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists" )) })?; - if !pdfcraft_fonts::embedding::covers(face.bytes, span) { - let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); + if !covers_rows(face.bytes, span) { + let missing = span.chars().find(|&c| !row_break(c) && !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4]))); return Err(EditError::Invalid(match missing { Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)), None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family), @@ -251,12 +251,13 @@ impl CharacterPreparation<'_> { }; let start = range.start; for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + // A row break moves to the next row instead of asking a font for a glyph. The CR + // of a CRLF stays a `\r` the rows skip, so the pair is one break; every other break + // is written as `\n`. Either way it keeps its place among the draft's characters, + // which rows are counted in. + if row_break(ch) { + let crlf = ch == '\r' + && start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); continue; } @@ -923,7 +924,7 @@ fn legacy_thai_font( if !has_thai_source_face(cache, &family) { return Ok(None); } - let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty()); + let rows = || span.split(row_break).filter(|row| !row.is_empty()); for row in rows() { if let Err(error) = shape_existing_thai(cache, &source.font, row) { // Not in the face's existing codes. A complete program of the face that shapes every @@ -1097,7 +1098,7 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: } if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)) || !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face)) - || !pdfcraft_fonts::embedding::covers(face.bytes, span) + || !covers_rows(face.bytes, span) { return false; } @@ -1112,16 +1113,22 @@ fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: && pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| { pdfcraft_fonts::embedding::validate(&bytes).is_ok() && pdfcraft_fonts::source_shaping::has_thai_layout(&bytes) - && pdfcraft_fonts::embedding::covers(&bytes, span) - && span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) + && covers_rows(&bytes, span) + && span.split(row_break).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok()) }) }) } +/// Whether a complete program draws every row of `span`. [`row_break`] characters move to the next +/// row and are never drawn, so they need no glyph (#6272); `embedding::covers` exempts only CR and LF. +fn covers_rows(bytes: &[u8], span: &str) -> bool { + span.split(row_break).all(|row| pdfcraft_fonts::embedding::covers(bytes, row)) +} + /// Use the same Thai script selection and mark/cluster positioning as Add Text, including /// rows beginning with Latin or digits (guessing from their first character would select Latin). fn shape_thai_row(text: &str) -> Result { - if text.len() > 262_144 || text.contains(['\n', '\r']) { + if text.len() > 262_144 || text.contains(row_break) { return Err(EditError::Invalid("shape one bounded text row at a time".into())); } let clusters = 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/copy_image.rs b/crates/engine/src/copy_image.rs index 9d5668ccf5..6e8c2af8d4 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -2,7 +2,7 @@ //! file in its original format. A picture drawn through grouped //! Form artwork (as Word, InDesign and many scanners write them) is found inside the group. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object}; @@ -13,6 +13,31 @@ const MAX_COPY_PIXELS: u64 = 64_000_000; /// How deep Form artwork is searched for its picture. const MAX_FORM_DEPTH: usize = 12; +/// A picture on a page, with its index in [`Document::page_images`]. +pub type PagePicture = (usize, pdfcraft_edit::PageImage); + +/// The topmost of `pictures` under user-space point `at` (`[x, y]`). `pictures` is bottom first, +/// as [`Document::page_pictures_job`] lists them, so the last one that holds `at` wins. +pub fn picture_at(pictures: &[PagePicture], at: [f64; 2]) -> Option<&PagePicture> { + pictures.iter().rev().find(|(_, image)| image.contains(at)) +} + +/// The pictures on `page`, bottom first: raster images, and Form artwork that holds one. +fn pictures(cos: &Cos, page: usize) -> Vec { + let images = pdfcraft_edit::page_images(cos, page).unwrap_or_default(); + // Artwork drawn many times is searched once. + let mut holds_one: HashMap = HashMap::new(); + images + .into_iter() + .enumerate() + .filter(|(_, i)| match (i.is_form, i.object) { + (false, Some(_)) => true, + (true, Some(form)) => *holds_one.entry(form).or_insert_with(|| picture_in_form(cos, form).is_some()), + _ => false, + }) + .collect() +} + impl Document { /// The topmost picture on `page` under user-space point `at`, as its index in /// [`Document::page_images`]: a raster image, or Form artwork that holds one. @@ -28,6 +53,18 @@ impl Document { }) } + /// Work that lists the pictures on `page`, bottom first, for another thread: it reads every + /// drawing operator on the page, seconds on a dense one, so it must not run on the window + /// thread. Look a point up with [`picture_at`]. + /// + /// `None` when the document can't be read. The work reads the document as it is now: later + /// edits aren't seen (key its answer by [`Document::edit_generation`]). It returns no pictures + /// for a page that doesn't exist or can't be read. + pub fn page_pictures_job(&self, page: usize) -> Option Vec + Send + 'static> { + let cos = self.editor.as_ref()?.cos.clone(); + Some(move || crate::guard(|| pictures(&cos, page)).unwrap_or_default()) + } + /// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes): /// a JPEG as stored in the PDF, otherwise a lossless PNG. pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 22667377c3..728b9e3615 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -32,7 +32,8 @@ pub struct Document { pub dirty: bool, /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. pub(crate) generation: u64, - /// The generation last handed out by `autosave_snapshots`. + /// The generation last handed out by `autosave_snapshots`, or another one when storing that + /// snapshot failed (`autosave_failed`), so it is due again. pub(crate) snapshot_generation: u64, /// Why the document cannot be edited (e.g. encryption), if so. pub read_only_reason: Option, @@ -727,6 +728,14 @@ pub(crate) fn write_display_copy( } } +/// `doc` as the reader shows it: comments without an appearance stream drawn +/// (`pdfcraft_annot::with_missing_appearances`) and form fields drawn from their values where the +/// file leaves that to the viewer. `None` when `doc` needs neither. The vector print draws from +/// this copy too, so what prints is what the page shows (#371, #3475 draw them for display only). +pub(crate) fn as_displayed(doc: &pdfcraft_cos::Document) -> Option { + with_field_appearances(pdfcraft_annot::with_missing_appearances(doc), doc) +} + /// Carry the marks across a revalidation that leaves the display copy alone, when it may: only /// when every verdict is the one the copy was drawn with. Returns whether it did; otherwise the /// copy has to be drawn again. @@ -778,7 +787,7 @@ pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc (display, true), None if layered => (cos, false), None => return None, diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 1d4a799397..265104c657 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -561,8 +561,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). + // One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by + // the caret's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); pdfcraft_annot::add_text_replacement( doc, diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 6b204ec372..04d848eb8d 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -95,7 +95,11 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, + | Edit::RemoveMarks { .. } + // Recognized text is an invisible text layer stamped on its page, with its font in the + // page's resources (#6310): a full re-inspection made applying one word cost seconds on + // a large file. + | Edit::AddOcrText { .. } => Scope::Content, Edit::Batch { edits, .. } => { let mut scopes = edits.iter().map(scope_of); let first = scopes.next().unwrap_or(Scope::Full); diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index f581504204..dce233279c 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -26,29 +26,70 @@ pub struct JsOutput { pub doc_errors: Vec, } +/// Console lines one output keeps, from every script an edit runs together (a form's +/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise +/// be multiplied by the number of fields a document declares. +pub const MAX_CONSOLE_LINES: usize = 10_000; +/// Bytes of console text one output keeps, for the same reason. +pub const MAX_CONSOLE_BYTES: usize = 1 << 20; +/// Alerts one output keeps. +pub const MAX_ALERTS: usize = 1_000; + impl JsOutput { /// Whether scripts produced nothing: no alerts, console lines, requests or errors. pub fn is_empty(&self) -> bool { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } + /// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what + /// is past them is left out, which one closing line says. + fn say(&mut self, lines: impl IntoIterator) { + let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes"); + // Once the notice is written the console is closed: nothing may follow it, and a later + // rejection must not add a second copy. + if self.console.last() == Some(¬e) { + return; + } + let mut bytes: usize = self.console.iter().map(String::len).sum(); + let mut dropped = false; + for l in lines { + if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES { + dropped = true; + break; + } + bytes += l.len(); + self.console.push(l); + } + if dropped && !self.console.iter().any(|l| l == ¬e) { + self.console.push(note); + } + } + + /// Adds `alerts` within [`MAX_ALERTS`]. + fn alert(&mut self, alerts: impl IntoIterator) { + let room = MAX_ALERTS.saturating_sub(self.alerts.len()); + self.alerts.extend(alerts.into_iter().take(room)); + } + /// Adds what a field's own script produced: its alerts, console lines, errors and requests, /// except a request to show a side panel or open Save As, which is noted on the console /// and dropped. fn absorb(&mut self, o: &Outcome) { - self.alerts.extend(o.alerts.iter().cloned()); - self.console.extend(o.console.iter().cloned()); + self.alert(o.alerts.iter().cloned()); + self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a // button's may show or hide a side panel, or open Save As. + // The warnings go through `say` like any other console line, so they count against the + // line and byte caps instead of growing the console past them. + let mut notes = Vec::new(); for r in &o.requests { match r { - Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), - Request::SaveAs => { - self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into()) - } + Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), + Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), r => self.requests.push(r.clone()), } } + self.say(notes); self.errors.extend(o.error.iter().cloned()); // The document scripts run before every field script, so a failing one would repeat // once per calculation; it is reported once. @@ -61,8 +102,8 @@ impl JsOutput { } pub(crate) fn append(&mut self, other: JsOutput) { - self.alerts.extend(other.alerts); - self.console.extend(other.console); + self.alert(other.alerts); + self.say(other.console); self.requests.extend(other.requests); // As in `absorb`: a document script's error, already reported, is not repeated; a field // script's errors are each their own, even one worded like a document script's. @@ -118,6 +159,7 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState s.readonly = f.read_only(); s.required = f.has(pdfcraft_forms::flags::REQUIRED); s.display = display; + s.rect = f.widgets.first().map(|w| w.rect); s.char_limit = f.max_len; s.options = match f.kind { FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), @@ -147,8 +189,9 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState] read_only: (a.readonly != b.readonly).then_some(a.readonly), required: (a.required != b.required).then_some(a.required), display: (a.display != b.display).then_some(a.display), + rect: (a.rect != b.rect).then_some(a.rect).flatten(), }; - (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c) + (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c) }) .collect() } @@ -203,6 +246,7 @@ impl Scripts for JsRunner { self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { rc: true, value: value.to_string(), + value_set: false, changes: Vec::new(), message: None, }) @@ -239,6 +283,7 @@ impl JsRunner { Some(ScriptResult { rc: o.rc, value: o.value.clone(), + value_set: o.value_set, changes: changes(&states, &o.changed), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), }) @@ -330,11 +375,9 @@ impl Session { if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); } - for names in resets { - let all: Vec = fields.iter().map(|f| f.name.clone()).collect(); - let names = (!names.is_empty()) - .then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect()); - edits.push(Edit::ResetForm { names }); + if !resets.is_empty() { + let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) }); } if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; @@ -343,6 +386,21 @@ impl Session { } } +/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the +/// defaults, so resetting the union once does what each call in turn would, and recalculates +/// once. `None` is the whole form (a call with no names). A field is named by a selector that +/// is its name or the name of a field above it, looked up in a set of the selectors, so the +/// work is the fields times their depth, not the fields times the selectors, which a script +/// controls. +fn reset_targets(fields: &[&str], requests: &[Vec]) -> Option> { + if requests.iter().any(Vec::is_empty) { + return None; + } + let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect(); + let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p)); + Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect()) +} + /// A page's words for form-field detection, in user space (underscore runs split from the /// text around them). fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec { @@ -492,6 +550,47 @@ impl Session { mod review_tests { use super::*; + /// Many `resetForm` calls with many selectors over a big form: one reset of the union, found + /// by looking each field's name and parents up, not by comparing every field with every + /// selector. + #[test] + fn reset_requests_are_coalesced_and_matched_through_an_index() { + let fields: Vec = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect(); + let names: Vec<&str> = fields.iter().map(String::as_str).collect(); + let requests: Vec> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect(); + let started = std::time::Instant::now(); + assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field"); + let mut requests = requests; + requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]); + let hit = reset_targets(&names, &requests).unwrap(); + assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name"); + assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); + requests.push(Vec::new()); + assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); + } + + /// A form of many calculated fields, each printing its own cap of console lines, keeps a + /// bounded console: past the budget, lines are left out and one closing line says so. + #[test] + fn console_output_of_one_edit_is_bounded() { + let mut out = JsOutput::default(); + let line = "x".repeat(4_096); + let run = Outcome { console: vec![line; 1_000], alerts: vec![Message::plain("hi"); 100], ..Default::default() }; + for _ in 0..1_000 { + out.absorb(&run); + } + let bytes: usize = out.console.iter().map(String::len).sum(); + assert!(bytes <= MAX_CONSOLE_BYTES + 256, "{bytes} bytes kept"); + assert!(out.console.len() <= MAX_CONSOLE_LINES + 1, "{} lines kept", out.console.len()); + assert!(out.console.last().unwrap().starts_with("more console lines were left out")); + assert_eq!(out.console.iter().filter(|l| l.starts_with("more console lines")).count(), 1, "said once"); + assert_eq!(out.alerts.len(), MAX_ALERTS); + let mut merged = JsOutput::default(); + merged.append(out); + assert!(merged.console.len() <= MAX_CONSOLE_LINES + 1); + } + /// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with /// the word so far without reading the word again. #[test] diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs index f75967097c..b2fab68acb 100644 --- a/crates/engine/src/layers.rs +++ b/crates/engine/src/layers.rs @@ -6,10 +6,102 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool}; use crate::{Document, display_config, render_threads}; +/// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and +/// most category names and groups it reads in all of them together (entries can share one +/// array, so the count is over every entry, not each). +const MAX_AUTO_STATES: usize = 1024; +const MAX_AUTO_ITEMS: usize = 65_536; + +/// The usage dictionary entry and its state key that a `/Category` name of a usage application +/// dictionary reads when printing (ISO 32000-2 Table 101): Print, View and Export each name a +/// state of the group's own. Zoom, User and Language depend on the magnification, the user and +/// the system language, which printing doesn't fix, so they give no recommendation here. +fn print_category(name: &[u8]) -> Option<(&'static [u8], &'static [u8])> { + match name { + b"Print" => Some((b"Print", b"PrintState")), + b"View" => Some((b"View", b"ViewState")), + b"Export" => Some((b"Export", b"ExportState")), + _ => None, + } +} + +/// The layers as they print: `layers` as shown, then the default configuration's auto state for +/// the Print event (ISO 32000-2 §8.11.4.4, Table 101). Each usage application dictionary +/// (`/AS` entry) whose `/Event` is `/Print` recommends, for each group in its `/OCGs` and each of +/// its categories ([`print_category`]), the state the group's usage dictionary gives (for the +/// Print category, `/Usage /Print /PrintState`). A group with recommendations is on only if all +/// of them, in all the entries naming it, are ON; other groups keep their state. `/Category` is +/// an array; a single name is read as one. `None` when no `/AS` entry is for printing. +pub(crate) fn print_layer_states(cos: &pdfcraft_cos::Document, layers: &[Layer]) -> Option> { + use pdfcraft_cos::Object; + let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r))?; + let properties = root.as_dict()?.get(b"OCProperties").map(|p| cos.resolve(p))?; + let config = properties.as_dict()?.get(b"D").map(|d| cos.resolve(d))?; + let auto = config.as_dict()?.get(b"AS").map(|a| cos.resolve(a))?; + let mut printing = false; + let mut recommended: std::collections::HashMap<(u32, u16), bool> = std::collections::HashMap::new(); + let mut items_left = MAX_AUTO_ITEMS; + for entry in auto.as_array()?.iter().take(MAX_AUTO_STATES) { + let entry = cos.resolve(entry); + let Some(entry) = entry.as_dict() else { continue }; + if entry.get(b"Event").map(|e| cos.resolve(e)).as_deref().and_then(Object::as_name) != Some(b"Print") { + continue; + } + printing = true; + let categories = entry.get(b"Category").map(|c| cos.resolve(c)); + let mut read: Vec<(&[u8], &[u8])> = Vec::new(); + let mut consider = |name: Option<&[u8]>| { + if let Some(category) = name.and_then(print_category).filter(|c| !read.contains(c)) { + read.push(category); + } + }; + match categories.as_deref() { + Some(Object::Array(names)) => { + let count = names.len().min(items_left); + items_left -= count; + names.iter().take(count).for_each(|n| consider(cos.resolve(n).as_name())); + } + Some(Object::Name(n)) => consider(Some(n.as_slice())), + _ => {} + } + if read.is_empty() { + continue; + } + let Some(groups) = entry.get(b"OCGs").map(|g| cos.resolve(g)) else { continue }; + for group in groups.as_array().into_iter().flatten() { + let Some(left) = items_left.checked_sub(1) else { break }; + items_left = left; + let Some(id) = group.as_ref() else { continue }; + let ocg = cos.resolve(group); + let usage = ocg.as_dict().and_then(|g| g.get(b"Usage").map(|u| cos.resolve(u))); + for (entry_key, state_key) in &read { + // No state given (a Print category without `/PrintState`): no recommendation, so + // the group is left as it is. + let state = usage + .as_deref() + .and_then(|u| u.as_dict().and_then(|u| u.get(entry_key).map(|e| cos.resolve(e)))) + .and_then(|e| e.as_dict().and_then(|e| e.get(state_key).map(|s| cos.resolve(s)))) + .and_then(|s| match s.as_name() { + Some(b"ON") => Some(true), + Some(b"OFF") => Some(false), + _ => None, + }); + if let Some(on) = state { + let all_on = recommended.entry((id.num, id.generation)).or_insert(true); + *all_on = *all_on && on; + } + } + } + } + printing.then(|| layers.iter().map(|l| Layer { visible: recommended.get(&l.id).copied().unwrap_or(l.visible), ..l.clone() }).collect()) +} + /// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy -/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its -/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` -/// is left alone. +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. The +/// states written are final, so what would set them again goes: the auto state (`/AS`), and the +/// layers' own `/Usage`, which some readers apply when they draw (a `/ViewState /ON` turns a layer +/// that prints off back on). The configuration's other entries (`/Order`, `/RBGroups`…) are kept. +/// A document without `/OCProperties` is left alone. pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { use pdfcraft_cos::{Dict, ObjRef, Object}; let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; @@ -22,18 +114,35 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: config.set(b"BaseState".to_vec(), Object::name("ON")); config.set(b"ON".to_vec(), refs(true)); config.set(b"OFF".to_vec(), refs(false)); + config.remove(b"AS"); properties.set(b"D".to_vec(), Object::Dict(config)); root.set(b"OCProperties".to_vec(), Object::Dict(properties)); cos.set(root_ref, Object::Dict(root)); + for layer in layers { + let id = ObjRef::new(layer.id.0, layer.id.1); + let Some(mut group) = cos.get(id).as_dict().cloned() else { continue }; + if group.remove(b"Usage").is_some() { + cos.set(id, Object::Dict(group)); + } + } } /// Render `doc` with the layer visibility in its `info.layers`. pub(crate) fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); doc.config.layers = Arc::new(overrides); + doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } +/// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as +/// they print ([`print_layer_states`]), so the preview shows the page as it prints. `None` when +/// the document has no auto state for printing (or no structure to read it from). +pub(crate) fn print_preview_layers(cos: Option<&pdfcraft_cos::Document>, layers: &[Layer]) -> Option>> { + let printed = print_layer_states(cos?, layers)?; + Some(Arc::new(printed.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect())) +} + /// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the /// state the changes before it left. Returns whether any layer ended up changed. pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index baa06b02fd..9be9666590 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,6 +24,7 @@ pub mod commands; mod comments; pub mod compare; mod copy_image; +pub use copy_image::{PagePicture, picture_at}; pub mod dates; mod document; mod edit; @@ -86,7 +87,7 @@ pub use pdfcraft_organize::Standard; pub use pdfcraft_organize::check_custom_key; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; -pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_cos::{Algorithm, civil_from_days}; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::added::{ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, @@ -116,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, @@ -149,7 +150,7 @@ pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; pub use history::Label; -use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; +use layers::{apply_layer_state, print_layer_states, print_preview_layers, set_default_layer_state, use_layer_choices}; pub use open::{OpenContext, PreparedDocument}; pub use page_filter::{PageOrientation, PageParity, filter_pages}; pub use pictures::{MarkFile, image_dimensions}; @@ -160,7 +161,10 @@ pub use session::Session; pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; use sources::{open_source, open_source_with}; #[cfg(test)] -use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts}; +use test_hooks::{ + check_refresh_panic, panic_on_refresh, record_full_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, + secondary_scan_counts, take_full_refreshes, +}; use xfa_form::{ XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index cb31e3b550..512c1c97ad 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -971,10 +971,23 @@ impl Session { pub fn ocr_job(&self, id: DocId, pages: &[usize], settings: OcrSettings) -> Option { let doc = self.get(id)?; let all = doc.info.pages.len(); - let pages: Vec = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() }; + let mut seen = vec![false; all]; + let pages: Vec = if pages.is_empty() { + seen.fill(true); + (0..all).collect() + } else { + let mut deduped = Vec::new(); + for &p in pages { + if seen.get(p) == Some(&false) { + seen[p] = true; + deduped.push(p); + } + } + deduped + }; let native = (0..all) .map(|p| { - if !pages.contains(&p) { + if seen.get(p) != Some(&true) { return None; } doc.page_images(p) @@ -2234,6 +2247,59 @@ mod tests { assert_eq!(page, 0); } + /// A scan selected explicitly is rendered at its native scale, like the same page in an + /// all-pages job: selecting pages must not lose the native-resolution lookup and render + /// the scan above its own resolution at a higher chosen dpi. + #[test] + fn selected_scan_page_uses_native_scale_like_all_pages() { + let mut s = Session::new(); + let page = export::encode_png(2480, 3508, &vec![255; (2480 * 3508 * 4) as usize]).unwrap(); + let bytes = s.create_from_images(&[("a4.png".into(), page)]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let settings = OcrSettings { dpi: 300.0, ..Default::default() }; + let all = s.ocr_job(id, &[], settings.clone()).unwrap(); + let selected = s.ocr_job(id, &[0], settings).unwrap(); + assert_eq!(selected.pages, vec![0]); + let wanted = 300.0 / 72.0; + assert!(all.render_scale(0) < wanted, "native resolution limits the all-pages job: {}", all.render_scale(0)); + assert!( + (selected.render_scale(0) - all.render_scale(0)).abs() < 1e-4, + "selected page {} vs all-pages {}", + selected.render_scale(0), + all.render_scale(0) + ); + } + + /// A job requested with the same page twice carries each page once: a duplicate would + /// otherwise be recognized twice. + #[test] + fn ocr_job_deduplicates_requested_pages() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap(); + let job = s.ocr_job(id, &[2, 0, 2, 1, 0, 2], OcrSettings::default()).unwrap(); + assert_eq!(job.pages, vec![2, 0, 1], "each requested page appears once, in request order"); + } + + #[test] + fn ocr_job_bounds_page_vector_reservation_by_page_count() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(fixture(3)), None).unwrap(); + let requested = vec![999usize; 1024]; + let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap(); + assert!(job.pages.is_empty()); + assert!(job.pages.capacity() <= 3, "reservation {} exceeds the 3 pages that can survive deduplication", job.pages.capacity()); + } + + #[test] + fn ocr_job_does_not_reserve_capacity_for_invalid_pages() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(fixture(1024)), None).unwrap(); + let requested = vec![4096usize; 1024]; + let job = s.ocr_job(id, &requested, OcrSettings::default()).unwrap(); + assert!(job.pages.is_empty()); + assert_eq!(job.pages.capacity(), 0, "no valid pages were found, so no capacity should be reserved"); + } + /// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel /// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution /// letter page is still never shrunk below one pixel per point. diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 80a5ad30c7..a09acc9360 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -8,7 +8,8 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; use crate::history::{Editor, Keys, TextBlocksCache}; use crate::{ AppearanceCache, DocId, Document, Session, StatusCaptions, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, - info_fields, js, note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, + info_fields, js, note_warnings, print_preview_layers, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, + xfa_values_from_datasets, }; /// Immutable session state needed while a document is prepared away from its owning thread. @@ -125,11 +126,17 @@ impl Session { // pages and fields so the rest of the engine works on it. The original bytes stay: the // laid-out form is one appended revision. let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. + // Write `work` as one more revision and reopen it: what the document then is. The notes + // about damage repaired while reading the file stay with it (Document Properties), once. let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + let mut new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + for line in work.repair_log() { + if !new_cos.repair_log().contains(line) { + new_cos.note_repair(line.clone()); + } + } Ok((new_bytes, new_info, new_cos)) }; let (bytes, info, cos, xfa) = match (info.xfa, cos) { @@ -254,9 +261,6 @@ impl Session { }; match rebuilt { Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); (b, i, Ok(Ok(c))) } @@ -283,7 +287,7 @@ impl Session { /// The last step of opening: build the document record and register it. fn build_document(context: &OpenContext, data: DocumentOpenData<'_>) -> Document { let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let mut config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; @@ -318,6 +322,8 @@ impl Session { } } } + // The print preview draws the layers as they print. + config.print_layers = print_preview_layers(editor.as_ref().map(|e| &e.cos), &info.layers); let display = display_bytes(editor.as_mut(), &bytes, &mut signatures, &context.captions); let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config)); let (form, adopted) = editor diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 30a14c5695..1a741c0912 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -12,11 +12,12 @@ use pdfcraft_cos::{SaveOptions, write_full}; use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize}; /// Where an optimization is. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OptimizeStage { - /// Removing the user data chosen under Discard User Data. - #[default] + /// Removing the user data chosen under Discard User Data, if any was. Discarding, + /// Reading what the pages draw: `done` of `total` read. + Reading { done: usize, total: usize }, /// Merging the fragments of a page picture into one image. Fragments, /// About to process image `done` (0-based) of `total`. @@ -29,14 +30,21 @@ pub enum OptimizeStage { Writing, } +impl Default for OptimizeStage { + fn default() -> Self { + OptimizeStage::Reading { done: 0, total: 0 } + } +} + impl OptimizeStage { - /// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are - /// small fixed steps. + /// How far along the whole run is, from 0 to 1, weighed by where the time goes: reading pages + /// and pictures. The rest are small fixed steps. pub fn fraction(self) -> f32 { + let part = |done: usize, total: usize| if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }; match self { - OptimizeStage::Discarding => 0.0, - OptimizeStage::Fragments => 0.03, - OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }, + OptimizeStage::Discarding | OptimizeStage::Fragments => 0.0, + OptimizeStage::Reading { done, total } => 0.25 * if total == 0 { 0.0 } else { part(done, total) }, + OptimizeStage::Images { done, total } => 0.25 + 0.55 * part(done, total), OptimizeStage::CleaningUp => 0.8, OptimizeStage::Merging => 0.85, OptimizeStage::Writing => 0.9, @@ -91,10 +99,10 @@ impl OptimizeJob { pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; - step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { + step(OptimizeStage::Discarding)?; // Every ticked category, as `remove_hidden` decides between them: with form fields // ticked, discarding hidden text also takes the hidden text the flattened appearances // bring onto the page, which no scan before flattening counts. It refuses when its scan @@ -112,6 +120,7 @@ impl OptimizeJob { }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { + optimize::Stage::Reading { done, total } => OptimizeStage::Reading { done, total }, optimize::Stage::Fragments => OptimizeStage::Fragments, optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total }, optimize::Stage::CleanUp => OptimizeStage::CleaningUp, diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 4ed5895860..29df75b014 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -11,7 +11,7 @@ use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; use crate::export::encode_png; -use crate::{Document, guard}; +use crate::{Document, guard, print_layer_states}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one /// page per entry, the size of the source page as displayed, drawing what `content` prints. @@ -22,11 +22,16 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont let mut config = src.config.clone(); // The copy below holds only what prints, so the viewer's own comment hiding plays no part. config.hide_comments = false; - let printed = guard(|| -> Result, String> { + let (printed, layers) = guard(|| -> Result<(Vec, Option>), String> { let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; pdfcraft_print::keep_printed(&mut cos, content); - write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + // The layers as the vector print sets them: as shown, then the Print event's auto state. + let layers = print_layer_states(&cos, &doc.info.layers); + Ok((write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())?, layers)) })??; + if let Some(layers) = layers { + config.layers = std::sync::Arc::new(layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect()); + } let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e5029ccbd6..fe2d62aeb3 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -14,8 +14,8 @@ use crate::{ LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, - parse_ymd, print, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, - sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, + parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, + source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, }; mod editing; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 753f8dd0bd..ddbd6d1014 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -6,7 +6,7 @@ use super::{ inspect, js, keys_after, note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, write_incremental, xfa, xfa_sync_datasets, }; -use crate::{EditNotice, MarkKind}; +use crate::{EditNotice, MarkKind, print_preview_layers}; /// Whether the edit, or any edit inside a batch, deletes pages. fn deletes_pages(edit: &Edit) -> bool { @@ -515,6 +515,8 @@ impl Session { } fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + #[cfg(test)] + crate::record_full_refresh(); // A full refresh may follow structural or annotation changes: cached appearances // may be stale, so they are rebuilt below. if let Some(editor) = doc.editor.as_mut() { @@ -545,6 +547,7 @@ impl Session { } } doc.info = info; + doc.config.print_layers = print_preview_layers(Some(&editor.cos), &doc.info.layers); doc.fonts_stale = false; let mut form = pdfcraft_forms::fields(&editor.cos); xfa::mark_script_buttons(&editor.cos, &mut form); diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index f4e25bc2f6..61845be75c 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,7 +1,7 @@ use super::{ Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, - Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, - source_kind, writable, write_full, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster, + set_default_layer_state, source_kind, writable, write_full, }; impl Session { @@ -164,10 +164,21 @@ impl Session { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // Comments and fields without an appearance print as the page shows them (and as Print + // as Image already printed them): the vector print drew only appearance streams, so a + // text box without one was on screen but not on paper. ISO 32000-2 §12.5.3: an + // annotation with the Print flag is printed. + if let Some(displayed) = guard(|| crate::document::as_displayed(&cos)).ok().flatten() { + cos = displayed; + } // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer // action) has changed one, the print copy's default configuration says which are on, so a // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { + // On top of either, the configuration's auto state for the Print event sets the layers it + // names (`print_layer_states`), as printing does in a viewer (ISO 32000-2 §8.11.4.4). + if let Some(printed) = print_layer_states(&cos, &doc.info.layers) { + set_default_layer_state(&mut cos, &printed); + } else if !doc.config.layers.is_empty() { set_default_layer_state(&mut cos, &doc.info.layers); } // Signature states as the screen shows them, so not when its display copy could not be diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 34f4d4405d..a727ce6329 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -217,6 +217,15 @@ impl Session { out } + /// The snapshot [`Self::autosave_snapshots`] handed out for `id` could not be stored: the + /// document is due again, so the next autosave tries it again. + pub fn autosave_failed(&mut self, id: DocId) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + // Any generation but the current one leaves it due. + d.snapshot_generation = d.generation.wrapping_sub(1); + } + } + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at /// `path` (where Save writes), as when the session ended. pub fn mark_recovered(&mut self, id: DocId, path: Option) { diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 5ef135d6c8..711779903a 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -120,17 +120,7 @@ impl Session { let rects: Vec<[f64; 4]> = text .line_rects(glyphs) .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) + .filter_map(|v| line_user_rect(&doc.info.pages[page], v)) .filter(|r| { !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) }) @@ -313,3 +303,112 @@ impl Session { &self.docs } } + +/// Convert a view-space text line rectangle into a normalized, finite PDF user-space rectangle. +fn line_user_rect(page: &pdfcraft_render::PageInfo, v: [f32; 4]) -> Option<[f64; 4]> { + if !v.iter().all(|x| x.is_finite()) { + return None; + } + let r = page.view_rect_to_user(v).map(f64::from); + r.iter().all(|x| x.is_finite()).then_some(r) +} + +#[cfg(test)] +mod tests { + use super::line_user_rect; + use crate::{Edit, Session}; + use pdfcraft_render::PageInfo; + use std::sync::Arc; + + /// Build a sample `PageInfo` with a non-origin crop box `[10.0, 20.0, 210.0, 320.0]` (`200 × 300`). + fn sample_page(rotation: u16) -> PageInfo { + let (width, height) = if rotation % 180 == 90 { (300.0, 200.0) } else { (200.0, 300.0) }; + PageInfo { width, height, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation } + } + + #[test] + fn line_user_rect_rejects_non_finite_coordinates() { + let page = sample_page(0); + assert_eq!(line_user_rect(&page, [f32::NAN; 4]), None); + assert_eq!(line_user_rect(&page, [f32::NAN, 10.0, 50.0, 20.0]), None); + assert_eq!(line_user_rect(&page, [10.0, 20.0, f32::INFINITY, 30.0]), None); + assert_eq!(line_user_rect(&page, [10.0, f32::NEG_INFINITY, 50.0, 30.0]), None); + + for bad_crop_box in [[f32::NAN, 0.0, 200.0, 300.0], [0.0, f32::INFINITY, 200.0, 300.0], [0.0, 0.0, f32::NEG_INFINITY, 300.0]] { + let mut bad_crop = sample_page(0); + bad_crop.crop = bad_crop_box; + assert_eq!(line_user_rect(&bad_crop, [10.0, 20.0, 50.0, 40.0]), None); + } + + // Finite inputs whose crop span overflows f32 during view-to-user mapping must also be rejected. + let overflow_page = PageInfo { width: 1.0, height: 1.0, label: String::new(), crop: [-f32::MAX, 0.0, f32::MAX, 300.0], rotation: 0 }; + assert_eq!(line_user_rect(&overflow_page, [1.0, 10.0, 2.0, 20.0]), None); + } + + #[test] + fn line_user_rect_matches_view_rect_to_user_across_rotations() { + let v = [12.0, 24.0, 96.0, 48.0]; + let cases = [ + (0, [22.0, 272.0, 106.0, 296.0]), + (90, [34.0, 32.0, 58.0, 116.0]), + (180, [114.0, 44.0, 198.0, 68.0]), + (270, [162.0, 224.0, 186.0, 308.0]), + ]; + for (rotation, expected) in cases { + let page = sample_page(rotation); + let got = line_user_rect(&page, v).expect("finite view rect maps to user space"); + for (g, e) in got.into_iter().zip(expected) { + assert!((g - e).abs() < 1e-4, "rotation {rotation}: got {got:?}, expected {expected:?}"); + } + } + } + + #[test] + fn find_urls_returns_finite_user_rects_on_rotated_page() { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(), + ]; + let body = "BT /F1 12 Tf 20 150 Td (Visit https://example.com today) Tj ET"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + let mut session = Session::new(); + let id = session.open("urls.pdf", None, Arc::new(pdf), None).expect("opens"); + let expected_rect = [46.004, 147.6, 153.38, 159.6]; + let base_urls = session.find_urls(id); + assert_eq!(base_urls.len(), 1); + let base_rect = base_urls[0].1[0]; + for (got, exp) in base_rect.into_iter().zip(expected_rect) { + assert!((got - exp).abs() < 1e-2, "0 deg: got {base_rect:?}, expected {expected_rect:?}"); + } + + for step in [90, 180, 270] { + session.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotates"); + let urls = session.find_urls(id); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].0, 0); + assert_eq!(urls[0].2, "https://example.com"); + assert_eq!(urls[0].1.len(), 1); + let r = urls[0].1[0]; + for (got, exp) in r.into_iter().zip(expected_rect) { + assert!((got - exp).abs() < 1e-2, "rotation {step} deg: got {r:?}, expected {expected_rect:?}"); + } + } + } +} diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs index d6e504599e..1f6bd94822 100644 --- a/crates/engine/src/test_hooks.rs +++ b/crates/engine/src/test_hooks.rs @@ -40,3 +40,17 @@ pub(crate) fn record_link_scan() { counts.set((markers, links + 1)); }); } + +thread_local! { + static FULL_REFRESHES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A whole-document refresh (re-inspection) ran on this thread. +pub(crate) fn record_full_refresh() { + FULL_REFRESHES.with(|n| n.set(n.get() + 1)); +} + +/// How many whole-document refreshes ran on this thread since the last call, and starts over. +pub(crate) fn take_full_refreshes() -> usize { + FULL_REFRESHES.with(|n| n.replace(0)) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 666f9a3373..ed9640b79d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1779,6 +1779,45 @@ fn low_resolution_printing_prints_only_the_chosen_content() { } } +/// A comment and a field the file leaves without an appearance print as the page shows them: +/// a text box (`/FreeText` with only `/Contents` and `/DA`) and a check box set on with +/// `/NeedAppearances`. The vector print drew only appearance streams, so both were on screen and +/// in Print as Image but not on paper (pdf.js `bug1865341`, `freetext_print_no_appearance`). +/// ISO 32000-2 §12.5.3: an annotation with the Print flag is printed. +#[test] +fn comments_and_fields_without_an_appearance_print_as_shown() { + let content = ""; + let pdf = format!( + "%PDF-1.7 + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /NeedAppearances true /DA (/Helv 0 Tf 0 g) >> >> endobj + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Annots [5 0 R 6 0 R] >> endobj + 4 0 obj << /Length {} >> stream +{content} +endstream endobj + 5 0 obj << /Type /Annot /Subtype /FreeText /F 4 /P 3 0 R /Rect [10 120 190 190] /DA (/Helv 40 Tf 0 g) /Contents (WWWW) >> endobj + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (Box) /V /Yes /AS /Yes /F 4 /P 3 0 R /Rect [10 10 110 110] /MK << /CA (8) >> >> endobj + trailer << /Root 1 0 R >> +%%EOF", + content.len(), + ); + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap(); + let settings = + print::Settings { pages: vec![0], paper: (200.0, 200.0), layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (200, 200)); + // Dark pixels in the text box (top half) and in the check box (bottom half). + let dark = |y0: usize, y1: usize| { + (y0..y1) + .flat_map(|y| (0..w as usize).map(move |x| (x, y))) + .filter(|&(x, y)| rgba.get((y * w as usize + x) * 4).is_some_and(|&r| r < 128)) + .count() + }; + assert!(dark(10, 80) > 50, "the text box prints"); + assert!(dark(90, 190) > 50, "the check box prints"); +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, @@ -3120,6 +3159,66 @@ fn setting_the_calculation_order_recalculates_and_undoes() { assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_)))); } +/// A Calculate script that sets its own field through the field object, not `event.value`, +/// gives the field that value, text included; when it sets `event.value` as well, that wins. +#[test] +fn a_calculate_script_setting_its_own_field_object_keeps_the_value() { + let (mut s, id) = session_with(1); + for (i, name) in ["origin", "copy", "both"].into_iter().enumerate() { + let y = 250.0 - 40.0 * i as f64; + let kind = NewField::Text { multiline: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap(); + } + let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) }; + s.apply(id, script("copy", "var o = this.getField(\"origin\");\nthis.getField(\"copy\").value = o.value;")).unwrap(); + s.apply(id, script("both", "this.getField(\"both\").value = \"field\";\nevent.value = this.getField(\"origin\").value + \"!\";")).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("abc".into()) }).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()); + assert_eq!(value(&s, "copy").as_deref(), Some("abc")); + assert_eq!(value(&s, "both").as_deref(), Some("abc!"), "event.value decides when the script sets both"); + s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("12".into()) }).unwrap(); + assert_eq!(value(&s, "copy").as_deref(), Some("12")); +} + +/// A Calculate script that assigns `event.value` the value its field already holds keeps it, even +/// after writing another value through its field object; and a combo box a script moves to +/// another option selects that option in `/I` too. +#[test] +fn a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection() { + let (mut s, id) = session_with(1); + s.apply( + id, + Edit::AddField { page: 0, rect: [20.0, 250.0, 180.0, 272.0], kind: NewField::Text { multiline: false }, name: Some("origin".into()) }, + ) + .unwrap(); + s.apply(id, Edit::AddField { page: 0, rect: [20.0, 210.0, 180.0, 232.0], kind: NewField::Text { multiline: false }, name: Some("kept".into()) }) + .unwrap(); + let kind = NewField::Combo { options: vec!["Small".into(), "Large".into()], editable: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, 170.0, 180.0, 192.0], kind, name: Some("size".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "size".into(), value: FieldValue::Choice(vec!["Small".into()]) }).unwrap(); + let script = |name: &str, js: &str| Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(js.into()) }; + s.apply( + id, + script( + "kept", + "var v = event.value; +this.getField(\"kept\").value = \"field\"; +event.value = v;", + ), + ) + .unwrap(); + s.apply(id, script("size", "event.value = this.getField(\"origin\").value > 5 ? \"Large\" : \"Small\";")).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "origin".into(), value: FieldValue::Text("9".into()) }).unwrap(); + assert!(s.take_js_output(id).errors.is_empty(), "both scripts ran to the end"); + let doc = s.get(id).unwrap(); + let value = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()); + assert_eq!(value("kept").as_deref(), None, "event.value, set to the field's own value, decides"); + assert_eq!(value("size").as_deref(), Some("Large")); + let size = doc.form.iter().find(|f| f.name == "size").map(|f| f.obj).unwrap(); + let selected = doc.editor.as_ref().unwrap().cos.get(size).as_dict().and_then(|d| d.get(b"I")).cloned(); + assert_eq!(selected, Some(pdfcraft_cos::Object::Array(vec![pdfcraft_cos::Object::Int(1)]))); +} + /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { @@ -3441,6 +3540,185 @@ fn printing_keeps_hidden_layers_hidden() { assert!(red > 100, "shown again, Layer A prints again: {red} red"); } +/// A PDF of `objs`, numbered from 1, the first being the catalog. +fn pdf_of_objects(objs: &[Vec]) -> Vec { + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// ISO 32000-2 §8.11.4.4: the default configuration's auto state for the Print event sets the +/// layers it names when the document prints. Red is on screen but `/PrintState /OFF`, green is off +/// on screen but `/PrintState /ON`: on paper, vector or as an image, green prints and red doesn't. +/// Two blue squares are on screen and `/PrintState /OFF` too, but no Print recommendation reaches +/// them (one is named only in the View entry, the other in a Print entry without the Print +/// category), so they print as shown. +#[test] +fn printing_applies_the_print_auto_state() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC \ + /OC /C BDC 0 0 1 rg 210 10 40 40 re f EMC /OC /D BDC 0 0 1 rg 210 55 40 40 re f EMC"; + let screen_only = b"/Usage << /View << /ViewState /ON >> /Print << /PrintState /OFF >> >> >>"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R 8 0 R] /D << /OFF [6 0 R] \ + /AS [<< /Event /Print /OCGs [5 0 R 6 0 R] /Category [/Print] >> << /Event /View /OCGs [5 0 R 6 0 R 7 0 R] /Category [/View] >> \ + << /Event /Print /OCGs [8 0 R] /Category [/Language] >>] >> >> >>" + .to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R \ + /Resources << /Properties << /A 5 0 R /B 6 0 R /C 7 0 R /D 8 0 R >> >> >>" + .to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + [b"<< /Type /OCG /Name (View only) ".as_slice(), screen_only].concat(), + b"<< /Type /OCG /Name (Print only) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>".to_vec(), + [b"<< /Type /OCG /Name (View entry) ".as_slice(), screen_only].concat(), + [b"<< /Type /OCG /Name (Other category) ".as_slice(), screen_only].concat(), + ]; + let pdf = pdf_of_objects(&objs); + + let colours = |page: &pdfcraft_render::RenderedPage| { + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + let blue = px.iter().filter(|p| p[2] > 200 && p[0] < 60 && p[1] < 60).count(); + (red, green, blue) + }; + let mut s = Session::new(); + let id = s.open("auto-state.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green, shown_blue) = colours(&render_page(&s, id)); + assert!(red > 100 && green == 0, "on screen, red shows and green doesn't: {red} red, {green} green"); + // The print dialog's preview shows the page as it prints, the view as it shows; so after a + // Layers panel change too. + let preview = |s: &Session, tag: u64| { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), display_config(&doc.config)); + colours(&renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag, ..Default::default() })) + }; + let view_entry = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "View entry").unwrap(); + let mut printed_blue = Vec::new(); + for _ in 0..2 { + let (red, green, blue) = preview(&s, pdfcraft_render::PRINT_TAG); + assert!(red == 0 && green > 100, "the preview shows the print: {red} red, {green} green"); + printed_blue.push(blue); + let (red, green, _) = preview(&s, 0); + assert!(red > 100 && green == 0, "the view is unchanged: {red} red, {green} green"); + // Hidden after the first round, shown again after the second. + assert!(s.set_layer_visible(id, view_entry, printed_blue.len() == 2)); + } + // Hidden in the panel, View entry (no Print recommendation) leaves the preview too. + assert!(printed_blue[1] * 4 < printed_blue[0] * 3, "the preview follows the panel: {printed_blue:?} blue"); + for raster in [None, Some(print::Raster { dpi: 72.0, grayscale: false })] { + let bytes = Arc::new(s.print_pdf(id, &print::Settings { pages: vec![0], raster, ..Default::default() }).unwrap()); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, bytes.clone(), None).unwrap(); + let (red, green, blue) = colours(&render_page(&out, printed)); + assert_eq!(red, 0, "the view-only layer doesn't print (as image: {})", raster.is_some()); + assert!(green > 100, "the print-only layer prints (as image: {}): {green} green", raster.is_some()); + // Both blue squares print: the one shown on screen alone is half of them. + assert!( + blue * 4 > shown_blue * 3, + "the layers no Print recommendation reaches print as shown (as image: {}): {blue} of {shown_blue} blue", + raster.is_some() + ); + // The vector copy's states are final: nothing a reader applies when drawing is left to + // turn them back (the auto state, or a layer's `/Usage /View /ViewState`). + let layers = &out.get(printed).unwrap().info.layers; + if raster.is_none() { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + assert_eq!(layers.len(), 4); + for layer in layers { + let group = cos.get(pdfcraft_cos::ObjRef::new(layer.id.0, layer.id.1)); + assert!(group.as_dict().is_some_and(|g| !g.contains(b"Usage")), "{} keeps its /Usage", layer.name); + } + let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r)).unwrap(); + let properties = root.as_dict().and_then(|r| r.get(b"OCProperties")).map(|p| cos.resolve(p)).unwrap(); + let config = properties.as_dict().and_then(|p| p.get(b"D")).map(|d| cos.resolve(d)).unwrap(); + assert!(config.as_dict().is_some_and(|d| !d.contains(b"AS")), "the print copy keeps /AS"); + } + } +} + +/// The print auto state of `layers` (object numbers from 5) in a document whose default +/// configuration has `auto` as its `/AS` and `groups` as the layers' dictionaries. +fn print_states_of(auto: &str, groups: &[&str]) -> Vec { + let ocgs: String = (0..groups.len()).map(|i| format!("{} 0 R ", i + 5)).collect(); + let mut objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [{ocgs}] /D << /AS {auto} >> >> >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_vec(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + ]; + objs.extend(groups.iter().map(|g| g.as_bytes().to_vec())); + let cos = pdfcraft_cos::Document::open(Arc::new(pdf_of_objects(&objs))).unwrap(); + let layers: Vec = (0..groups.len()).map(|i| Layer { id: (i as u32 + 5, 0), name: format!("{i}"), visible: true, initial: true }).collect(); + crate::print_layer_states(&cos, &layers).expect("there are Print entries").iter().map(|l| l.visible).collect() +} + +/// ISO 32000-2 §8.11.4.4: a group prints only if every category of every Print entry naming it +/// recommends ON. A Print category (`/PrintState /ON`) and a View category of another Print entry +/// (`/ViewState /OFF`) disagree, so the group stays off; with both ON it prints. A `/Category` +/// written as a single name (Table 101 asks for an array) is read as one. +#[test] +fn a_layer_prints_only_if_every_recommendation_is_on() { + let auto = "[<< /Event /Print /OCGs [5 0 R 6 0 R] /Category [/Print] >> << /Event /Print /OCGs [5 0 R 6 0 R] /Category [/View] >> \ + << /Event /Print /OCGs [7 0 R] /Category /Print >>]"; + let states = print_states_of( + auto, + &[ + "<< /Type /OCG /Name (Disagree) /Usage << /View << /ViewState /OFF >> /Print << /PrintState /ON >> >> >>", + "<< /Type /OCG /Name (Agree) /Usage << /View << /ViewState /ON >> /Print << /PrintState /ON >> >> >>", + "<< /Type /OCG /Name (Single name) /Usage << /Print << /PrintState /OFF >> >> >>", + ], + ); + assert_eq!(states, [false, true, false]); +} + +/// At most 1,024 `/AS` entries are read: an entry past them that would turn the layer off is not +/// reached, while the same entry within them is. +#[test] +fn the_print_auto_state_reads_a_bounded_number_of_entries() { + let layer = "<< /Type /OCG /Name (Layer) /Usage << /Print << /PrintState /OFF >> >> >>"; + let other = "<< /Type /OCG /Name (Other) >>"; + let auto = |before: usize| { + let filler = "<< /Event /Print /OCGs [6 0 R] /Category [/Print] >> ".repeat(before); + format!("[{filler} << /Event /Print /OCGs [5 0 R] /Category [/Print] >>]") + }; + assert_eq!(print_states_of(&auto(1023), &[layer, other]), [false, true], "the 1,024th entry is read"); + assert_eq!(print_states_of(&auto(1024), &[layer, other]), [true, true], "the 1,025th entry is not"); +} + +/// Reading the print auto state is bounded over all `/AS` entries together: 1,024 Print entries +/// sharing one `/Category` array of 70,000 names, `/Print` last, read 65,536 names in all, find no +/// Print category and recommend nothing, instead of reading the array 1,024 times. +#[test] +fn the_print_auto_state_reads_a_bounded_number_of_categories() { + let names = format!("[{} /Print]", "/View ".repeat(70_000)); + let entries = "<< /Event /Print /OCGs [5 0 R] /Category 6 0 R >> ".repeat(1024); + let objs: Vec> = vec![ + format!("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /AS [{entries}] >> >> >>").into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_vec(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + b"<< /Type /OCG /Name (Layer) /Usage << /Print << /PrintState /OFF >> >> >>".to_vec(), + names.into_bytes(), + ]; + let cos = pdfcraft_cos::Document::open(Arc::new(pdf_of_objects(&objs))).unwrap(); + let layer = Layer { id: (5, 0), name: "Layer".into(), visible: true, initial: true }; + let printed = crate::print_layer_states(&cos, std::slice::from_ref(&layer)).expect("there are Print entries"); + assert!(printed.iter().all(|l| l.visible), "past the bound nothing is read, so the layer prints as shown"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); @@ -3904,7 +4182,9 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { }) .unwrap(); assert!(bytes.starts_with(b"%PDF-")); - assert_eq!(stages.first(), Some(&OptimizeStage::Discarding)); + assert_eq!(stages.first(), Some(&OptimizeStage::Reading { done: 0, total: 2 }), "it says what it does first"); + assert!(!stages.contains(&OptimizeStage::Discarding), "nothing was chosen to discard: {stages:?}"); + assert!(stages.iter().any(|st| matches!(st, OptimizeStage::Images { .. })), "{stages:?}"); assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]); let fractions: Vec = stages.iter().map(|st| st.fraction()).collect(); assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}"); @@ -3915,6 +4195,20 @@ fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { "no images: no division by zero" ); + // What the bar shows before anything is reported says nothing of user data either. + assert_eq!(OptimizeStage::default(), OptimizeStage::Reading { done: 0, total: 0 }); + assert_eq!(OptimizeStage::default().fraction(), 0.0); + assert!(OptimizeStage::Reading { done: 2, total: 4 }.fraction() > 0.0, "reading pages moves the bar"); + + // Discarding says so when something is. This document has no metadata, which only stops it after + // the stage is reported. + let mut with_discard = Vec::new(); + let _ = s.optimize_job(id, &settings, &[crate::Hidden::Metadata]).unwrap().run(|st| { + with_discard.push(st); + true + }); + assert_eq!(with_discard.first(), Some(&OptimizeStage::Discarding)); + // Cancelled at the merge: nothing is written, the open document is untouched. let before = s.get(id).unwrap().bytes.clone(); let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging); @@ -4593,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. @@ -4634,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"); @@ -5016,6 +5333,7 @@ fn sign_prepared_with( v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true })); v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); value(&mut v); + let certifies = v.get(b"Reference").is_some(); let sig = doc.add(Object::Dict(v)); // The field by name, never by number: a full save (encryption, repairs) is // free to renumber objects, and a hardcoded reference would stamp the value @@ -5025,6 +5343,12 @@ fn sign_prepared_with( let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields"); let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field"); doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap(); + if certifies { + // A certification is the signature the catalog's /Perms /DocMDP names (ISO 32000-2, 12.8.2.2). + let mut perms = Dict::new(); + perms.set(b"DocMDP".to_vec(), Object::Ref(sig)); + doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms))).unwrap(); + } let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() }; let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap(); let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]); @@ -6080,6 +6404,20 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn javascript_field_rect_moves_first_widget_and_undoes() { + let mut s = Session::new(); + let id = s.open("rect.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let initial = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect; + s.run_javascript(id, "getField('price').rect = [30, 40, 130, 60];", Some("go")).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, initial); + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().widgets[0].rect, [30.0, 40.0, 130.0, 60.0]); +} + /// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before /// each of their scripts, and alert when either is missing. #[test] @@ -9011,6 +9349,46 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { assert_eq!(Session::content_refresh_lists(None), (true, true), "undo/redo re-read both"); } +/// Recognized text is an invisible text layer on its page, so applying it refreshes like any +/// other content edit, not by re-inspecting the whole document (#6310): on a 567-page file with +/// 60,102 font resources one word took 2.4 s, the full refresh nearly all of it. The page tree, +/// comments, fields, links and marks stay as they were; the words are on their page, the font +/// list picks up the OCR font on `refresh_fonts`, undo and redo take the words out and back +/// without a full refresh either, and a save keeps them. +#[test] +fn ocr_words_refresh_only_the_page_content_they_add() { + use crate::ocr::{OcrPage, PlacedWord}; + let (mut s, id) = session_with(3); + let words_on = |s: &Session, page: usize| s.get(id).unwrap().text_lines(page).iter().map(|l| l.text.trim().to_string()).collect::>(); + let before = s.get(id).unwrap(); + let view = format!("{:?}", (&before.info.pages, &before.info.annotations, &before.info.fields, &before.links, before.marks())); + let others = (words_on(&s, 0), words_on(&s, 2)); + assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO"))); + take_full_refreshes(); + let word = + PlacedWord { text: "HELLO".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0], confidence: None, source: "ocrs".into() }; + assert_eq!(s.apply_ocr(id, &[OcrPage { page: 1, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() }]).unwrap(), 1); + assert_eq!(take_full_refreshes(), 0, "applying OCR words doesn't re-inspect the document"); + let after = s.get(id).unwrap(); + assert_eq!(format!("{:?}", (&after.info.pages, &after.info.annotations, &after.info.fields, &after.links, after.marks())), view); + assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "{:?}", words_on(&s, 1)); + assert_eq!((words_on(&s, 0), words_on(&s, 2)), others, "the other pages are untouched"); + s.refresh_fonts(id); + let d = s.get(id).unwrap(); + let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap(); + assert_eq!(format!("{:?}", d.info.fonts), format!("{:?}", full.fonts), "the font list is what a full inspection reads"); + assert_eq!(d.info.file_size, full.file_size); + s.undo(id).unwrap(); + assert!(!words_on(&s, 1).iter().any(|w| w.contains("HELLO")), "undo takes the words out"); + s.redo(id).unwrap(); + assert!(words_on(&s, 1).iter().any(|w| w == "HELLO"), "redo puts them back"); + assert_eq!(take_full_refreshes(), 0, "undo and redo refresh the content alone too"); + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let rid = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert!(reopened.get(rid).unwrap().text_lines(1).iter().any(|l| l.text.trim() == "HELLO"), "the save keeps the words"); +} + /// A content edit can add a font (Add text in Times); the narrow refresh leaves the font list /// as it was, and `refresh_fonts` reads it again before Properties ▸ Fonts or `doc_info` show it. #[test] @@ -10301,6 +10679,159 @@ fn a_date_its_validate_script_refuses_is_not_tried_again() { assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name")); } +/// Floating fields (XFA 3.3 p. 1222): text that embeds a hidden field shows the value its +/// initialize script gives it, the value a button's script gives it later and the value a +/// calculation gives one, as it does the sheet number and count; the fields sit in an unnamed +/// subform, as forms place them (#6160). +#[test] +fn text_shows_the_values_scripts_give_its_floating_fields() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("float.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let text = page_texts(&s, id).join( + " +", + ); + assert!(text.contains("Viewer: Opened"), "{text}"); + assert!(text.contains("Sheet 1 of 1"), "{text}"); + assert!(text.contains("Calculated: Opened!"), "{text}"); + let o = s.run_javascript(id, "", Some("set")).expect("clicks"); + assert_eq!(o.error, None, "{o:?}"); + let text = page_texts(&s, id).join( + " +", + ); + assert!(text.contains("Viewer: Clicked"), "{text}"); + assert!(text.contains("Calculated: Clicked!"), "{text}"); +} + +/// Text that embeds a field follows its data whoever writes it (XFA 3.3 pp. 221–222): a +/// script writing another field bound to the same data, a script writing a page area's field, +/// a typed value in a plain one-line field, a pick in a drop-down list, and a reset (of a +/// drop-down list, and of a page area's field that has no widget). +#[test] +fn floating_fields_follow_their_data_whoever_writes_it() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("follow.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let text = |s: &Session| { + page_texts(s, id).join( + " +", + ) + }; + let click = |s: &mut Session, button: &str| { + let o = s.run_javascript(id, "", Some(button)).expect("clicks"); + assert_eq!(o.error, None, "{button}: {o:?}"); + }; + assert!(text(&s).contains("Edit: start"), "{}", text(&s)); + click(&mut s, "share"); + assert!(text(&s).contains("Note: Shared"), "the field bound to the same data: {}", text(&s)); + click(&mut s, "stamp"); + assert!(text(&s).contains("Stamp: Paid"), "a page area's field: {}", text(&s)); + let edit = s.get(id).unwrap().form.iter().find(|f| f.name.ends_with("Edit")).map(|f| f.name.clone()).expect("the Edit field"); + s.apply(id, Edit::SetFieldValue { name: edit, value: FieldValue::Text("typed".into()) }).unwrap(); + assert!(text(&s).contains("Edit: typed"), "a typed value in a one-line field: {}", text(&s)); + click(&mut s, "pickB"); + assert!(text(&s).contains("Pick: B"), "{}", text(&s)); + click(&mut s, "clearPick"); + assert!(text(&s).contains("Pick: A"), "a reset drop-down list: {}", text(&s)); + let pick = s.get(id).unwrap().form.iter().find(|f| f.name.ends_with("Pick")).map(|f| f.name.clone()).expect("the Pick field"); + s.apply(id, Edit::SetFieldValue { name: pick, value: FieldValue::Choice(vec!["B".into()]) }).unwrap(); + assert!(text(&s).contains("Pick: B"), "a picked value: {}", text(&s)); + click(&mut s, "pickB"); + click(&mut s, "clear"); + let after = text(&s); + assert!(after.contains("Edit: start") && !after.contains("Shared"), "reset: {after}"); + assert!(after.contains("Pick: A"), "a reset drop-down list: {after}"); + assert!(after.contains("Stamp:") && !after.contains("Paid"), "a reset page-area field with no widget: {after}"); +} + +/// A reset gives a hidden floating field its default in every row of a repeating subform, not +/// only the first, and each row's text shows it. +#[test] +fn a_reset_clears_a_floating_field_in_every_row() { + let xdp = r##" + + +
1020
+
+"##; + let mut s = Session::new(); + let id = s.open("rows.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let text = page_texts(&s, id).join( + " +", + ); + assert!(text.contains("Amount: 10") && text.contains("Amount: 20"), "{text}"); + let o = s.run_javascript(id, "", Some("clear")).expect("clicks"); + assert_eq!(o.error, None, "{o:?}"); + let text = page_texts(&s, id).join( + " +", + ); + assert_eq!(text.matches("Amount: none").count(), 2, "both rows reset: {text}"); +} + /// An XFA button's click script may show the attachments panel; a text field's exit script /// asking the same is noted on the console and ignored. #[test] @@ -11492,3 +12023,53 @@ fn imported_bookmarks_undo_in_one_step() { assert!(err.to_string().contains("no bookmark"), "{err}"); assert!(s.get(id).unwrap().info.outline.is_empty()); } + +/// 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/src/xfa.rs b/crates/engine/src/xfa.rs index f20c82f6c6..4246e5b504 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -275,6 +275,9 @@ struct Runner<'a, 'b> { /// rebuilt when the structure changed). session: Option, changes: Changes, + /// A field embedded in rich text (a floating field) took a new value since the form was + /// last laid out: the text that shows it is laid out again when the event ends. + embedded_changed: bool, } impl<'a, 'b> Runner<'a, 'b> { @@ -304,6 +307,7 @@ impl<'a, 'b> Runner<'a, 'b> { cached: None, session: None, changes: Changes::default(), + embedded_changed: false, } } @@ -422,6 +426,9 @@ impl<'a, 'b> Runner<'a, 'b> { fn set_value(&mut self, som: &str, value: &str) -> bool { use pdfcraft_forms::FieldKind as K; let path = pdfcraft_xfa::data_path(self.tpl, som); + // A floating field shows the value of its data node in the text that embeds it (XFA 3.3 + // pp. 221–222): a page area's, or one another field shares, as well. + let embedded = pdfcraft_xfa::path_is_embedded(self.tpl, &path); let names = self.widgets_at(&path); // The data text: the widget at `som` says how its kind writes the value; without one, // the first widget bound to the node, else the value itself. @@ -471,6 +478,7 @@ impl<'a, 'b> Runner<'a, 'b> { } if changed { self.changes.values += 1; + self.embedded_changed |= embedded; } changed } @@ -521,6 +529,7 @@ impl<'a, 'b> Runner<'a, 'b> { } self.relayouts += 1; self.flush()?; + self.embedded_changed = false; let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?; self.console(report.warnings); crate::xfa_redraw(self.doc)?; @@ -541,13 +550,14 @@ impl<'a, 'b> Runner<'a, 'b> { } else { Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect()) }; - if listed.as_ref().is_some_and(Vec::is_empty) { - return Ok(()); + // Names no widget answers to may still name a floating field (below). + let widgets = !listed.as_ref().is_some_and(Vec::is_empty); + if widgets { + pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; } - pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; self.fields = None; let infos = self.fields().clone(); - for (som, name) in &by_som { + for (som, name) in by_som.iter().filter(|_| widgets) { if listed.as_ref().is_some_and(|l| !l.contains(name)) { continue; } @@ -558,10 +568,28 @@ impl<'a, 'b> Runner<'a, 'b> { let path = pdfcraft_xfa::data_path(self.tpl, som); let text = data_text(info); if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != text { + self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path); self.data_op(pdfcraft_xfa::DataOp::Value { path, text }); self.changes.values += 1; } } + // A floating field without a widget (a hidden one, as forms place them) goes back to + // its default too, and the text that embeds it shows that. + for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) { + if by_som.contains_key(&som) || (!names.is_empty() && !names.iter().any(|n| som_matches(&som, n))) { + continue; + } + // Every row that holds it, not only the first. + let names: Vec = pdfcraft_xfa::data_path(self.tpl, &som).into_iter().map(|(name, _)| name).collect(); + let paths = self.data.as_ref().map(|d| d.paths_named(&names, pdfcraft_xfa::MAX_FORM_NODES)).unwrap_or_default(); + for path in paths { + if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != default { + self.embedded_changed = true; + self.data_op(pdfcraft_xfa::DataOp::Value { path, text: default.clone() }); + self.changes.values += 1; + } + } + } self.invalidate(); Ok(()) } @@ -719,7 +747,7 @@ impl<'a, 'b> Runner<'a, 'b> { fn finish(mut self) -> Result { #[cfg(test)] self.check_live(); - if self.relayouts_skipped { + if self.relayouts_skipped || self.embedded_changed { self.relayouts = 0; self.relayouts_skipped = false; self.relayout()?; @@ -779,7 +807,9 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, } } -/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must +/// Reflow a laid-out XFA form when an edited multiline field can grow, or an edited text field, +/// choice list or check box is embedded in rich text (a floating field, whose value that text +/// shows). The datasets must /// already hold the final values, including calculation results. Fixed-height fields keep /// their existing geometry; forms without scripts parse their template only when needed. /// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot. @@ -794,12 +824,14 @@ pub(crate) fn relayout_changed_text( let current = &*doc; let mut changed = before .iter() - .filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE)) + .filter(|f| { + use pdfcraft_forms::FieldKind as K; + matches!(f.kind, K::Text | K::Combo | K::List | K::CheckBox) + }) .filter_map(|f| { let object = current.get(f.obj); let dict = object.as_dict()?; - let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default(); - if f.value.first().map_or("", String::as_str) == value { + if value_unchanged(current, f, dict.get(b"V")) { return None; } dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text())) @@ -812,7 +844,7 @@ pub(crate) fn relayout_changed_text( Some(tpl) => std::borrow::Cow::Borrowed(tpl), None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?), }; - let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som)); + let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som) || pdfcraft_xfa::field_is_embedded(&template, &som)); drop(changed); if !reflow { return Ok(None); @@ -822,6 +854,60 @@ pub(crate) fn relayout_changed_text( Ok(Some(report.warnings)) } +/// Whether field `f` still holds the value it had (`f.value`), its `/V` being `v`: a list box's +/// picks are an array, a check box's state a name (Off for none, as the field's value has +/// none), and of a text field only the first value counts. Compared in place, so an edit costs +/// no copy of every field's value. +fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: Option<&pdfcraft_cos::Object>) -> bool { + use pdfcraft_cos::Object as O; + let text = f.kind == pdfcraft_forms::FieldKind::Text; + let first = f.value.first().map_or("", String::as_str); + // The value is the one string `is` accepts. + let one = |is: &dyn Fn(&str) -> bool| if text { is(first) } else { matches!(f.value.as_slice(), [old] if is(old)) }; + let none = if text { first.is_empty() } else { f.value.is_empty() }; + let held; + let v = match v { + Some(r @ O::Ref(_)) => { + held = doc.resolve(r); + Some(&*held) + } + v => v, + }; + match v { + Some(O::Array(items)) => { + let mut old = f.value.iter(); + for item in items { + let held_item; + let item = match item { + O::Ref(_) => { + held_item = doc.resolve(item); + &*held_item + } + i => i, + }; + let Some(s) = item.as_string() else { continue }; + if text { + return s.text_eq(first); + } + if !old.next().is_some_and(|o| s.text_eq(o)) { + return false; + } + } + if text { none } else { old.next().is_none() } + } + Some(O::Name(n)) if n.as_slice() == b"Off" => none, + Some(O::Name(n)) => { + let n = String::from_utf8_lossy(n); + one(&|old| old == n) + } + Some(v) => match v.as_string() { + Some(s) => one(&|old| s.text_eq(old)), + None => none, + }, + None => none, + } +} + /// On open: initialize scripts, then calculations. Returns what they changed. pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result { let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); @@ -954,4 +1040,102 @@ mod tests { Some((2, "form[0].table[0]".into(), "row".into(), 2)) ); } + + fn field(obj: pdfcraft_cos::ObjRef, kind: pdfcraft_forms::FieldKind, value: &[&str]) -> pdfcraft_forms::Field { + pdfcraft_forms::Field { + name: "f".into(), + obj, + kind, + value: value.iter().map(|v| (*v).to_owned()).collect(), + default: Vec::new(), + flags: 0, + max_len: None, + options: Vec::new(), + da: String::new(), + quadding: 0, + tooltip: None, + widgets: Vec::new(), + actions: pdfcraft_forms::af::Actions::default(), + button: None, + } + } + + /// A field dictionary whose `/V` is `v`, with a SOM path. + fn field_dict(doc: &mut pdfcraft_cos::Document, v: Option) -> pdfcraft_cos::ObjRef { + let mut d = pdfcraft_cos::Dict::new(); + if let Some(v) = v { + d.set(b"V".to_vec(), v); + } + d.set(pdfcraft_xfa::SOM_KEY.to_vec(), pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("form[0].f[0]"))); + doc.add(d) + } + + #[test] + fn changed_values_are_found_in_place() { + use pdfcraft_cos::{Object as O, PdfString}; + use pdfcraft_forms::FieldKind as K; + let s = |t: &str| O::String(PdfString::text(t)); + let mut doc = pdfcraft_cos::Document::new_empty(); + let same = |doc: &mut pdfcraft_cos::Document, kind, old: &[&str], v: Option| { + let r = field_dict(doc, v); + let dict = doc.get(r); + value_unchanged(doc, &field(r, kind, old), dict.as_dict().and_then(|d| d.get(b"V"))) + }; + // Text: the first value only, an absent or non-text value being empty. + assert!(same(&mut doc, K::Text, &["Café"], Some(s("Café")))); + assert!(!same(&mut doc, K::Text, &["Café"], Some(s("Cafe")))); + assert!(same(&mut doc, K::Text, &[], None)); + assert!(same(&mut doc, K::Text, &[], Some(s("")))); + assert!(!same(&mut doc, K::Text, &["a"], None)); + assert!(same(&mut doc, K::Text, &["a", "b"], Some(O::Array(vec![s("a"), s("c")])))); + // A value given by reference is followed. + let inner = doc.add(s("日本")); + assert!(same(&mut doc, K::Text, &["日本"], Some(O::Ref(inner)))); + assert!(!same(&mut doc, K::Combo, &["日"], Some(O::Ref(inner)))); + // Combo box: one value. + assert!(same(&mut doc, K::Combo, &["Two"], Some(s("Two")))); + assert!(!same(&mut doc, K::Combo, &["Two"], Some(s("Three")))); + assert!(!same(&mut doc, K::Combo, &[], Some(s("Two")))); + assert!(!same(&mut doc, K::Combo, &["Two"], None)); + // List box: every pick, in order, by count; non-strings in the array are skipped. + let picks = |v: &[&str]| O::Array(v.iter().map(|t| s(t)).collect()); + assert!(same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "b"])))); + assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["b", "a"])))); + assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a"])))); + assert!(!same(&mut doc, K::List, &["a"], Some(picks(&["a", "b"])))); + assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "c"])))); + assert!(same(&mut doc, K::List, &[], Some(picks(&[])))); + let item = doc.add(s("b")); + assert!(same(&mut doc, K::List, &["a", "b"], Some(O::Array(vec![s("a"), O::Int(3), O::Ref(item)])))); + // Check box: a state name, Off for none. + assert!(same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Yes")))); + assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Off")))); + assert!(same(&mut doc, K::CheckBox, &[], Some(O::name("Off")))); + assert!(!same(&mut doc, K::CheckBox, &[], Some(O::name("Yes")))); + assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("On")))); + } + + #[test] + fn unchanged_values_return_before_the_template_is_read() { + use pdfcraft_cos::{Object as O, PdfString}; + use pdfcraft_forms::FieldKind as K; + // Laid out, but with no XFA packets: reading the template fails. + let mut doc = pdfcraft_cos::Document::new_empty(); + let mut acro = pdfcraft_cos::Dict::new(); + acro.set(pdfcraft_xfa::LAYOUT_KEY.to_vec(), pdfcraft_cos::Dict::new()); + let acro = doc.add(acro); + let root = doc.root().unwrap_or_else(|| panic!("no catalog")); + let mut catalog = doc.get(root).as_dict().cloned().unwrap_or_default(); + catalog.set(b"AcroForm".to_vec(), O::Ref(acro)); + doc.set(root, catalog); + assert!(pdfcraft_xfa::existing_layout(&doc).is_some()); + let text = field_dict(&mut doc, Some(O::String(PdfString::text("same")))); + let list = field_dict(&mut doc, Some(O::Array(vec![O::String(PdfString::text("a")), O::String(PdfString::text("b"))]))); + let check = field_dict(&mut doc, Some(O::name("Yes"))); + let before = [field(text, K::Text, &["same"]), field(list, K::List, &["a", "b"]), field(check, K::CheckBox, &["Yes"])]; + assert_eq!(relayout_changed_text(&mut doc, None, &before), Ok(None)); + // A changed pick reaches the template, which this document doesn't have. + let before = [field(text, K::Text, &["same"]), field(list, K::List, &["b", "a"]), field(check, K::CheckBox, &["Yes"])]; + assert!(relayout_changed_text(&mut doc, None, &before).is_err()); + } } diff --git a/crates/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs index a5c986c6b3..949264494d 100644 --- a/crates/export/src/as_markdown.rs +++ b/crates/export/src/as_markdown.rs @@ -60,20 +60,23 @@ pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String { let mut merged: Vec<(String, bool, bool)> = Vec::new(); for &(text, bold, italic) in runs { + let text = md_text(text); + if text.is_empty() { + continue; + } match merged.last_mut() { - Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text), - _ => merged.push((text.to_string(), bold, italic)), + Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(&text), + _ => merged.push((text, bold, italic)), } } - let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect(); let mut out = String::new(); - for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() { + for (k, (text, bold, italic)) in merged.iter().enumerate() { let core = text.trim(); let open = marks(core.chars().next(), out.chars().last()); // Markers of a styled neighbour right next to these would join them into one run. - let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) { - (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, - (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())), + let close = match merged.get(k.saturating_add(1)) { + Some((next, b, i)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, + next => marks(core.chars().last(), next.and_then(|(n, ..)| n.chars().next())), }; out.push_str(&emphasis(text, *bold, *italic, open, close)); } @@ -149,3 +152,27 @@ pub fn markdown(pages: &[Page]) -> String { s.push('\n'); s } + +#[cfg(test)] +mod tests { + use super::paragraph; + + #[test] + fn empty_or_control_run_does_not_split_styled_runs() { + assert_eq!(paragraph(&[("hello", true, false), ("", false, false), ("world", true, false)]), "**helloworld**"); + assert_eq!(paragraph(&[("hello", true, false), ("\u{1}", false, false), ("world", true, false)]), "**helloworld**"); + } + + #[test] + fn empty_styled_run_does_not_affect_neighbor_emphasis() { + assert_eq!(paragraph(&[("x", true, false), ("", false, true)]), "**x**"); + assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("", true, true), ("bar", false, false)]), "foo.bar"); + // Empty/control runs between differently styled runs still look ahead to the next non-empty run. + assert_eq!(paragraph(&[("bold", true, false), ("", false, false), ("italic", false, true)]), "bold*italic*"); + assert_eq!(paragraph(&[("bold", true, false), ("\u{1}", false, false), (" italic", false, true)]), "**bold** *italic*"); + // Leading empty/control runs do not affect the opening delimiter. + assert_eq!(paragraph(&[("", false, true), ("\u{1}", true, false), ("lead", true, false)]), "**lead**"); + // All-empty/control paragraphs produce empty output. + assert_eq!(paragraph(&[("", true, false), ("\u{1}", false, true), ("\u{7f}", false, false)]), ""); + } +} diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs new file mode 100644 index 0000000000..4349dd558e --- /dev/null +++ b/crates/export/src/flow.rs @@ -0,0 +1,231 @@ +//! Text that runs on from one line to the next. +//! +//! The reading blocks keep style boundaries (#4158), so a sentence with a bold name or an italic +//! word in it reaches the exporter as several blocks, and `layout::paragraphs` joins only the ones +//! that run on along one line. A sentence that wraps onto the next line after +//! such a run came out as one paragraph per line. [`join_wrapped`] joins a paragraph whose last +//! line reaches the right edge of the page's text with the one that starts just below it at the +//! same left edge, in the same size: the text wrapped there. Headings, list items, a line that +//! ends a sentence and a line with paragraph space after it stay apart. +//! +//! A list item's bullet is often a block of its own: drawn in a symbol font, a tab stop before +//! the item's text. Table detection read such a list as a two-column table, bullets in one +//! column and the items in the other. [`bullet_items`] pairs each bullet with its text, so the +//! text runs on from its bullet and the bullets stay out of table detection. + +use super::layout::{Block, Run}; + +/// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets +/// Word writes into its PDFs (U+F0B7, U+F0A7). +const BULLETS: [char; 11] = ['•', '◦', '▪', '‣', '⁃', '●', '○', '■', '□', '\u{F0B7}', '\u{F0A7}']; + +/// Dashes and a middle dot: list markers at the start of a line, punctuation inside one. +const DASHES: [char; 4] = ['-', '\u{2013}', '\u{2014}', '·']; + +/// A run taller than this many times its font size is more than one line: a block of several +/// rows ends where its widest row does, not where its last one does. +const ONE_LINE: f64 = 1.6; + +/// How far (as a multiple of the font size) the space between two lines may be from none, and +/// they still follow each other within a paragraph: more is paragraph spacing. +const LINE_GAP: f64 = 0.5; + +/// How close to the right edge of the page's text a full line ends: within this many times the +/// font size, or this fraction of the text's width, whichever is more (a ragged line ends short +/// of the edge by up to a long word). +const FULL_LINE: (f64, f64) = (3.0, 0.12); + +/// How far (points) the first line of a paragraph may start from the left edge of the line above +/// it and still be at the same left edge. +const SAME_LEFT: f64 = 2.0; + +/// How far after its bullet (as a multiple of the bullet's size) a list item's text may start: a +/// tab stop away. +const BULLET_GAP: f64 = 3.0; + +/// How many blocks [`bullet_items`] may look at on one page, so that a page of many bullets stays +/// quick; past it, the bullets left are not taken for bullets. +const BULLET_BUDGET: usize = 1_000_000; + +/// Takes a step from `budget`: whether there was one left. +fn step(budget: &mut usize) -> bool { + budget.checked_sub(1).map(|left| *budget = left).is_some() +} + +/// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half +/// the shorter one, as `layout::continued_from` has it. +fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { + a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) +} + +/// Whether a block is a bullet drawn on its own: one bullet, dash or middle dot, no wider than one +/// and a half times its size. +fn is_bullet(b: &Block) -> bool { + let mut chars = b.text.trim().chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return false }; + (BULLETS.contains(&c) || DASHES.contains(&c)) && b.rect[2] - b.rect[0] <= 1.5 * b.size +} + +/// Whether no other block on `blocks[i]`'s line starts left of it. Each block looked at takes a +/// step from `budget`; when the budget runs out the answer is no. +fn opens_line(i: usize, blocks: &[Block], budget: &mut usize) -> bool { + let Some(a) = blocks.get(i) else { return false }; + for (j, b) in blocks.iter().enumerate() { + if !step(budget) { + return false; + } + if j != i && b.rect[0] < a.rect[0] && on_one_line(a.rect, b.rect) { + return false; + } + } + true +} + +/// The list items of a page whose bullet is a block of its own, as (bullet, text): the text is the +/// block that starts nearest after the bullet on its line, within [`BULLET_GAP`] times its size. +/// A dash or a middle dot is a bullet only when it opens its line; inside a line it is +/// punctuation, or a table cell's "none". The items' texts of a list all start at one tab stop, +/// so the search looks through every block that starts there, within [`BULLET_BUDGET`]. +pub(crate) fn bullet_items(blocks: &[Block]) -> Vec<(usize, usize)> { + let mut by_start: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[0], i)).collect(); + by_start.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut budget = BULLET_BUDGET; + let mut out = Vec::new(); + for (ai, a) in blocks.iter().enumerate().filter(|(_, b)| is_bullet(b)) { + // The text may start a point inside the bullet's box, as a run that continues another can. + let k = by_start.partition_point(|(x, _)| *x < a.rect[2] - 1.0); + let text = by_start + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(x, _)| *x <= a.rect[2] + BULLET_GAP * a.size) + .take_while(|_| step(&mut budget)) + .filter(|(_, bi)| *bi != ai) + .filter_map(|(_, bi)| blocks.get(*bi).map(|b| (*bi, b))) + .find(|(_, b)| on_one_line(a.rect, b.rect) && (a.rotation - b.rotation).abs() <= 0.1 && !is_bullet(b)) + .map(|(bi, _)| bi); + let Some(text) = text else { continue }; + let dash = a.text.trim().chars().next().is_some_and(|c| DASHES.contains(&c)); + if !dash || opens_line(ai, blocks, &mut budget) { + out.push((ai, text)); + } + } + out +} + +/// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed +/// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", +/// "i.e." and "2026. Year" are text. +pub(crate) fn list_item(text: &str) -> bool { + let t = text.trim_start(); + let Some(first) = t.chars().next() else { return false }; + if BULLETS.contains(&first) || DASHES.contains(&first) || first == '*' { + return true; + } + let digits = t.chars().take_while(char::is_ascii_digit).count(); + // ASCII digits are one byte each and `first` is one whole character, so both cuts fall on + // character boundaries. + let (rest, marks): (&str, &[char]) = if (1..=3).contains(&digits) { + (t.get(digits..).unwrap_or_default(), &['.', ')']) + } else if digits == 0 && first.is_alphabetic() { + (t.get(first.len_utf8()..).unwrap_or_default(), &[')']) + } else { + return false; + }; + let mut after = rest.chars(); + after.next().is_some_and(|c| marks.contains(&c)) && after.next().is_none_or(char::is_whitespace) +} + +/// Whether a line's text ends a sentence (closing quotes and brackets aside). Text set without +/// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left +/// apart from the next rather than joined to it wrongly. +fn ends_sentence(text: &str) -> bool { + text.trim_end() + .trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']', '」', '』']) + .ends_with(['.', '!', '?', ':', ';', '…', '。', '!', '?']) +} + +/// Whether `c` is of the writing that puts no space between words: Chinese and Japanese +/// characters, their punctuation and fullwidth forms. A line that wraps between two of them +/// joins without a word space. +fn unspaced(c: char) -> bool { + matches!( + c, + '\u{3000}'..='\u{30FF}' // CJK punctuation, Hiragana, Katakana + | '\u{31F0}'..='\u{31FF}' // Katakana extension + | '\u{3400}'..='\u{4DBF}' // CJK extension A + | '\u{4E00}'..='\u{9FFF}' // CJK unified ideographs + | '\u{F900}'..='\u{FAFF}' // CJK compatibility ideographs + | '\u{FF01}'..='\u{FF9F}' // fullwidth forms, halfwidth Katakana + | '\u{20000}'..='\u{3FFFF}' // CJK extension B onwards + ) +} + +/// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto +/// the next line joined to it. `level` is a paragraph's heading level (0 for body text). +pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Run<'a>]) -> u8) -> Vec>> { + // The left and right edges of the page's text: where a full line starts and ends. + let (left, right) = paras + .iter() + .flatten() + .map(|r| r.block.rect) + .filter(|r| r[0].is_finite() && r[2].is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x[0]), r.max(x[2]))); + if right <= left { + return paras; + } + // Top to bottom, then left to right, by each paragraph's first run. + paras.sort_by(|a, b| { + let top_left = |p: &[Run]| p.first().map_or([0.0; 2], |r| [r.block.rect[3], r.block.rect[0]]); + let (a, b) = (top_left(a), top_left(b)); + b[0].total_cmp(&a[0]).then(a[1].total_cmp(&b[1])) + }); + // Each paragraph with its heading level. A joined paragraph is body text: only body text joins. + let mut out: Vec<(Vec>, u8)> = Vec::with_capacity(paras.len()); + for para in paras { + let lvl = level(¶); + match out.last_mut() { + Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { + // The line break was a word space, unless the line ends in a hyphen; the hyphen + // stays, as it may belong to the word ("well-known"). Chinese and Japanese text + // wraps between characters, with no space. + let unspaced_break = |end: &str| { + end.chars().next_back().is_some_and(unspaced) && para.first().and_then(|r| r.text.chars().next()).is_some_and(unspaced) + }; + if let Some(end) = prev.last_mut() + && !end.text.ends_with(char::is_whitespace) + && !end.text.ends_with('-') + && !unspaced_break(&end.text) + { + end.text.push(' '); + } + prev.extend(para); + } + _ => out.push((para, lvl)), + } + } + out.into_iter().map(|(para, _)| para).collect() +} + +/// Whether the body text paragraph `prev` ends in a full line whose text runs on in `next`, the +/// body text paragraph just below it. `[left, right]` are the edges of the page's text. +fn wraps_into(prev: &[Run], next: &[Run], [left, right]: [f64; 2]) -> bool { + let (Some(last), Some(first)) = (prev.last(), next.first()) else { return false }; + let (a, b) = (last.block, first.block); + let size = a.size; + let alike = size.is_finite() && size > 0.0 && (a.size - b.size).abs() <= 0.5 && a.rotation.abs() <= 0.1 && b.rotation.abs() <= 0.1; + if !alike || a.rect[3] - a.rect[1] > ONE_LINE * size || list_item(&first.text) || ends_sentence(&last.text) { + return false; + } + // The last line: the runs at the end of `prev` that share the last run's line. + let (line_left, line_right) = prev + .iter() + .rev() + .take_while(|r| on_one_line(r.block.rect, a.rect)) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x.block.rect[0]), r.max(x.block.rect[2]))); + let full = line_right >= right - (FULL_LINE.0 * size).max(FULL_LINE.1 * (right - left)); + let same_left = (b.rect[0] - line_left).abs() <= SAME_LEFT; + // Just below: no paragraph space between the two lines. + let gap = a.rect[1] - b.rect[3]; + full && same_left && (-LINE_GAP * size..=LINE_GAP * size).contains(&gap) +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 2b96343ebc..a4efa9fc00 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -145,19 +145,31 @@ const INLINE_GAP: f64 = 0.5; /// page laid out so that many end at the same place stays quick. const CONTINUATION_PROBE: usize = 64; +/// How far (as a fraction of the font size) the block before may end past where a block starts +/// and still be the one it continues: a slanted italic's box leans into the next word by about a +/// quarter of its size. +const OVERHANG: f64 = 0.3; + +/// [`OVERHANG`] in points for a block of `size`: at least a point (rounding), at most six. +fn overhang(size: f64) -> f64 { + if size.is_finite() { (OVERHANG * size).clamp(1.0, 6.0) } else { 1.0 } +} + /// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. +/// [`INLINE_GAP`] later, or up to [`overhang`] earlier, as a slanted italic leans into the word +/// after it), so it is the same line of text running on (a link inside a sentence, a +/// superscript), not a cell of its own. fn continuations(blocks: &[Block]) -> Vec { continued_from(blocks).into_iter().map(|from| from.is_some()).collect() } /// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. +/// continue, the one that ends nearest to where it starts. A list item's text continues its +/// bullet, a tab stop before it (`flow::bullet_items`). fn continued_from(blocks: &[Block]) -> Vec> { let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks + let mut from: Vec> = blocks .iter() .enumerate() .map(|(bi, b)| { @@ -166,14 +178,20 @@ fn continued_from(blocks: &[Block]) -> Vec> { .get(k..) .unwrap_or_default() .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) - .collect() + .collect(); + for (bullet, text) in super::flow::bullet_items(blocks) { + if let Some(slot @ None) = from.get_mut(text) { + *slot = Some(bullet); + } + } + from } /// A gap between two runs wider than this (as a fraction of the font size) is a word space the @@ -241,6 +259,12 @@ pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec(blocks: &'a [Block], skip: &[bool], body: f64) -> Vec>> { + super::flow::join_wrapped(paragraphs(blocks, skip), |runs| para_level(runs, body)) +} + /// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold /// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run /// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. @@ -355,8 +379,16 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec], edges: &[f64 pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec, Vec) { let mut budget = RULE_BUDGET; let mut consumed = vec![false; blocks.len()]; + // A list's bullets are not cells: beside their items' text they read as a two-column table. + let mut bullet = vec![false; blocks.len()]; + for (i, _) in super::flow::bullet_items(blocks) { + if let Some(b) = bullet.get_mut(i) { + *b = true; + } + } + let cell_like = |i: usize, b: &Block| is_cell_like(b) && !bullet.get(i).copied().unwrap_or(false); // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, b)| cell_like(*i, b)).map(|(i, b)| (b.rect[0], i)).collect(); xs.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) for (x, i) in xs { @@ -392,7 +424,7 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec
, Vec Vec> { } let (tables, consumed) = tables(&p.blocks, &p.rules); // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + // A paragraph sits where its first run does: its other runs follow along its line and the + // lines it wraps onto. + let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 5ae1700115..885d415fc2 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,9 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text, and a block that -//! continues another on its line (an italic word, a link) is a run of that block's paragraph. -//! Word keeps pages with +//! document: headings are the paragraphs set larger than the body text; a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph, and +//! a paragraph that wraps onto the next line runs on there. Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -21,6 +21,7 @@ mod as_html; mod as_markdown; mod as_rtf; mod clip; +mod flow; mod layout; pub(crate) use as_docx::hex; @@ -36,10 +37,12 @@ pub use as_markdown::markdown; use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; pub use clip::{ClipBuilder, ClipSpan, ClipStyle, ClipText, MAX_CLIP_HTML_BYTES, MAX_CLIP_SPANS, MAX_CLIP_TEXT_BYTES, clip_html, font_family}; +#[cfg(test)] +use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 04cc027ab1..c662f9cb0f 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -125,6 +125,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut global_media: Vec<(String, &Image)> = Vec::new(); let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels) + let body = crate::layout::body_size(pages); for p in pages { let (tables, consumed) = crate::tables(&p.blocks, &p.rules); @@ -139,7 +140,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); - for runs in crate::paragraphs(&p.blocks, &skip) { + for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index e2207c2222..cb520d002e 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -983,3 +983,283 @@ fn a_ruled_table_gets_outer_borders_only_where_the_page_draws_them() { ruled_top.push([0.0, 0.0, 320.0, 0.0]); assert_eq!(tables(&blocks, &ruled_top).0[0].ruled, Some([true, false, false, false]), "only the top line near the table frames it"); } + +/// A line of 10 pt text from `x0` to `x1` whose box starts at `y0`: 12 pt tall, so lines 14 pt +/// apart leave 2 pt between their boxes. +fn line(t: &str, x0: f64, x1: f64, y0: f64) -> Block { + Block { size: 10.0, ..block(t, x0, y0, x1, y0 + 12.0) } +} + +/// The text of each flowing paragraph Word gets for a page of `blocks`. +fn word_texts(blocks: Vec) -> Vec { + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let xml = part(&docx(&[p], "Lines"), "word/document.xml"); + word_paragraphs(&xml).into_iter().map(|runs| runs.into_iter().map(|(t, _)| t).collect()).collect() +} + +/// A sentence over three lines, as the reading blocks give it: split at each style change, a +/// bold name in the first line and a slanted name in the second whose box leans 2.5 pt into the +/// comma after it (#4158). The next paragraph follows after paragraph space. +fn wrapped_sentence() -> Vec { + vec![ + line("The parties to this lease are the owner,", 72.0, 262.0, 700.0), + Block { bold: true, ..line("Harbour Lane Properties", 265.0, 400.0, 700.0) }, + line(", of 4 Quay Street, and the", 400.5, 540.0, 700.0), + line("tenant named in the schedule,", 72.0, 215.0, 686.0), + Block { italic: true, ..line("Blue Finch Studio", 218.0, 312.0, 686.0) }, + line(", who agree as follows to rent the upper", 309.5, 538.0, 686.0), + line("rooms for the whole of the term.", 72.0, 230.0, 672.0), + line("Rent is paid monthly in advance.", 72.0, 230.0, 640.0), + ] +} + +/// #4158: a sentence that wraps onto the next line after a change of style is one paragraph in +/// every format, its lines joined by a word space. +#[test] +fn a_sentence_that_wraps_after_a_style_change_is_one_paragraph() { + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_texts(wrapped_sentence()), [sentence, "Rent is paid monthly in advance."]); + let p = Page { width: 612.0, height: 792.0, blocks: wrapped_sentence(), ..Page::default() }; + let h = html(std::slice::from_ref(&p), "Lease"); + assert_eq!(h.matches("

").count(), 2, "{h}"); + assert!( + h.contains( + "

The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant \ +named in the schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term.

" + ), + "{h}" + ); + let r = rtf(std::slice::from_ref(&p)); + assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + let md = markdown(std::slice::from_ref(&p)); + assert_eq!(md.trim().split("\n\n").count(), 2, "{md}"); + assert!(md.contains("of 4 Quay Street, and the tenant named in the schedule, "), "{md}"); + // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. + let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); + let slide: Vec = + s.split("").skip(1).map(|p| p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect()).collect(); + assert!(slide.iter().any(|p| p == sentence), "{s}"); +} + +/// A slanted word's box leans into the word after it by about a quarter of its size, so that +/// word still runs on from it; text that starts further back is drawn over the text before it, +/// not after it. +#[test] +fn a_slanted_word_that_leans_into_the_next_stays_in_its_line() { + let leaning = vec![ + line("named in the schedule,", 72.0, 180.0, 700.0), + Block { italic: true, ..line("Blue Finch Studio", 183.0, 276.0, 700.0) }, + line(", who agree", 273.5, 330.0, 700.0), + ]; + assert_eq!(word_texts(leaning), ["named in the schedule, Blue Finch Studio, who agree"]); + let over = vec![line("A label", 72.0, 120.0, 700.0), line("over another", 115.0, 180.0, 700.0)]; + assert_eq!(word_texts(over), ["A label", "over another"]); +} + +/// A line runs on into the next only when it reaches the right edge of the text and the next +/// starts just below at the same left edge, in the same size. A line that ends a sentence or ends +/// short, and a next line that is a heading, a list item, indented or after paragraph space, keep +/// their paragraph break. Each case is a page of its own. +#[test] +fn a_paragraph_breaks_where_its_line_does_not_run_on() { + let full = "The tenant keeps the rooms, the stairs and the shared yard clean and in good repair"; + let next = "throughout the term of the lease"; + let first = |t: &str| line(t, 72.0, 540.0, 700.0); + let second = |t: &str| line(t, 72.0, 240.0, 686.0); + assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); + assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + // Chinese and Japanese wrap between characters: their lines join without a space. Korean + // spaces its words, so its lines join with one. + let zh = ("本合同自双方签字之日起生效,有效期", "为一年,期满前一个月双方协商续签事宜"); + assert_eq!(word_texts(vec![first(zh.0), second(zh.1)]), [format!("{}{}", zh.0, zh.1)]); + assert_eq!( + word_texts(vec![first("この契約は双方が署名した日から"), second("一年間有効とする")]), + ["この契約は双方が署名した日から一年間有効とする"] + ); + assert_eq!(word_texts(vec![first("임차인은 방과 계단을 깨끗하게"), second("유지합니다")]), ["임차인은 방과 계단을 깨끗하게 유지합니다"]); + let heading = |b: Block| Block { bold: true, ..b }; + for (why, blocks) in [ + ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), + ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("a Chinese sentence ends", vec![first("本合同自双方签字之日起生效。"), second(zh.1)]), + ("a Japanese sentence ends in quotes", vec![first("「この契約は一年間有効ですか?」"), second("双方が署名した日から")]), + ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), + ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), + ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), + ("a larger size", vec![first(full), Block { size: 12.0, ..second(next) }]), + ("a numbered item", vec![first(full), second("2. The tenant pays the rent")]), + ("a lettered item", vec![first(full), second("а) поддръжка на сървърите")]), + ("a bulleted item", vec![first(full), second("• the stairs")]), + ("a heading below", vec![first(full), heading(second("Rent and deposit"))]), + ("a heading above", vec![heading(first("Obligations of the tenant")), second(next)]), + ] { + assert_eq!(word_texts(blocks).len(), 2, "{why}"); + } +} + +/// Two columns of text, the right one set half a line lower: a full line in one column never runs +/// on into the line of the other just below it, though the right column's lines reach the page's +/// right edge and the left column's start at its left. +#[test] +fn a_line_never_runs_on_into_the_other_column() { + let left = ["The tenant keeps the rooms and the", "stairs clean and in good repair for", "the whole of the term of the lease"]; + let right = ["The landlord keeps the roof and the", "outer walls sound and dry for the", "whole of the term of the lease too"]; + let mut blocks = Vec::new(); + for (n, (l, r)) in left.iter().zip(right).enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(l, 72.0, 290.0, y)); + blocks.push(line(r, 322.0, 540.0, y - 7.0)); + } + let paragraphs = word_texts(blocks); + for line in left.iter().chain(&right) { + assert!(paragraphs.iter().any(|p| p.contains(line)), "{line}: {paragraphs:?}"); + } + for p in ¶graphs { + assert!(!(left.iter().any(|l| p.contains(l)) && right.iter().any(|r| p.contains(r))), "{p:?} mixes the columns"); + } +} + +/// A poem quoted between paragraphs keeps its lines: they stop well short of the right edge the +/// prose around it reaches, so none of them is a full line that wraps. +#[test] +fn a_poem_keeps_its_lines() { + let poem = [ + "The kettle sings beside the door", + "the cat is curled upon the chair", + "and rain runs down the window glass", + "while evening settles on the stair", + ]; + let prose = "The tenant may read aloud in the shared garden on summer evenings, as in the verse below"; + let mut blocks = vec![line(prose, 72.0, 540.0, 720.0)]; + for (n, (t, end)) in poem.iter().zip([236.0, 230.0, 248.0, 232.0]).enumerate() { + blocks.push(line(t, 90.0, end, 696.0 - 14.0 * n as f64)); + } + let mut expected = vec![prose]; + expected.extend(poem); + assert_eq!(word_texts(blocks), expected); +} + +#[test] +fn list_items_are_told_from_text() { + for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] { + assert!(list_item(item), "{item}"); + } + for text in ["1.5 kg of", "2026. Year", "i.e. this", "т.е. така", "38 rooms", "Rent 12)", "1234) many", ""] { + assert!(!list_item(text), "{text}"); + } +} + +/// Wrapped lines join on hostile boxes and sizes without a panic, and never lose a line's text. +#[test] +fn joining_lines_survives_hostile_boxes() { + let mut blocks = Vec::new(); + for (i, v) in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e300].into_iter().enumerate() { + let y = 700.0 - 14.0 * i as f64; + blocks.push(Block { size: v, ..line(&format!("size {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [v, y, 540.0, y + 12.0], ..line(&format!("left {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [72.0, v, v, y + 12.0], ..line(&format!("box {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rotation: v, ..line(&format!("turn {i}"), 72.0, 540.0, y) }); + } + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let outputs = [ + html(std::slice::from_ref(&p), "Hostile"), + rtf(std::slice::from_ref(&p)), + part(&pptx(std::slice::from_ref(&p), "Hostile"), "ppt/slides/slide1.xml"), + word_texts(p.blocks.clone()).concat(), + ]; + for output in outputs { + for b in &p.blocks { + assert!(output.contains(&b.text), "{} was lost", b.text); + } + } +} + +/// A bulleted list as Word and LibreOffice draw one: each bullet a block of its own in a symbol +/// font, a tab stop before its item's text, between a lead-in line and the next paragraph. +fn bulleted_list(bullet: &str) -> Vec { + let mut blocks = vec![line("The tenant looks after:", 72.0, 180.0, 700.0)]; + for (n, (text, end)) in + [("the stairs and the landing;", 230.0), ("the shared yard and its gate;", 240.0), ("the bins, every week.", 200.0)].into_iter().enumerate() + { + let y = 686.0 - 14.0 * n as f64; + blocks.push(line(bullet, 90.0, 94.6, y)); + blocks.push(line(text, 108.0, end, y)); + } + blocks.push(line("Rent is paid monthly in advance.", 72.0, 230.0, 630.0)); + blocks +} + +/// A bulleted list whose bullets are blocks of their own is one paragraph per item, each item's +/// text after its bullet, in every format: not a two-column table of bullets and text. The +/// Symbol and Wingdings bullets Word writes count, and so does a dash that opens its line. +#[test] +fn a_bulleted_list_is_paragraphs_not_a_table() { + for bullet in ["•", "\u{F0B7}", "\u{F0A7}", "\u{2013}"] { + let p = Page { width: 612.0, height: 792.0, blocks: bulleted_list(bullet), ..Page::default() }; + let (found, _) = tables(&p.blocks, &[]); + assert!(found.is_empty(), "{bullet}: {found:?}"); + let items = ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].map(|t| format!("{bullet} {t}")); + let mut expected = vec!["The tenant looks after:".to_string()]; + expected.extend(items.iter().cloned()); + expected.push("Rent is paid monthly in advance.".into()); + assert_eq!(word_texts(p.blocks.clone()), expected, "{bullet}"); + let h = html(std::slice::from_ref(&p), "List"); + assert!(!h.contains("
") && items.iter().all(|i| h.contains(&format!("

{i}

"))), "{bullet}: {h}"); + assert!(!rtf(std::slice::from_ref(&p)).contains("\\trowd"), "{bullet}"); + let s = part(&pptx(std::slice::from_ref(&p), "List"), "ppt/slides/slide1.xml"); + assert!(!s.contains(""), "{bullet}: {s}"); + } +} + +/// A dash in a table row stands for "none": it is a cell like the others, not a bullet, so the +/// table keeps all its rows. +#[test] +fn a_dash_in_a_table_row_stays_a_cell() { + let mut blocks = Vec::new(); + for (n, (name, first, second)) in [("Apples", "\u{2013}", "5"), ("Pears", "3", "4"), ("Plums", "2", "6")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(name, 72.0, 110.0, y)); + blocks.push(line(first, 200.0, 205.0, y)); + blocks.push(line(second, 225.0, 230.0, y)); + } + let (found, consumed) = tables(&blocks, &[]); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].rows.len(), 3, "{found:?}"); + assert!(consumed.iter().all(|used| *used), "{consumed:?}"); +} + +/// A bullet is never a table cell, even when its items carry a second column such as an amount: +/// every item keeps its bullet in front of its text. +#[test] +fn a_bullet_is_never_a_table_cell() { + let mut blocks = Vec::new(); + for (n, (item, amount)) in [("apples", "5 kg"), ("pears", "4 kg"), ("plums", "2 kg")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line("•", 90.0, 94.6, y)); + blocks.push(line(item, 108.0, 140.0, y)); + blocks.push(line(amount, 300.0, 325.0, y)); + } + let (found, _) = tables(&blocks, &[]); + assert!(found.iter().flat_map(|t| &t.rows).flatten().all(|cell| cell.text != "•"), "{found:?}"); + let paragraphs = word_texts(blocks); + for item in ["• apples", "• pears", "• plums"] { + assert!(paragraphs.iter().any(|p| p.starts_with(item)), "{item}: {paragraphs:?}"); + } +} + +/// Every item of a long list keeps its bullet, though all the items' texts start at one tab stop. +#[test] +fn a_long_bulleted_list_keeps_every_bullet_with_its_item() { + let mut blocks = Vec::new(); + for n in 0..150 { + let y = 2200.0 - 14.0 * f64::from(n); + blocks.push(line("\u{F0B7}", 90.0, 94.6, y)); + blocks.push(line(&format!("item {n};"), 108.0, 160.0, y)); + } + let (found, _) = tables(&blocks, &[]); + assert!(found.is_empty(), "{found:?}"); + let paragraphs = word_texts(blocks); + assert_eq!(paragraphs.len(), 150, "{paragraphs:?}"); + assert!(paragraphs.iter().enumerate().all(|(n, p)| *p == format!("\u{F0B7} item {n};")), "{paragraphs:?}"); +} diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index ddb70270d8..26ed16b198 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -110,6 +110,125 @@ mod tests { data } + /// The JBIG2 file header's ID string (ISO 14492 D.4.1). + const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A]; + + /// `embedded`, segments as [`encode_jbig2_generic`] writes them (11-byte headers: no references, + /// a one-byte page association), behind a file header with `flags` and the page count `pages`, + /// in the organization `flags` declares (D.4.2, bit 0): 1 sequential, each segment's header + /// before its data (D.1); 0 random access, every header first, the last an end of file segment + /// numbered 2, which tells where they end, then every segment's data in the same order (D.2). + fn with_file_header(embedded: &[u8], flags: u8, pages: Option) -> Vec { + let mut data = FILE_HEADER_ID.to_vec(); + data.push(flags); + data.extend(pages.map(u32::to_be_bytes).into_iter().flatten()); + if flags & 0x01 != 0 { + data.extend_from_slice(embedded); + return data; + } + let (mut headers, mut bodies, mut rest) = (Vec::new(), Vec::new(), embedded); + while !rest.is_empty() { + let (header, after) = rest.split_at(11); + assert_eq!(header[5], 0, "no references"); + let (body, after) = after.split_at(u32::from_be_bytes(header[7..11].try_into().unwrap()) as usize); + headers.extend_from_slice(header); + bodies.extend_from_slice(body); + rest = after; + } + headers.extend_from_slice(&[0, 0, 0, 2, 51, 0, 1, 0, 0, 0, 0]); + data.extend(headers); + data.extend(bodies); + data + } + + /// A stream that starts with a JBIG2 file header (ISO 14492 D.4), which some producers write + /// into `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded organization there, + /// decodes past it as Acrobat and PDFBox draw it (hayro-jbig2 patch 17), in either organization + /// the header declares, with the page count and without it. "Bit 0: File organisation type. If + /// this bit is 0, the file uses the random-access organisation. If this bit is 1, the file uses + /// the sequential organisation." (D.4.2) A header with its reserved flag bits set is an error, + /// not a panic. + #[test] + fn a_stream_with_a_jbig2_file_header_decodes_past_it() { + let (w, h) = (203u32, 41u32); + let bits = noise(w, h); + let max = bits.len(); + let embedded = encode_jbig2_generic(&bits, w, h).unwrap(); + let decode = |flags: u8, pages: Option| decode_jbig2(&with_file_header(&embedded, flags, pages), None, w, h, max); + // Sequential organization (bit 0 is 1), one page. + assert_eq!(decode(0x01, Some(1)).unwrap(), bits, "sequential, page count given"); + // Sequential, number of pages unknown (bit 1 is 1): no count follows (D.4.3). + assert_eq!(decode(0x03, None).unwrap(), bits, "sequential, page count unknown"); + // Random-access organization (bit 0 is 0): the segment headers, then their data. + assert_ne!(with_file_header(&embedded, 0x00, Some(1))[13..], embedded[..], "laid out otherwise"); + assert_eq!(decode(0x00, Some(1)).unwrap(), bits, "random access, page count given"); + assert_eq!(decode(0x02, None).unwrap(), bits, "random access, page count unknown"); + assert!(decode(0x11, Some(1)).is_err(), "reserved bits"); + } + + /// A file header cut short, or the segments after it, is an error and not a panic: the ID string + /// alone or in part, a header whose flags promise a page count with fewer than its four bytes, + /// a header with no segments after it, and segments cut anywhere in either organization. No + /// prefix of a whole stream decodes to its samples. + #[test] + fn a_truncated_jbig2_file_header_is_an_error() { + let (w, h) = (24u32, 8u32); + let bits = noise(w, h); + let max = bits.len(); + let embedded = encode_jbig2_generic(&bits, w, h).unwrap(); + let decode = |data: &[u8]| decode_jbig2(data, None, w, h, max); + assert!(decode(&FILE_HEADER_ID[..5]).is_err(), "part of the ID string"); + assert!(decode(&FILE_HEADER_ID).is_err(), "the ID string, no flags"); + for count in 0..4 { + let mut data = FILE_HEADER_ID.to_vec(); + data.push(0x01); + data.extend_from_slice(&1u32.to_be_bytes()[..count]); + assert!(decode(&data).is_err(), "{count} bytes of the page count"); + } + for (flags, pages) in [(0x01, Some(1)), (0x03, None), (0x00, Some(1)), (0x02, None)] { + let whole = with_file_header(&embedded, flags, pages); + let header = 9 + if pages.is_some() { 4 } else { 0 }; + assert!(decode(&whole[..header]).is_err(), "flags {flags:#04x}: the header alone"); + assert!(decode(&whole[..whole.len() - 1]).is_err(), "flags {flags:#04x}: the last byte cut"); + for end in 0..whole.len() { + assert_ne!(decode(&whole[..end]).ok(), Some(bits.clone()), "flags {flags:#04x}: {end} bytes"); + } + assert_eq!(decode(&whole).unwrap(), bits, "flags {flags:#04x}: whole"); + } + } + + /// The segments after a file header count toward hayro-jbig2's cap on the segments of a stream + /// together with its `/JBIG2Globals`, in either organization (patches 8 and 17): globals of + /// extension segments, which decode to nothing, fill the cap to the image's segments, and one + /// more is refused. + #[test] + fn segments_after_a_jbig2_file_header_count_with_the_globals() { + /// hayro-jbig2's `MAX_SEGMENTS`, the most segments of a stream with its globals. + const MAX_SEGMENTS: u32 = 1 << 20; + let (w, h) = (24u32, 8u32); + let bits = noise(w, h); + let max = bits.len(); + let embedded = encode_jbig2_generic(&bits, w, h).unwrap(); + // Numbered past the image's segments (0 to 2), of no page, with no data. + let globals = |count: u32| -> Vec { + (16..16 + count) + .flat_map(|number| { + let mut segment = number.to_be_bytes().to_vec(); + segment.extend_from_slice(&[62, 0, 0, 0, 0, 0, 0]); + segment + }) + .collect() + }; + // The image's page information and region, and in random access its end of file segment. + for (flags, organization, image) in [(0x01, "sequential", 2), (0x00, "random access", 3)] { + let data = with_file_header(&embedded, flags, Some(1)); + let (at_cap, past_cap) = (globals(MAX_SEGMENTS - image), globals(MAX_SEGMENTS - image + 1)); + assert_eq!(decode_jbig2(&data, Some(&at_cap), w, h, max).unwrap(), bits, "{organization}: at the cap"); + let refused = decode_jbig2(&data, Some(&past_cap), w, h, max).unwrap_err(); + assert!(refused.to_string().contains("too many segments"), "{organization}: {refused}"); + } + } + /// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic /// region of noise, and symbols for letters repeated across the page. With no rows asked for, /// nothing is decoded. 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 0ef49c81a5..978b9d11ae 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -356,6 +356,94 @@ const MAX_CMAP_RANGES: usize = 1 << 16; /// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger /// one is not decoded at all. const MAX_CMAP_BYTES: usize = 16 << 20; +/// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes. +const MAX_TYPE3_GLYPHS: usize = 256; +/// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped. +const MAX_TYPE3_GLYPH_BYTES: usize = 64 << 10; +/// Decoded bytes all of one font's glyph descriptions may take together. +const MAX_TYPE3_FONT_BYTES: usize = 4 << 20; +/// Farthest a Type 3 glyph box reaches from the baseline, in text space per unit of font size. A +/// bitmap font drawn in page units at `0.01 Tf` is a few thousand; past this a box is garbage, and +/// clamping it keeps the run boxes built from it finite. +const MAX_TYPE3_EXTENT: f64 = 1e6; + +/// The union of the boxes a Type 3 font's glyph descriptions declare with `d1` (ISO 32000-2 +/// §9.6.4, Table 111), in glyph space, and whether every description chosen was read; `None` when +/// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences` +/// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a +/// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`] +/// descriptions within [`MAX_TYPE3_FONT_BYTES`]. +fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { + let procs = dict(doc, font.get(b"CharProcs"))?; + let mut names: Vec<&[u8]> = Vec::new(); + // Whether every glyph that can be drawn is read; if not, the union may be short of them. + let mut complete = true; + let encoding = dict(doc, font.get(b"Encoding")); + // A base encoding reaches glyphs `/Differences` doesn't name, which Type 3 doesn't provide for (its + // `/Differences` "shall specify the complete character encoding", Table 110): those boxes go unread. + if encoding.as_ref().is_some_and(|e| e.contains(b"BaseEncoding")) { + complete = false; + } + let diffs = encoding.as_ref().and_then(|e| e.get(b"Differences")).map(|d| doc.resolve(d)); + for name in diffs.as_deref().and_then(Object::as_array).into_iter().flatten().filter_map(Object::as_name) { + if procs.get(name).is_none() || names.contains(&name) { + continue; + } + if names.len() >= MAX_TYPE3_GLYPHS { + complete = false; + break; + } + names.push(name); + } + let chosen: Vec<&Object> = if names.is_empty() { + complete = procs.len() <= MAX_TYPE3_GLYPHS; + procs.iter().take(MAX_TYPE3_GLYPHS).map(|(_, proc)| proc).collect() + } else { + names.iter().filter_map(|n| procs.get(n)).collect() + }; + let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0); + let mut union: Option<[f64; 4]> = None; + for proc in chosen { + let Object::Stream(s) = &*doc.resolve(proc) else { continue }; + let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else { + if budget.remaining() == 0 { + // The rest go unread too. + complete = false; + break; + } + continue; + }; + if let Some([llx, lly, urx, ury]) = d1_box(&data) { + let r = [llx.min(urx), lly.min(ury), llx.max(urx), lly.max(ury)]; + union = Some(union.map_or(r, |u| [u[0].min(r[0]), u[1].min(r[1]), u[2].max(r[2]), u[3].max(r[3])])); + } + } + union.map(|u| (u, complete)) +} + +/// The glyph box `llx lly urx ury` of a glyph description that begins with `wx wy llx lly urx ury +/// d1`; `None` for `d0` or anything else first. +fn d1_box(data: &[u8]) -> Option<[f64; 4]> { + let mut lexer = pdfcraft_cos::Lexer::new(data, 0); + let mut operands = [0.0; 6]; + for slot in &mut operands { + lexer.skip_ws(); + *slot = std::str::from_utf8(lexer.token()).ok()?.parse::().ok().filter(|v| v.is_finite())?; + } + lexer.skip_ws(); + (lexer.token() == b"d1").then(|| [operands[2], operands[3], operands[4], operands[5]]) +} + +/// The lowest and highest text-space y of the glyph-space box `r` under a font matrix whose +/// y column is `b`, `d`, `f` (§9.2.4): a flipped matrix (`d < 0`) turns the box over. `None` +/// when the box has no height in text space or the numbers overflow; clamped to +/// [`MAX_TYPE3_EXTENT`]. +fn text_y_extent(r: [f64; 4], b: f64, d: f64, f: f64) -> Option<(f64, f64)> { + let ys = [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])].map(|(x, y)| b * x + d * y + f); + let lo = ys.iter().copied().fold(f64::INFINITY, f64::min); + let hi = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max); + (lo.is_finite() && hi.is_finite() && hi > lo).then_some((lo.max(-MAX_TYPE3_EXTENT), hi.min(MAX_TYPE3_EXTENT))) +} /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -754,6 +842,8 @@ impl Metrics { m.std14 = None; let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); let descriptor; + // A Type 3 font measured from its glyph descriptions' `d1` boxes or its `/FontBBox` (see below). + let mut type3_measured = false; if subtype == b"Type0" { m.composite = true; m.default = 1000.0; @@ -814,13 +904,28 @@ impl Metrics { descriptor = dict(doc, font.get(b"FontDescriptor")); m.default = descriptor.as_ref().and_then(|d| d.get(b"MissingWidth")).and_then(|w| doc.resolve(w).as_f64()).unwrap_or(0.0); if subtype == b"Type3" { - let fm = nums(doc, font.get(b"FontMatrix")); - if fm.len() == 6 { - m.scale = fm[0]; - let bbox = nums(doc, font.get(b"FontBBox")); - if bbox.len() == 4 && fm[3] != 0.0 { - m.ascent = (bbox[3] * fm[3]).max(0.5); - m.descent = (bbox[1] * fm[3]).min(-0.1); + if let [a, b, _, d, _, f] = nums(doc, font.get(b"FontMatrix"))[..] { + m.scale = a; + // The glyphs' own boxes first: a bitmap font's `/FontBBox` is often all zero + // or one unit high, while each `d1` declares the box its glyph fills. + // Each `d1` box "shall be correct" (Table 111), so their union is used as + // declared, only widened to reach the baseline: a font of small glyphs gets a + // small box. A `/FontBBox` is often one unit high, so it keeps the 0.5 / 0.1 em + // minimum. + // A union cut short by a limit may miss glyphs that are drawn, so a nonzero + // `/FontBBox` comes first then; all zero, the partial union is still better + // than no box. A box with no height in text space gives way to the next. + let glyphs = type3_glyph_box(doc, font); + // All zero: no assumption about glyph sizes (Table 110). + let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0)); + let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0)); + let candidates = [union(true), bbox.map(|r| (r, 0.5, -0.1)), union(false)]; + if let Some(((lo, hi), min_ascent, max_descent)) = + candidates.into_iter().flatten().find_map(|(r, up, down)| text_y_extent(r, b, d, f).map(|e| (e, up, down))) + { + m.ascent = hi.max(min_ascent); + m.descent = lo.min(max_descent); + type3_measured = true; } } } else if m.widths.is_empty() { @@ -840,11 +945,15 @@ impl Metrics { let ascent = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()); let descent = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()); // Fonts often claim 0; never shrink the glyph box below a sensible minimum. - if ascent.is_some() || subtype != b"Type3" { + // A Type 3 font measured from its glyphs' `d1` boxes or its `/FontBBox` keeps those: + // they are in glyph space and go through its font matrix, while a descriptor's + // /Ascent and /Descent are optional for Type 3 (Table 120) and read here as 1000-unit + // values, whatever the matrix. + if (ascent.is_some() && !type3_measured) || subtype != b"Type3" { let a = ascent.unwrap_or(0.0) / 1000.0; m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 }; } - if descent.is_some() || subtype != b"Type3" { + if (descent.is_some() && !type3_measured) || subtype != b"Type3" { let de = descent.unwrap_or(0.0) / 1000.0; m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 }; } @@ -951,7 +1060,11 @@ impl Metrics { || (self.outlined_cids.is_empty() && self.explicit_default); } match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { - Some(w) => *w > 0.0 || code == 32, + // A full font keeps its space whatever `/Widths` says; a subset that gives its space no width has no + // space glyph (pdfTeX writes 0 for the codes it never uses), and a typed space drawn as code 32 would + // glue the words together (#6131): the edit sets it as a positioning gap instead. Only when code 32 + // is a space: another glyph a subset puts there keeps its code. + Some(w) => *w > 0.0 || (code == 32 && !(self.subset && self.text_of(code).is_some_and(|t| t.chars().all(char::is_whitespace)))), None => self.widths.is_empty() && !self.subset, } } @@ -1289,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) { @@ -1351,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()) { @@ -1414,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; @@ -1447,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 59cd46d4be..9a0a32be6a 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -265,6 +265,228 @@ fn type3_descriptor_names_the_font_without_changing_its_metrics() { assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); } +/// A Type 3 font with `matrix`, `bbox` and one glyph description per entry of `procs`. +fn type3(doc: &mut Document, matrix: [f64; 6], bbox: [i64; 4], procs: &[&str]) -> Dict { + let mut charprocs = Dict::new(); + for (i, proc) in procs.iter().enumerate() { + let glyph = doc.add(Object::Stream(Stream::flate(Dict::new(), proc.as_bytes()))); + charprocs.set(format!("g{i}").into_bytes(), Object::Ref(glyph)); + } + font( + doc, + vec![ + ("Subtype", Object::name("Type3")), + ("FontMatrix", Object::Array(matrix.iter().map(|&v| Object::Real(v)).collect())), + ("FontBBox", Object::Array(bbox.iter().map(|&v| Object::Int(v)).collect())), + ("CharProcs", Object::Dict(charprocs)), + ], + ) +} + +/// ISO 32000-2 §9.6.4: a bitmap font whose `/FontBBox` is all zero (no assumption about glyph +/// sizes) is as high as the boxes its glyphs declare with `d1`, not a fixed 0.6 of the size. +#[test] +fn a_type3_font_is_as_high_as_its_d1_glyph_boxes() { + let mut doc = Document::new_empty(); + let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + let f = type3(&mut doc, unit, [0, 0, 0, 0], &["100 0 0 0 100 100 d1\nq 100 0 0 100 0 0 cm BI /W 1 /H 1 /IM true ID \x00 EI Q"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (100.0, 0.0)); + // Shown at `0.12 Tf`, the glyph is 12 points high. + assert!((m.ascent * 0.12 - 12.0).abs() < 1e-9); + + // The union of every glyph's box, leading comments and white space allowed; `d0` glyphs + // declare none, and a non-numeric operand isn't a box. + let f = type3(&mut doc, unit, [0, 0, 0, 0], &["% c\n 50 0 0 -20 40 60 d1", "60 0 d0", "60 0 0 -30 40 x d1", "60 0 0 0 40 80 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (80.0, -20.0)); + + // A wrong but non-zero `/FontBBox` (one unit high) gives way to the glyphs. + let f = type3(&mut doc, unit, [0, 0, 1, 1], &["100 0 0 -10 100 90 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (90.0, -10.0)); + + // No `d1` anywhere: the `/FontBBox` still decides, scaled by the matrix. + let f = type3(&mut doc, [0.001, 0.0, 0.0, 0.001, 0.0, 0.0], [0, -300, 1000, 900], &["600 0 d0"]); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 0.9).abs() < 1e-12 && (m.descent + 0.3).abs() < 1e-12, "{} {}", m.ascent, m.descent); +} + +/// Small glyphs keep their small box: there is no 0.5 em / 0.1 em minimum for `d1` boxes, which +/// made a 58-point line of 0.15 em glyphs 35 points high. The shape of a scanned-text bitmap +/// font: matrix 0.0042, `/FontBBox` all zero, `d1` boxes from -8 to 33 units. +#[test] +fn small_type3_glyphs_keep_their_d1_height() { + let mut doc = Document::new_empty(); + let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0]; + let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1", "26 0 2 -8 24 28 d1", "13 0 0 0 1 1 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent); + // Glyphs that all sit above the baseline still reach down to it. + let f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 19 9 33 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && m.descent == 0.0, "{} {}", m.ascent, m.descent); +} + +/// A font matrix that turns glyph space over (`[1 0 0 -1 0 0]`, drawn under a flipped text +/// matrix) puts the top of the glyph box at text-space y -top: the box is as high as before, +/// upside down, not clamped to a sliver. +#[test] +fn a_flipped_type3_font_matrix_keeps_the_glyph_height() { + let mut doc = Document::new_empty(); + let flipped = [1.0, 0.0, 0.0, -1.0, 0.0, 0.0]; + let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &["37 0 2 -1 34 38 d1", "42 0 2 -18 40 38 d1", "46 0 3 -1 42 57 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (18.0, -57.0)); + let f = type3(&mut doc, flipped, [-4, -18, 51, 57], &[]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (18.0, -57.0), "the /FontBBox, turned over the same way"); +} + +/// Glyph descriptions are untrusted: unreadable ones, a box of infinities, a degenerate matrix +/// and a font with more glyphs than are read all leave finite metrics. +#[test] +fn type3_glyph_boxes_are_read_within_bounds() { + let mut doc = Document::new_empty(); + let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + for procs in [&["inf 0 0 -inf 1 inf d1"][..], &["d1"], &["1 2 3 d1"], &[""], &["1 0 0 0 1 0 d1"], &["1e308 0 -1e308 -1e308 1e308 1e308 d1"]] { + let f = type3(&mut doc, unit, [0, 0, 0, 0], procs); + let m = Metrics::from_dict(&doc, &f); + assert!(m.ascent > 0.0 && m.descent < 0.0, "{procs:?}"); + assert!(m.ascent <= MAX_TYPE3_EXTENT && m.descent >= -MAX_TYPE3_EXTENT, "{procs:?}"); + } + // A matrix with no y scale gives the box no height: the fallback metrics stand. + let f = type3(&mut doc, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0, 0, 0, 0], &["1 0 0 0 1 100 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (Metrics::fallback().ascent, Metrics::fallback().descent)); + // Glyphs past the first MAX_TYPE3_GLYPHS are not read. + let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS]; + procs.push("1 0 0 0 1 1000 d1"); + let f = type3(&mut doc, unit, [0, 0, 0, 0], &procs); + assert_eq!(Metrics::from_dict(&doc, &f).ascent, 10.0); +} + +/// `/Differences` with the glyph names `names` from code 0, set on the Type 3 font `f`. +fn with_differences(f: &mut Dict, names: &[String]) { + let mut items = vec![Object::Int(0)]; + items.extend(names.iter().map(|n| Object::name(n))); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(items)); + f.set(b"Encoding".to_vec(), Object::Dict(enc)); +} + +/// The glyph descriptions the encoding reaches are the ones read, since its `/Differences` +/// "shall specify the complete character encoding" (ISO 32000-2 Table 110): a drawn glyph past +/// the first [`MAX_TYPE3_GLYPHS`] descriptions still counts. +#[test] +fn type3_glyph_boxes_are_read_for_the_glyphs_the_encoding_reaches() { + let mut doc = Document::new_empty(); + let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS + 44]; + procs.push("1 0 0 0 1 1000 d1"); + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + with_differences(&mut f, &[format!("g{}", MAX_TYPE3_GLYPHS + 44), "g0".into()]); + assert_eq!(Metrics::from_dict(&doc, &f).ascent, 1000.0); +} + +/// Byte limits on glyph descriptions: one larger than [`MAX_TYPE3_GLYPH_BYTES`] is skipped, its +/// `d1` box unread, and the rest are read; once the font's [`MAX_TYPE3_FONT_BYTES`] run out, +/// the boxes read so far may miss glyphs that are drawn, so a nonzero `/FontBBox` stands. +#[test] +fn type3_glyph_byte_limits_leave_honest_metrics() { + let mut doc = Document::new_empty(); + let unit = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + let pad = |head: &str, len: usize| format!("{head}{}", " ".repeat(len.saturating_sub(head.len()))); + let oversized = pad("1 0 0 0 1 1000 d1", MAX_TYPE3_GLYPH_BYTES + 100); + let procs = [oversized.as_str(), "1 0 0 0 1 3 d1"]; + let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs); + with_differences(&mut f, &["g0".into(), "g1".into()]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (3.0, 0.0), "the oversized glyph is skipped, the other read"); + + let filler = pad("0 0 m", MAX_TYPE3_GLYPH_BYTES - 1000); + let fillers = MAX_TYPE3_FONT_BYTES / (MAX_TYPE3_GLYPH_BYTES - 1000) + 1; + let mut procs = vec!["1 0 0 0 1 1 d1"]; + procs.extend(std::iter::repeat_n(filler.as_str(), fillers)); + procs.push("1 0 0 0 1 1000 d1"); + let mut f = type3(&mut doc, unit, [0, -2, 10, 5], &procs); + let names: Vec = (0..procs.len()).map(|i| format!("g{i}")).collect(); + with_differences(&mut f, &names); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone"); +} + +/// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` +/// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). +#[test] +fn type3_d1_glyph_boxes_win_over_descriptor_metrics() { + let mut doc = Document::new_empty(); + let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0]; + let mut f = type3(&mut doc, small, [0, 0, 0, 0], &["13 0 2 -8 9 33 d1"]); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"Ascent".to_vec(), Object::Int(891)); + descriptor.set(b"Descent".to_vec(), Object::Int(-216)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let m = Metrics::from_dict(&doc, &f); + assert!((m.ascent - 33.0 * 0.0042).abs() < 1e-12 && (m.descent + 8.0 * 0.0042).abs() < 1e-12, "{} {}", m.ascent, m.descent); +} + +/// A `/Differences` naming more distinct glyphs than [`MAX_TYPE3_GLYPHS`] leaves some unread, so +/// a nonzero `/FontBBox` stands over the boxes of those read. +#[test] +fn type3_glyphs_past_the_limit_leave_the_font_bbox_standing() { + let mut doc = Document::new_empty(); + let mut procs = vec!["1 0 0 0 1 10 d1"; MAX_TYPE3_GLYPHS]; + procs.push("1 0 0 0 1 1000 d1"); + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &procs); + let names: Vec = (0..procs.len()).map(|i| format!("g{i}")).collect(); + with_differences(&mut f, &names); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (50.0, -2.0)); +} + +/// `d1` boxes with no height in text space (all flat) give way to a usable `/FontBBox`. +#[test] +fn flat_type3_glyph_boxes_give_way_to_the_font_bbox() { + let mut doc = Document::new_empty(); + let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 5 1 5 d1"]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (50.0, -2.0)); +} + +/// A Type 3 font measured from its `/FontBBox` keeps that box through its font matrix over a +/// descriptor's `/Ascent` and `/Descent`, which would be read as 1000-unit values. +#[test] +fn a_type3_font_bbox_wins_over_descriptor_metrics() { + let mut doc = Document::new_empty(); + let small = [0.0042, 0.0, 0.0, 0.0042, 0.0, 0.0]; + let mut f = type3(&mut doc, small, [0, -50, 100, 200], &[]); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"Ascent".to_vec(), Object::Int(891)); + descriptor.set(b"Descent".to_vec(), Object::Int(-216)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let m = Metrics::from_dict(&doc, &f); + // 200 and -50 units at 0.0042, kept at the 0.5 / -0.1 em floor. + assert!((m.ascent - 0.84).abs() < 1e-9 && (m.descent + 0.21).abs() < 1e-9, "{} {}", m.ascent, m.descent); +} + +/// An `/Encoding` with a `/BaseEncoding` reaches glyphs its `/Differences` doesn't name, whose +/// boxes go unread, so a nonzero `/FontBBox` stands over the boxes of those named. +#[test] +fn a_type3_base_encoding_leaves_the_font_bbox_standing() { + let mut doc = Document::new_empty(); + let mut f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, -2, 10, 50], &["1 0 0 0 1 10 d1", "1 0 0 0 1 1000 d1"]); + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0), Object::name("g0")])); + f.set(b"Encoding".to_vec(), Object::Dict(enc)); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.ascent, m.descent), (50.0, -2.0)); +} + #[test] fn simple_fonts_decode_and_encode_through_their_encoding() { let mut doc = Document::new_empty(); @@ -334,6 +556,76 @@ fn subset_fonts_only_encode_the_glyphs_they_have() { assert_eq!(m.encode("abc"), None); } +/// #6131: pdfTeX writes `/FirstChar` below 32 and `/Widths` of 0 for the codes it never uses, the space +/// among them: the subset has no space glyph, and the words are apart by `TJ` gaps. Writing a typed space as +/// code 32 would glue the words together at width 0, so the font can't encode one and the edit sets the +/// space as a gap instead (as for a subset whose widths stop short of 32). +#[test] +fn a_subset_that_gives_the_space_no_width_cannot_encode_one() { + // pdfTeX writes Type 1 fonts; the same rule holds for a TrueType subset. + for subtype in ["TrueType", "Type1"] { + a_zero_width_space_in_a_subset_of(subtype); + } +} + +fn a_zero_width_space_in_a_subset_of(subtype: &str) { + let metrics = |base: &str, space: i64| { + let mut doc = Document::new_empty(); + // Codes 32..=98: the space, then a and b (97, 98) with a width, everything else 0. + let widths: Vec = (32..=98) + .map(|code| { + Object::Int(match code { + 32 => space, + 97 | 98 => 500, + _ => 0, + }) + }) + .collect(); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name(subtype)), + ("BaseFont", Object::name(base)), + ("FirstChar", Object::Int(32)), + ("Widths", Object::Array(widths)), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let subset = metrics("ABCDEF+Sample", 0); + assert!(subset.subset); + assert_eq!(subset.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(subset.encode("a b"), None, "no space glyph: a gap sets it"); + assert_eq!(subset.encode(" "), None); + assert_eq!(metrics("ABCDEF+Sample", 250).encode("a b").as_deref(), Some(&b"a b"[..]), "a subset that does draw the space"); + assert_eq!(metrics("Sample", 0).encode("a b").as_deref(), Some(&b"a b"[..]), "a full font keeps its space whatever the width says"); +} + +/// Only a *space* at code 32 is given up for a width of 0: a subset that puts another glyph there (a symbol +/// font, say) keeps encoding it. +#[test] +fn a_subset_keeps_a_zero_width_glyph_that_is_not_a_space_at_code_32() { + let mut doc = Document::new_empty(); + let widths: Vec = (32..=98).map(|code| Object::Int(if code == 97 { 500 } else { 0 })).collect(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(32), Object::name("Z")])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Sample")), + ("FirstChar", Object::Int(32)), + ("Widths", Object::Array(widths)), + ("Encoding", Object::Dict(enc)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.text_of(32).as_deref(), Some("Z")); + assert_eq!(m.encode("Z").as_deref(), Some(&[32][..]), "code 32 is a glyph here, not a space"); +} + #[test] fn cmap_streams_decode_within_a_size_cap() { // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. @@ -893,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/af/calc.rs b/crates/forms/src/af/calc.rs index d29956cbc2..5c8098dfbb 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -531,6 +531,10 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< let num = |s: &String| make_number(s).unwrap_or(0.0); match c { Calculate::None => None, + // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the + // empty product 1: a blank quantity × price row reads 0, and a total over the rows + // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) + Calculate::Simple { op: CalcOp::Product, fields } if fields.iter().all(|f| values(f).is_empty()) => Some("0".into()), Calculate::Simple { op, fields } => { let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); let v = match op { diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 79c5c7be02..5e8c4f085f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -360,6 +360,35 @@ fn dates_times_and_masks() { assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); } +#[test] +fn a_calculation_over_fields_without_values_is_0_for_every_operation() { + // A blank row: neither field has a value, so the form's lookup returns none. + let vals = |name: &str| -> Vec { if name == "a" { vec!["2".into()] } else { vec![] } }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + for op in CalcOp::ALL { + assert_eq!(simple(op, &["qty", "price"]), Some("0".into()), "{op:?} of empty fields"); + assert_eq!(simple(op, &[]), Some("0".into()), "{op:?} of no fields"); + } + assert_eq!(simple(CalcOp::Product, &["a", "qty"]), Some("2".into()), "a field with no value is left out"); +} + +/// A drop-down's blank option is often a single space, the value it starts with. That is a value +/// worth 0, so a row whose quantity is still blank reads 0 × price = 0. Read as "no value", it +/// would be left out and the row would read the price alone. +#[test] +fn a_blank_option_of_a_single_space_is_a_value_worth_0() { + let vals = |name: &str| -> Vec { + match name { + "qty" => vec![" ".into()], + "price" => vec!["605".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Product, &["qty", "price"]), Some("0".into())); + assert_eq!(simple(CalcOp::Product, &["qty", "none"]), Some("0".into()), "a blank quantity on a row without a price"); +} + #[test] fn validation_and_calculation() { let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 91cc352701..891a533eaa 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -399,9 +399,17 @@ fn helvetica() -> Object { Object::Dict(f) } +/// An `/MK /BC` or `/BG` colour (ISO 32000-2 Table 192) as a fill operator. A four-entry array +/// with a non-number in it, such as `[1 0 0 null]`, is CMYK with that entry 0, as annotation `/C` +/// colours are read. fn color_array(doc: &Document, mk: Option<&Dict>, key: &[u8]) -> Option { let a = doc.resolve(mk?.get(key)?); - let v: Vec = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + let a = a.as_array()?; + let v: Vec = if a.len() == 4 { + a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0)).collect() + } else { + a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect() + }; match v.as_slice() { [g] => Some(format!("{} g", n(*g))), [r, g, b] => Some(format!("{} {} {} rg", n(*r), n(*g), n(*b))), diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 34775ee75e..4cb8da3b0f 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 { @@ -106,12 +98,19 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option { if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { parts.push(t); } - cur = d.get(b"Parent").and_then(|p| p.as_ref()); + cur = parent_of(doc, d); } parts.reverse(); (!parts.is_empty()).then(|| parts.join(".")) } +/// The field `d` names as its `/Parent`, when that is a dictionary. A reference to an object the +/// file doesn't hold reads as `null`, and a `null` entry is the same as an absent one (ISO +/// 32000-2 §7.3.9): the field is then a root, as if it named no parent. +fn parent_of(doc: &Document, d: &Dict) -> Option { + d.reference(b"Parent").filter(|p| doc.get(*p).as_dict().is_some()) +} + /// 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; @@ -331,13 +330,19 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); + let mut by_parent: std::collections::HashMap> = std::collections::HashMap::new(); let pages = page_refs(doc); for (i, p) in pages.iter().enumerate() { if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); + if let Some(r) = e.as_ref() + && let std::collections::hash_map::Entry::Vacant(slot) = page_of.entry(r) + { + slot.insert(i); + annot_index.insert(r, k); + if let Some(parent) = widget_parent(doc, r) { + by_parent.entry(parent).or_default().push(r); + } } } } @@ -351,18 +356,24 @@ pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { None => Inherited::default(), }; let mut out = Vec::new(); + let listed_roots: Vec = af + .as_ref() + .and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())) + .unwrap_or_default() + .iter() + .filter_map(Object::as_ref) + .collect(); let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), + roots: listed_roots.iter().copied().collect(), + path: Vec::new(), + by_parent, scripts: script_budget(), values: DecodeBudget::new(MAX_VALUE_BYTES, 0), }; - if let Some(af) = &af { - for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - if let Some(r) = f.as_ref() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } + for r in listed_roots { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); } let listed = out.len(); adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); @@ -413,7 +424,7 @@ fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet, /// A widget belongs to the first field that lists it, however often it is listed. widgets: std::collections::HashSet, + /// The fields `/Fields` lists: root fields, with no ancestors (ISO 32000-2 §12.7.3, Table 224). + roots: std::collections::HashSet, + /// The fields being walked, outermost first, whose `/Kids` are being read. + path: Vec, + /// The page widgets (no `/T`) that name each field as their `/Parent`, in page-annotation order. + by_parent: std::collections::HashMap>, scripts: DecodeBudget, values: DecodeBudget, } +/// The field a page widget `r` names as its `/Parent`, when `r` is a widget and not a field of its +/// own (no `/T`). +fn widget_parent(doc: &Document, r: ObjRef) -> Option { + let obj = doc.get(r); + let d = obj.as_dict()?; + if d.name(b"Subtype") != Some(b"Widget") || d.contains(b"T") { + return None; + } + d.reference(b"Parent") +} + +/// Whether `k`, listed in the `/Kids` of field `r`, is not a child of `r` after all: `r` itself or +/// a field `r` descends from (a loop), or a root field (one `/Fields` lists) that doesn't name `r` +/// as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), and a +/// child names its parent (§12.7.4.1, Table 226): only the `/Kids` entry says otherwise, so the +/// field is read as the root it is, and `r` keeps its other kids. +fn not_a_child(doc: &Document, seen: &Seen, r: ObjRef, k: ObjRef) -> bool { + k == r || seen.path.contains(&k) || (seen.roots.contains(&k) && doc.get(k).as_dict().and_then(|d| d.reference(b"Parent")) != Some(r)) +} + /// Read the field at `r` and the fields under it into `out`, with what they inherit from /// `parent`: kids with a name are fields of their own, kids without are this field's widgets. fn walk( @@ -483,11 +520,14 @@ 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: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + 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: 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); } + seen.path.pop(); return; } // A field of no kind this reads is skipped before it claims widgets or scripts, which a later @@ -503,7 +543,10 @@ fn walk( Some(b"Sig") => FieldKind::Signature, _ => return, }; - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + // 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() }; 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 31e6812e6b..78dcdcff28 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -42,6 +42,8 @@ pub struct FieldChange { pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. pub display: Option, + /// Move/resize the first widget, in PDF user space. + pub rect: Option<[f64; 4]>, } /// What a script did. @@ -51,6 +53,8 @@ pub struct ScriptResult { pub rc: bool, /// `event.value` afterwards. pub value: String, + /// Whether the script assigned `event.value`, even the value it already had. + pub value_set: bool, pub changes: Vec, /// Why it rejected the value (its alert), for the error shown. pub message: Option, @@ -82,7 +86,7 @@ pub struct NoScripts; impl Scripts for NoScripts { fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult { - ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None } } } @@ -173,6 +177,9 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: } } } + if let Some(rect) = c.rect { + crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?; + } } Ok(()) } @@ -191,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() }; @@ -199,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 59903748cd..205c7c8129 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -305,6 +305,85 @@ fn field_flags_reject_values_outside_the_unsigned_32_bit_domain() { } } +/// A `/Kids` entry that loops back (the field itself, or a field it descends from), or names a root +/// field that doesn't name this one as its `/Parent`, is not a child (ISO 32000-2 §12.7.3, +/// Table 224; §12.7.4.1, Table 226). The field keeps its other kids: here its radio buttons. +#[test] +fn kids_that_loop_back_or_are_roots_are_not_children() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 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 [6 0 R 9 0 R 12 0 R 15 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 7 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /Off /Kids [5 0 R 6 0 R] >>".into(), // 5 lists itself + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 65 715] /P 3 0 R /AP << /N << /S 11 0 R /Off 11 0 R >> >> >>".into(), // 6 + "<< /FT /Btn /Ff 49152 /T (left) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 7 lists the root 8 + "<< /FT /Btn /Ff 49152 /T (right) /V /Off /Kids [7 0 R 12 0 R] >>".into(), // 8 lists the root 7 + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 650 65 665] /P 3 0 R /AP << /N << /A 11 0 R /Off 11 0 R >> >> >>".into(), // 9 + "<< /T (group) /Kids [13 0 R] >>".into(), // 10 + "<< /Length 0 >> +stream + +endstream" + .into(), // 11 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [50 600 65 615] /P 3 0 R /AP << /N << /B 11 0 R /Off 11 0 R >> >> >>".into(), // 12 + "<< /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >>".into(), // 13 + "<< /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >>".into(), // 14 lists its parent + "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 550 250 570] /P 3 0 R >>".into(), // 15 + ]; + let doc = document(&objs); + let all = fields(&doc); + let listed: Vec<(&str, FieldKind, Vec)> = + all.iter().map(|f| (f.name.as_str(), f.kind, f.widgets.iter().map(|w| w.obj).collect())).collect(); + let r = |num| ObjRef::new(num, 0); + assert_eq!( + listed, + [ + ("size", FieldKind::Radio, vec![r(6)]), + ("left", FieldKind::Radio, vec![r(9)]), + ("right", FieldKind::Radio, vec![r(12)]), + ("group.inner.deep", FieldKind::Text, vec![r(15)]), + ] + ); +} + +/// A page widget whose `/Parent` names a field is one of that field's widgets even when the +/// field's `/Kids` leave it out: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here +/// `radio` lists itself and one of its two widgets, and `pair` and `other` list each other (roots, +/// so not children) and none of their widgets. Each field gets every widget naming it, once. +#[test] +fn widgets_naming_a_field_that_does_not_list_them_join_it() { + let w = |parent: u32, state: &str, y: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /Rect [50 {y} 65 {}] /P 3 0 R /AP << /N << /{state} 10 0 R /Off 10 0 R >> >> >>", + y + 15 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 6 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 7 0 R 11 0 R 12 0 R 13 0 R] >>".into(), // 3 + w(5, "A", 700), // 4: not in 5's /Kids + "<< /FT /Btn /Ff 49152 /T (radio) /V /Off /Kids [5 0 R 7 0 R] >>".into(), // 5 + "<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 6 + w(5, "B", 650), // 7: in 5's /Kids + "<< /FT /Btn /Ff 49152 /T (pair) /V /Off /Kids [9 0 R 11 0 R] >>".into(), // 8 lists the root 9 + "<< /FT /Btn /Ff 49152 /T (other) /V /Off /Kids [8 0 R] >>".into(), // 9 lists the root 8 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 10 + w(8, "C", 600), // 11: in 8's /Kids + w(8, "D", 550), // 12: not in 8's /Kids + w(9, "E", 500), // 13: not in 9's /Kids + ]; + let doc = document(&objs); + let (all, adopted) = enumerate(&doc); + let listed: Vec<(&str, Vec)> = all.iter().map(|f| (f.name.as_str(), f.widgets.iter().map(|w| w.obj).collect())).collect(); + let r = |num| ObjRef::new(num, 0); + assert_eq!(listed, [("radio", vec![r(7), r(4)]), ("pair", vec![r(11), r(12)]), ("other", vec![r(13)])]); + assert_eq!(adopted, 0, "no widget is read as a field of its own"); + let states: Vec<_> = field(&all, "radio").widgets.iter().filter_map(|w| w.on_state.as_deref()).collect(); + assert_eq!(states, ["B", "A"]); +} + /// Some writers leave every field out of `/Fields` and put the widgets only in the page /// annotations: the tree walk alone finds nothing, and Acrobat and the browsers fill them anyway. /// The pages' widgets are adopted as fields, after the listed tree fields. @@ -353,6 +432,37 @@ fn fields_listed_only_on_the_pages_are_adopted() { assert_eq!(adopted_page_fields(&doc), 4); } +/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary, +/// has no parent: the reference reads as `null`, the same as an absent entry (ISO +/// 32000-2 §7.3.9). It is adopted as a field of its own, under its own name, and can be filled. +#[test] +fn a_widget_whose_parent_is_missing_is_a_field_of_its_own() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 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 7 0 R 8 0 R] >>".into(), // 3 + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (city) /V (Oslo) /Parent 40 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 5 parent not in the file + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (zip) /Parent 9 0 R /Rect [50 660 250 680] /P 3 0 R >>".into(), // 7 parent is no dictionary + "<< /Type /Annot /Subtype /Widget /Parent 40 0 R /Rect [400 700 415 715] /P 3 0 R >>".into(), // 8 no /FT or /T: not a field + "[1 2 3]".into(), // 9 + ]; + let mut doc = document(&objs); + assert_combined_matches_legacy(&doc, 2); + let all = fields(&doc); + let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["city", "zip"]); + let city = field(&all, "city"); + assert_eq!( + (city.kind, city.value.as_slice(), city.widgets.len(), city.widgets[0].page), + (FieldKind::Text, &["Oslo".to_string()][..], 1, Some(0)) + ); + set_value(&mut doc, "zip", &FieldValue::Text("0150".into())).unwrap(); + let doc = reopen(&doc); + assert_eq!(field(&fields(&doc), "zip").value, ["0150"]); +} + /// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on /// states by position (`/0`, `/1`): the export values select them, as in Acrobat. #[test] @@ -1599,7 +1709,7 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { struct Ran; impl Scripts for Ran { fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { - ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: "ran".into(), value_set: true, changes: Vec::new(), message: None } } } let mut doc = one_page(); @@ -1858,6 +1968,59 @@ fn missing_fields_and_built_names_are_flagged() { assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); } +/// An order form's rows, as a public one lays them out: an editable drop-down quantity whose blank +/// option, and starting value, is a single space, a prefilled price, the row's product and a total +/// over the rows. A blank quantity is 0, so its row is 0; a quantity cleared to nothing on a row +/// without a price leaves the product nothing to multiply, and it is 0 too, not 1. +#[test] +fn an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows() { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [250 {y} 350 {}] /P 3 0 R {rest} >>", y + 20) + }; + let qty = |name: &str, y: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 393216 /T ({name}) /Opt [( ) (1) (2) (3)] /V ( ) /Rect [50 {y} 150 {}] /P 3 0 R >>", + y + 20 + ) + }; + let prd = |row: u32| { + format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"PRD\", new Array \\(\"QTY {row}\", \"price {row}\"\\)\\);) >> >>") + }; + let objs: Vec = vec![ + "<< /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 [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + qty("QTY 0", 700), + tx("price 0", 700, "/V (605)"), + tx("Total 0", 700, &format!("/V (0) {}", prd(0))), + qty("QTY 1", 660), + tx("price 1", 660, "/V (780)"), + tx("Total 1", 660, &format!("/V (0) {}", prd(1))), + qty("QTY 2", 620), + tx("price 2", 620, ""), + tx("Total 2", 620, &format!("/V (0) {}", prd(2))), + tx( + "Total", + 580, + r#"/V (0) /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array \("Total 0", "Total 1", "Total 2"\)\);) >> >>"#, + ), + ]; + let mut doc = document(&objs); + let totals = |doc: &Document| { + let all = fields(doc); + ["Total 0", "Total 1", "Total 2", "Total"].map(|n| field(&all, n).value.first().cloned().unwrap_or_default()) + }; + recalculate(&mut doc).unwrap(); + assert_eq!(totals(&doc), ["0", "0", "0", "0"], "every quantity blank: a single space is worth 0"); + set_value(&mut doc, "QTY 0", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "only the chosen row"); + set_value(&mut doc, "QTY 2", &FieldValue::Text(String::new())).unwrap(); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "a quantity cleared on a row without a price"); + let doc = reopen(&doc); + assert_eq!(totals(&doc), ["1210", "0", "0", "1210"], "kept across a save"); +} + /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. @@ -3013,20 +3176,22 @@ fn recalc_chain_where_script_changes_checkbox() { ScriptResult { rc: true, value: "calc1".into(), + value_set: true, changes: vec![FieldChange { name: "agree".into(), value: Some(vec!["Yes".into()]), read_only: None, required: None, display: None, + rect: None, }], message: None, } } else if script == "read_box" { let box_val = fields.iter().find(|f| f.name == "agree").and_then(|f| f.value.first()).cloned().unwrap_or_default(); - ScriptResult { rc: true, value: format!("box_was_{box_val}"), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: format!("box_was_{box_val}"), value_set: true, changes: Vec::new(), message: None } } else { - ScriptResult { rc: true, value: String::new(), changes: Vec::new(), message: None } + ScriptResult { rc: true, value: String::new(), value_set: false, changes: Vec::new(), message: None } } } } @@ -3047,6 +3212,61 @@ fn recalc_chain_where_script_changes_checkbox() { assert_eq!(field(&all, "result").value, ["box_was_Yes"], "second recalc saw the normalized checkbox value"); } +/// A calculation returning a combo option's display text stores the option's export value in /V +/// and selects it in /I, as choosing it does. +#[test] +fn a_calculated_combo_stores_the_export_value_of_the_option_it_names() { + struct Pick; + impl Scripts for Pick { + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None } + } + } + let mut doc = one_page(); + let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false }; + add_field(&mut doc, 0, [10.0, 10.0, 100.0, 30.0], &kind, Some("country")).unwrap(); + let obj = field(&fields(&doc), "country").obj; + let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]); + doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "Canada"), pair("fr", "France")]))).unwrap(); + set_field_script(&mut doc, "country", "calculate", Some("France")).unwrap(); + assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 1); + let dict = doc.get(obj).as_dict().cloned().unwrap(); + assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr")))); + assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)]))); + assert_eq!(field(&fields(&doc), "country").value, ["fr"]); + assert_eq!(recalculate_with(&mut doc, &mut Pick).unwrap(), 0, "the display text names the value it already has"); +} + +/// An export value names its own option even when an earlier option shows that text, whether a +/// calculation or the user sets it. +#[test] +fn an_export_value_wins_over_an_earlier_options_display_text() { + struct Pick; + impl Scripts for Pick { + fn run(&mut self, _event: FieldEvent, script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { rc: true, value: script.into(), value_set: true, changes: Vec::new(), message: None } + } + } + let mut doc = one_page(); + let pair = |e: &str, d: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(d))]); + let mut objs = Vec::new(); + for (name, y) in [("calculated", 10.0), ("chosen", 40.0)] { + let kind = NewField::Combo { options: vec!["ca".into(), "fr".into()], editable: false }; + add_field(&mut doc, 0, [10.0, y, 100.0, y + 20.0], &kind, Some(name)).unwrap(); + let obj = field(&fields(&doc), name).obj; + doc.update_dict(obj, |d| d.set(b"Opt".to_vec(), Object::Array(vec![pair("ca", "fr"), pair("fr", "France")]))).unwrap(); + objs.push(obj); + } + set_field_script(&mut doc, "calculated", "calculate", Some("fr")).unwrap(); + recalculate_with(&mut doc, &mut Pick).unwrap(); + set_value(&mut doc, "chosen", &FieldValue::Choice(vec!["fr".into()])).unwrap(); + for obj in objs { + let dict = doc.get(obj).as_dict().cloned().unwrap(); + assert_eq!(dict.get(b"V").cloned(), Some(Object::String(PdfString::text("fr")))); + assert_eq!(dict.get(b"I").cloned(), Some(Object::Array(vec![Object::Int(1)]))); + } +} + #[test] fn partial_appearance_keeps_custom_default_appearance_and_font_resources() { let mut doc = fixture(); @@ -3414,6 +3634,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`). @@ -5731,3 +6042,35 @@ fn locked_button_export_edits_leave_the_document_unchanged() { assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); } } + +/// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C` +/// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB. +#[test] +fn a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero() { + let widget = |name: &str, mk: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << {mk} >> /BS << /W 1 /S /S >> >>") + }; + let objs: Vec = vec![ + "<< /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 [5 0 R 6 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] >>".into(), + widget("cmyk", "/BG [0 0 1 null] /BC [1 0 0 null]"), + widget("rgb", "/BG [0 0 1] /BC [1 0 0]"), + ]; + let mut doc = document(&objs); + let off = |doc: &mut Document, name: &str| { + redraw_field(doc, name).unwrap(); + let all = fields(doc); + let w = field(&all, name).widgets[0].obj; + let wd = doc.get(w).as_dict().cloned().unwrap(); + let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + let cmyk = off(&mut doc, "cmyk"); + assert!(cmyk.contains("0 0 1 0 k") && cmyk.contains("1 0 0 0 K"), "CMYK background and border: {cmyk}"); + let rgb = off(&mut doc, "rgb"); + assert!(rgb.contains("0 0 1 rg") && rgb.contains("1 0 0 RG"), "three numbers stay RGB: {rgb}"); +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index f2176e7fd5..3ac8efab91 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -163,6 +163,11 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result let current = f.value.first().cloned().unwrap_or_default(); let v = if let Some(js) = &f.actions.scripts.calculate { let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); + // A script may set its own field through the field object (`getField("Total").value + // = …`) rather than through `event.value`. `apply_changes` leaves the event's own + // field to the event, so that value is the result unless the script also assigned + // `event.value`, even the value it already had. + let own = r.changes.iter().find(|c| c.name == f.name).and_then(|c| c.value.as_ref()).map(|v| v.first().cloned().unwrap_or_default()); scripting::apply_changes(doc, &r.changes, &f.name)?; if !r.changes.is_empty() { all = fields(doc); @@ -170,13 +175,37 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result if !r.rc { continue; } - r.value + match own { + Some(v) if !r.value_set => v, + _ => r.value, + } } else { let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; v }; + // A combo box result naming an option, by export value or display text, is stored as that + // option's export value (the first element of an /Opt pair, ISO 32000-2 Table 234), as + // `write_value` stores a choice, so /V, /I and exported form data agree. + let combo = f.kind == FieldKind::Combo; + let matched = if combo { option_named(&f, &v) } else { None }; + let v = match matched.and_then(|i| f.options.get(i)) { + Some((export, _)) => export.clone(), + None => v, + }; if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; + // A combo box's /I (its option indices, ISO 32000-2 Table 234) follows the value, as + // `write_value` keeps it, so the selection kept never disagrees with the value. + let picked = matched.and_then(|i| i64::try_from(i).ok()); + doc.update_dict(f.obj, |d| { + d.set(b"V".to_vec(), PdfString::text(&v)); + match picked { + Some(i) if combo => d.set(b"I".to_vec(), Object::Array(vec![Object::Int(i)])), + _ if combo => { + d.remove(b"I"); + } + _ => {} + } + })?; redraw(doc, &f, std::slice::from_ref(&v), scripts)?; if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { target.value = vec![v]; @@ -187,6 +216,12 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result Ok(changed) } +/// The index of the choice option `v` names: the option whose export value it is, else the first +/// whose display text it is, so a display text never shadows a later option's export value. +fn option_named(f: &Field, v: &str) -> Option { + f.options.iter().position(|(e, _)| e == v).or_else(|| f.options.iter().position(|(_, d)| d == v)) +} + /// The fields whose calculation differs between two readings of a form, matched by field object /// and named as in `after`: the formulas an edit rewrote, such as a rename's. pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { @@ -280,7 +315,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr // Accept export values or display texts; free text only in editable combo boxes. let mut exports = Vec::new(); for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { + match option_named(f, v).and_then(|i| f.options.get(i)) { Some((e, _)) => exports.push(e.clone()), None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), None if f.options.is_empty() => exports.push(v.clone()), diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index e977457e2b..54274bb140 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -94,6 +94,36 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { } function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) }; + let rect_get = getter!(ctx, n, |f, c| match f.rect { + Some(rect) => JsArray::from_iter(rect.into_iter().map(JsValue::from).collect::>(), c).into(), + None => JsValue::null(), + }); + let rect_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let value = arg(args, 0); + let object = value.as_object().filter(|o| o.is_array()).ok_or_else(|| error("rect must be an array of four finite numbers"))?; + let array = JsArray::from_object(object.clone())?; + if array.length(c)? != 4 { + return Err(error("rect must be an array of four finite numbers")); + } + let mut rect = [0.0; 4]; + for (i, coordinate) in rect.iter_mut().enumerate() { + *coordinate = array.get(i as u64, c)?.to_number(c)?; + } + if !rect.iter().all(|coordinate| coordinate.is_finite()) { + return Err(error("rect coordinates must be finite")); + } + let width = (rect[2] - rect[0]).abs(); + let height = (rect[3] - rect[1]).abs(); + if !width.is_finite() || !height.is_finite() || width < 4.0 || height < 4.0 { + return Err(error("rect must be at least 4 points wide and high")); + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + write(c, name, |f| f.rect = Some(rect))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { let f = read(c, name)?; @@ -150,6 +180,7 @@ pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) + .accessor(js_string!("rect"), Some(rect_get), Some(rect_set), rw) .function(get_item_at, js_string!("getItemAt"), 2) .function(is_box_checked, js_string!("isBoxChecked"), 1) .function(check_this_box, js_string!("checkThisBox"), 2) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index cfad619938..06ff21f438 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -245,4 +245,42 @@ mod tests { assert_eq!(num_text(-1e20), "-100000000000000000000"); assert_eq!(num_text(f64::MAX).len(), 309); } + + #[test] + fn decode_xml_and_html_entities() { + let eval = |script: &str| { + let root = XfaNode::default(); + let out = run_formcalc(script, &XfaEvent::default(), &XfaDoc::default(), &root, Limits::default()); + assert!(out.error.is_none(), "{:?}", out.error); + out.result.unwrap_or_default() + }; + + // `'` in both xml and html + assert_eq!(eval(r#"Decode("It's <ok> & "fine"", "xml")"#), "It's & \"fine\""); + assert_eq!(eval(r#"Decode("It's", "html")"#), "It's"); + + // Decimal and hex numeric character references (case-insensitive #x/#X and hex digits, leading zeros) + assert_eq!(eval(r#"Decode("ABCDe€😀", "xml")"#), "ABCDe€😀"); + assert_eq!(eval(r#"Decode("ABCé", "html")"#), "ABCé"); + + // Invalid / surrogate / out-of-range / zero / malformed numeric references stay literal without panicking + assert_eq!( + eval(r#"Decode("� � � � � &#; &#x; A G; &;", "xml")"#), + "� � � � � &#; &#x; A G; &;" + ); + + // Single-pass decoding: `&lt;` and `&lt;` must decode to `<`, never `<` + assert_eq!(eval(r#"Decode("&lt; &#65; &lt; &apos;", "xml")"#), "< A < '"); + assert_eq!(eval(r#"Decode("&nbsp; &eacute;", "html")"#), "  é"); + + // HTML named entities decode in "html" mode but remain literal in "xml" mode + assert_eq!(eval(r#"Decode("café ©€—αΩ♥", "html")"#), "café\u{00A0}©€—αΩ♥"); + assert_eq!(eval(r#"Decode("café ©€", "xml")"#), "café ©€"); + // Case sensitivity of HTML named entities (`É` vs `é`, `″` vs `′`, `‡` vs `†`) + assert_eq!(eval(r#"Decode("Éé″′‡†&bogus;", "html")"#), "Éé″′‡†&bogus;"); + // Non-ASCII characters after `&` or `&#` and long runs of `&` without `;` + assert_eq!(eval(r#"Decode("&é; &#é; &#xé; &é", "html")"#), "&é; &#é; &#xé; &é"); + let amps = "&".repeat(5_000); + assert_eq!(eval(&format!(r#"Decode("{amps}'", "xml")"#)), format!("{amps}'")); + } } diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index c3b7c7f1f2..e9e3bf90be 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -2,7 +2,7 @@ use super::ast::Expr; use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date}; use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; -use super::words::word_num; +use super::words::{decode_entities, word_num}; use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { @@ -200,7 +200,8 @@ impl Interp<'_> { let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; Val::Str(match (lower.as_str(), kind.as_str()) { ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("decode", "xml") => decode_entities(&s, false), + ("decode", "html") => decode_entities(&s, true), ("encode", _) => s .bytes() .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index 0b2dfe4b94..c5b9cb49db 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -292,15 +292,23 @@ impl Interp<'_> { } _ => Ok(Val::Null), }, - Val::Layout => match m.as_str() { - "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), - "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), - "relayout" | "relayoutpagearea" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) + Val::Layout => { + // No layout method reads its arguments, but each is evaluated once, for what it + // does (a message box in an argument still shows), as the other objects' are. + for i in 0..args.len() { + self.arg(args, i)?; } - _ => Ok(Val::Num(0.0)), - }, + match m.as_str() { + // On screen every page is a sheet of its own (see the JavaScript `sheet`). + "page" | "abspage" | "sheet" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), + "pagecount" | "abspagecount" | "sheetcount" => Ok(Val::Num(self.h.doc.page_count as f64)), + "relayout" | "relayoutpagearea" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Num(0.0)), + } + } Val::Xfa => match m.as_str() { "resolvenode" | "resolvenodes" => { let s = self.arg_str(args, 0)?; diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index 46af19f761..9d01abf331 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -49,52 +49,12 @@ pub(super) fn lex(src: &str) -> Result, String> { } } '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); + let tok_line = line; + let s = lex_string(&chars, &mut i, &mut line)?; + out.push(Token { tok: Tok::Str(s), line: tok_line }); } c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + let n = lex_number(&chars, &mut i, line)?; out.push(Token { tok: Tok::Num(n), line }); } '!' => { @@ -127,3 +87,122 @@ pub(super) fn lex(src: &str) -> Result, String> { out.push(Token { tok: Tok::Eof, line }); Ok(out) } + +fn hex_u32(digits: &[char]) -> Option { + let mut v = 0u32; + for &c in digits { + v = (v << 4) | c.to_digit(16)?; + } + Some(v) +} + +/// FormCalc string escape (`\uXXXX` or `\UXXXXXXXX`, XFA 3.3 part 8 p. 1054); any other `\` stays literal. +fn unicode_escape(chars: &[char], at: usize) -> Option<(char, usize)> { + if chars.get(at) != Some(&'\\') { + return None; + } + match chars.get(at + 1)? { + 'u' => { + let hi = hex_u32(chars.get(at + 2..at + 6)?)?; + if (0xD800..=0xDBFF).contains(&hi) + && chars.get(at + 6..at + 8) == Some(&['\\', 'u']) + && let Some(lo) = chars.get(at + 8..at + 12).and_then(hex_u32).filter(|lo| (0xDC00..=0xDFFF).contains(lo)) + { + let scalar = 0x1_0000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + return Some((char::from_u32(scalar)?, 12)); + } + Some((char::from_u32(hi)?, 6)) + } + 'U' => { + let code = hex_u32(chars.get(at + 2..at + 10)?)?; + Some((char::from_u32(code)?, 10)) + } + _ => None, + } +} + +fn lex_string(chars: &[char], i: &mut usize, line: &mut usize) -> Result { + let mut s = String::new(); + *i += 1; + loop { + match chars.get(*i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(*i + 1) == Some(&'"') => { + s.push('"'); + *i += 2; + } + Some('"') => { + *i += 1; + return Ok(s); + } + Some(&ch) => { + if let Some((decoded, step)) = unicode_escape(chars, *i) { + s.push(decoded); + *i += step; + } else { + if ch == '\n' { + *line += 1; + } + s.push(ch); + *i += 1; + } + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } +} + +/// Whether `c` may start a FormCalc identifier. +fn is_ident_start(c: char) -> bool { + c.is_alphabetic() || c == '_' || c == '$' +} + +/// End index of a numeric exponent starting at `at` (`e`/`E`, optional sign, digits), or `None` +/// if no valid exponent starts there or if identifier characters immediately follow the digits. +fn exponent_end(chars: &[char], at: usize) -> Option { + if !matches!(chars.get(at), Some('e' | 'E')) { + return None; + } + let mut j = at + 1; + if matches!(chars.get(j), Some('+' | '-')) { + j += 1; + } + if !chars.get(j).is_some_and(char::is_ascii_digit) { + return None; + } + while chars.get(j).is_some_and(char::is_ascii_digit) { + j += 1; + } + if chars.get(j).is_some_and(|&c| c.is_alphanumeric() || c == '_' || c == '$') { + return None; + } + Some(j) +} + +/// Scan a FormCalc number literal starting at `*i`, stopping before `.member` or `..` while +/// rejecting malformed multi-dot literals such as `1.2.3`. +fn lex_number(chars: &[char], i: &mut usize, line: usize) -> Result { + let start = *i; + let mut seen_dot = false; + while *i < chars.len() { + let next = chars.get(*i + 1).copied(); + let next_is_member = next.is_some_and(is_ident_start) && exponent_end(chars, *i + 1).is_none(); + if chars[*i].is_ascii_digit() { + *i += 1; + } else if chars[*i] == '.' && !seen_dot && next != Some('.') && !next_is_member { + seen_dot = true; + *i += 1; + } else if chars[*i] == '.' && seen_dot && next.is_some_and(|c| c.is_ascii_digit()) { + return Err(format!("line {line}: bad number")); + } else { + break; + } + } + if let Some(end) = exponent_end(chars, *i) { + *i = end; + } + let text: String = chars[start..*i].iter().collect(); + text.parse().map_err(|_| format!("line {line}: bad number {text}")) +} diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index fceedadf5f..871d3874de 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -341,10 +341,7 @@ impl Parser { Ok(Expr::Neg(Box::new(self.unary()?))) } else if self.eat_op("+") { self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } + } else if self.eat_kw("not") || self.eat_kw("!") { Ok(Expr::Not(Box::new(self.unary()?))) } else { self.postfix() @@ -449,3 +446,31 @@ pub(super) fn parse(src: &str) -> Result, String> { t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unary_not_does_not_swallow_following_bang() { + assert_eq!(parse("not !1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))])); + assert_eq!(parse("not !flag"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Ident("flag".into()))))))])); + assert_eq!(parse("!!1"), Ok(vec![Stmt::Expr(Expr::Not(Box::new(Expr::Not(Box::new(Expr::Num(1.0))))))])); + } + + #[test] + fn string_literals_decode_xfa_unicode_escapes_and_keep_other_backslashes() { + assert_eq!(parse(r#""\u0041\u20ac\U00000042\U0001F600""#), Ok(vec![Stmt::Expr(Expr::Str("A\u{20ac}B\u{1F600}".into()))])); + assert_eq!(parse(r#""\uD83D\uDE00""#), Ok(vec![Stmt::Expr(Expr::Str("\u{1F600}".into()))])); + assert_eq!(parse(r#""C:\temp\new\u12""#), Ok(vec![Stmt::Expr(Expr::Str(r"C:\temp\new\u12".into()))])); + } + + #[test] + fn number_literals_stop_before_second_dot_for_member_access() { + assert_eq!(parse("1.5.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.5)), "rawValue".into()))])); + assert_eq!(parse("1.rawValue"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "rawValue".into()))])); + assert_eq!(parse("1.e2foo"), Ok(vec![Stmt::Expr(Expr::Member(Box::new(Expr::Num(1.0)), "e2foo".into()))])); + assert_eq!(parse("1.e2"), Ok(vec![Stmt::Expr(Expr::Num(100.0))])); + assert!(parse("1.2.3").is_err(), "a second decimal point followed by digits is a bad number"); + } +} diff --git a/crates/js/src/formcalc/words.rs b/crates/js/src/formcalc/words.rs index d0d119abb5..a6a5141920 100644 --- a/crates/js/src/formcalc/words.rs +++ b/crates/js/src/formcalc/words.rs @@ -73,3 +73,140 @@ pub(super) fn word_num(n: f64, cents: bool) -> String { } out } + +/// Decode XML (`html == false`) or HTML (`html == true`) character and entity references in a +/// single pass so `&lt;` becomes `<` rather than `<`, and invalid or unrecognized +/// references remain literal without panicking. +/// +/// When `html` is `false`, only the five predefined XML entities (`<`, `>`, `&`, +/// `"`, `'`) and numeric character references (`&#...;`, `&#x...;`, `&#X...;`) are +/// decoded. When `html` is `true`, W3C HTML 4 / XHTML 1.0 named entities are decoded as well. +pub(super) fn decode_entities(s: &str, html: bool) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(rest.get(..at).unwrap_or_default()); + rest = rest.get(at..).unwrap_or_default(); + match entity_at(rest, html) { + Some((c, len)) => { + out.push(c); + rest = rest.get(len..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = rest.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + +/// Parse a character reference starting at the leading `&` of `s`, returning the decoded `char` +/// and the byte length of the reference (including `&` and `;`). +fn entity_at(s: &str, html: bool) -> Option<(char, usize)> { + if let Some(body) = s.strip_prefix("&#") { + let (radix, digits, prefix_len) = match body.strip_prefix(['x', 'X']) { + Some(hex) => (16u32, hex, 3usize), + None => (10u32, body, 2usize), + }; + let mut value = 0u32; + let mut any_digit = false; + for (i, c) in digits.char_indices() { + match c.to_digit(radix) { + Some(d) => { + any_digit = true; + value = value.checked_mul(radix)?.checked_add(d)?; + if value > 0x10_FFFF { + return None; + } + } + None if c == ';' && any_digit && value != 0 => { + return Some((char::from_u32(value)?, prefix_len + i + 1)); + } + None => return None, + } + } + return None; + } + let body = s.strip_prefix('&')?; + let semi = body.bytes().take(9).position(|b| b == b';')?; + let name = body.get(..semi).filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric()))?; + let ch = match name { + "lt" => '<', + "gt" => '>', + "amp" => '&', + "quot" => '"', + "apos" => '\'', + _ if html => html_named_entity(name)?, + _ => return None, + }; + Some((ch, 1 + semi + 1)) +} + +/// Look up a W3C HTML 4 / XHTML 1.0 named entity (excluding the five XML entities). +fn html_named_entity(name: &str) -> Option { + if let Some(idx) = LATIN1_ENTITIES.iter().position(|&n| n == name) { + return char::from_u32(0xA0 + idx as u32); + } + HTML_ENTITIES.iter().find(|&&(n, _)| n == name).map(|&(_, c)| c) +} + +/// ISO-8859-1 named entities in code-point order `U+00A0..=U+00FF`. Entries must remain ordered +/// by successive code points starting at `0xA0` and must not be reordered alphabetically, since +/// [`html_named_entity`] maps each entry's index to `0xA0 + idx as u32`. +#[rustfmt::skip] +const LATIN1_ENTITIES: [&str; 96] = [ + "nbsp", "iexcl", "cent", "pound", "curren", "yen", "brvbar", "sect", "uml", "copy", "ordf", "laquo", "not", "shy", "reg", "macr", + "deg", "plusmn", "sup2", "sup3", "acute", "micro", "para", "middot", "cedil", "sup1", "ordm", "raquo", "frac14", "frac12", "frac34", "iquest", + "Agrave", "Aacute", "Acirc", "Atilde", "Auml", "Aring", "AElig", "Ccedil", "Egrave", "Eacute", "Ecirc", "Euml", "Igrave", "Iacute", "Icirc", "Iuml", + "ETH", "Ntilde", "Ograve", "Oacute", "Ocirc", "Otilde", "Ouml", "times", "Oslash", "Ugrave", "Uacute", "Ucirc", "Uuml", "Yacute", "THORN", "szlig", + "agrave", "aacute", "acirc", "atilde", "auml", "aring", "aelig", "ccedil", "egrave", "eacute", "ecirc", "euml", "igrave", "iacute", "icirc", "iuml", + "eth", "ntilde", "ograve", "oacute", "ocirc", "otilde", "ouml", "divide", "oslash", "ugrave", "uacute", "ucirc", "uuml", "yacute", "thorn", "yuml", +]; + +#[rustfmt::skip] +const HTML_ENTITIES: &[(&str, char)] = &[ + ("OElig", '\u{0152}'), ("oelig", '\u{0153}'), ("Scaron", '\u{0160}'), ("scaron", '\u{0161}'), ("Yuml", '\u{0178}'), ("fnof", '\u{0192}'), ("circ", '\u{02C6}'), ("tilde", '\u{02DC}'), + ("Alpha", '\u{0391}'), ("Beta", '\u{0392}'), ("Gamma", '\u{0393}'), ("Delta", '\u{0394}'), ("Epsilon", '\u{0395}'), ("Zeta", '\u{0396}'), ("Eta", '\u{0397}'), ("Theta", '\u{0398}'), + ("Iota", '\u{0399}'), ("Kappa", '\u{039A}'), ("Lambda", '\u{039B}'), ("Mu", '\u{039C}'), ("Nu", '\u{039D}'), ("Xi", '\u{039E}'), ("Omicron", '\u{039F}'), ("Pi", '\u{03A0}'), + ("Rho", '\u{03A1}'), ("Sigma", '\u{03A3}'), ("Tau", '\u{03A4}'), ("Upsilon", '\u{03A5}'), ("Phi", '\u{03A6}'), ("Chi", '\u{03A7}'), ("Psi", '\u{03A8}'), ("Omega", '\u{03A9}'), + ("alpha", '\u{03B1}'), ("beta", '\u{03B2}'), ("gamma", '\u{03B3}'), ("delta", '\u{03B4}'), ("epsilon", '\u{03B5}'), ("zeta", '\u{03B6}'), ("eta", '\u{03B7}'), ("theta", '\u{03B8}'), + ("iota", '\u{03B9}'), ("kappa", '\u{03BA}'), ("lambda", '\u{03BB}'), ("mu", '\u{03BC}'), ("nu", '\u{03BD}'), ("xi", '\u{03BE}'), ("omicron", '\u{03BF}'), ("pi", '\u{03C0}'), + ("rho", '\u{03C1}'), ("sigmaf", '\u{03C2}'), ("sigma", '\u{03C3}'), ("tau", '\u{03C4}'), ("upsilon", '\u{03C5}'), ("phi", '\u{03C6}'), ("chi", '\u{03C7}'), ("psi", '\u{03C8}'), + ("omega", '\u{03C9}'), ("thetasym", '\u{03D1}'), ("upsih", '\u{03D2}'), ("piv", '\u{03D6}'), ("ensp", '\u{2002}'), ("emsp", '\u{2003}'), ("thinsp", '\u{2009}'), ("zwnj", '\u{200C}'), + ("zwj", '\u{200D}'), ("lrm", '\u{200E}'), ("rlm", '\u{200F}'), ("ndash", '\u{2013}'), ("mdash", '\u{2014}'), ("lsquo", '\u{2018}'), ("rsquo", '\u{2019}'), ("sbquo", '\u{201A}'), + ("ldquo", '\u{201C}'), ("rdquo", '\u{201D}'), ("bdquo", '\u{201E}'), ("dagger", '\u{2020}'), ("Dagger", '\u{2021}'), ("bull", '\u{2022}'), ("hellip", '\u{2026}'), ("permil", '\u{2030}'), + ("prime", '\u{2032}'), ("Prime", '\u{2033}'), ("lsaquo", '\u{2039}'), ("rsaquo", '\u{203A}'), ("oline", '\u{203E}'), ("frasl", '\u{2044}'), ("euro", '\u{20AC}'), ("image", '\u{2111}'), + ("weierp", '\u{2118}'), ("real", '\u{211C}'), ("trade", '\u{2122}'), ("alefsym", '\u{2135}'), ("larr", '\u{2190}'), ("uarr", '\u{2191}'), ("rarr", '\u{2192}'), ("darr", '\u{2193}'), + ("harr", '\u{2194}'), ("crarr", '\u{21B5}'), ("lArr", '\u{21D0}'), ("uArr", '\u{21D1}'), ("rArr", '\u{21D2}'), ("dArr", '\u{21D3}'), ("hArr", '\u{21D4}'), ("forall", '\u{2200}'), + ("part", '\u{2202}'), ("exist", '\u{2203}'), ("empty", '\u{2205}'), ("nabla", '\u{2207}'), ("isin", '\u{2208}'), ("notin", '\u{2209}'), ("ni", '\u{220B}'), ("prod", '\u{220F}'), + ("sum", '\u{2211}'), ("minus", '\u{2212}'), ("lowast", '\u{2217}'), ("radic", '\u{221A}'), ("prop", '\u{221D}'), ("infin", '\u{221E}'), ("ang", '\u{2220}'), ("and", '\u{2227}'), + ("or", '\u{2228}'), ("cap", '\u{2229}'), ("cup", '\u{222A}'), ("int", '\u{222B}'), ("there4", '\u{2234}'), ("sim", '\u{223C}'), ("cong", '\u{2245}'), ("asymp", '\u{2248}'), + ("ne", '\u{2260}'), ("equiv", '\u{2261}'), ("le", '\u{2264}'), ("ge", '\u{2265}'), ("sub", '\u{2282}'), ("sup", '\u{2283}'), ("nsub", '\u{2284}'), ("sube", '\u{2286}'), + ("supe", '\u{2287}'), ("oplus", '\u{2295}'), ("otimes", '\u{2297}'), ("perp", '\u{22A5}'), ("sdot", '\u{22C5}'), ("lceil", '\u{2308}'), ("rceil", '\u{2309}'), ("lfloor", '\u{230A}'), + ("rfloor", '\u{230B}'), ("lang", '\u{2329}'), ("rang", '\u{232A}'), ("loz", '\u{25CA}'), ("spades", '\u{2660}'), ("clubs", '\u{2663}'), ("hearts", '\u{2665}'), ("diams", '\u{2666}'), +]; + +#[cfg(test)] +mod tests { + use super::{HTML_ENTITIES, LATIN1_ENTITIES, decode_entities}; + + #[test] + fn every_listed_html_entity_decodes_including_eight_byte_boundary() { + for (i, &name) in LATIN1_ENTITIES.iter().enumerate() { + let expected = char::from_u32(0xA0 + i as u32).unwrap().to_string(); + let input = format!("&{name};"); + assert_eq!(decode_entities(&input, true), expected, "entity {name}"); + assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal"); + } + for &(name, ch) in HTML_ENTITIES { + assert!(name.len() <= 8, "entity name {name} exceeds 8-byte parser limit"); + let input = format!("&{name};"); + assert_eq!(decode_entities(&input, true), ch.to_string(), "entity {name}"); + assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal"); + } + assert_eq!(decode_entities("ϑ", true), "\u{03D1}"); + assert_eq!(decode_entities("&thetasymx;", true), "&thetasymx;"); + } +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 3b9ebe6028..4865ecf1c7 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -276,12 +276,29 @@ pub(crate) fn platform() -> &'static str { } } -pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { +/// The `event` object for `e`, and the store behind its `value`: `value` holds what the script +/// reads and writes, and `set` turns true once the script assigns `event.value`, even the value it +/// already had. The store is out of the script's reach. +pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsResult<(JsObject, JsObject)> { + fn get(_: &JsValue, _: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult { + store.get(js_string!("value"), c) + } + fn set(_: &JsValue, args: &[JsValue], store: &JsObject, c: &mut Context) -> JsResult { + store.set(js_string!("value"), arg(args, 0), false, c)?; + store.set(js_string!("set"), true, false, c)?; + Ok(JsValue::undefined()) + } + let store = ObjectInitializer::new(ctx) + .property(js_string!("value"), s(&e.value), Attribute::all()) + .property(js_string!("set"), false, Attribute::all()) + .build(); + let value_get = function(ctx, NativeFunction::from_copy_closure_with_captures(get, store.clone())); + let value_set = function(ctx, NativeFunction::from_copy_closure_with_captures(set, store.clone())); let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); - ObjectInitializer::new(ctx) + let event = ObjectInitializer::new(ctx) .property(js_string!("name"), s(&e.name), Attribute::all()) .property(js_string!("type"), s(&e.kind), Attribute::all()) - .property(js_string!("value"), s(&e.value), Attribute::all()) + .accessor(js_string!("value"), Some(value_get), Some(value_set), Attribute::CONFIGURABLE | Attribute::ENUMERABLE) .property(js_string!("change"), s(&e.change), Attribute::all()) .property(js_string!("changeEx"), s(&e.change), Attribute::all()) .property(js_string!("willCommit"), e.will_commit, Attribute::all()) @@ -293,5 +310,6 @@ pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { .property(js_string!("commitKey"), 0, Attribute::all()) .property(js_string!("modifier"), false, Attribute::all()) .property(js_string!("shift"), false, Attribute::all()) - .build() + .build(); + Ok((event, store)) } diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 36b09bec97..591e2f472d 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -52,6 +52,8 @@ pub struct FieldState { /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. pub text_color: Option>, pub fill_color: Option>, + /// Rectangle of the first widget, in PDF user space. + pub rect: Option<[f64; 4]>, } impl FieldState { @@ -68,6 +70,7 @@ impl FieldState { char_limit: None, text_color: None, fill_color: None, + rect: None, } } @@ -275,6 +278,8 @@ pub struct Outcome { pub rc: bool, /// `event.value` after the script. pub value: String, + /// Whether the script assigned `event.value`, even the value it already had. + pub value_set: bool, pub change: String, /// Fields whose value or properties the script changed, as they are now. pub changed: Vec, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index ab7e32ed53..9435e7c8e4 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -217,7 +217,7 @@ fn run_here( let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; - let ev = event_object(&mut ctx, event); + let (ev, value_store) = event_object(&mut ctx, event)?; ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; // One document script that throws (calling a method PdfCraft lacks, say) must not keep // every field script in the form from running: what it defined before the error, and @@ -235,6 +235,7 @@ fn run_here( out.rc = rc.to_boolean(); let v = ev.get(js_string!("value"), &mut ctx)?; out.value = text(&v, &mut ctx)?; + out.value_set = value_store.get(js_string!("set"), &mut ctx)?.to_boolean(); let c = ev.get(js_string!("change"), &mut ctx)?; out.change = text(&c, &mut ctx)?; Ok(()) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b61649b729..a12b78dcb6 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -31,6 +31,21 @@ fn calculate_scripts_read_fields_and_set_the_value() { assert!(o.rc); } +/// `event.value` reads and writes as a plain value, numbers included, and the outcome says whether +/// the script assigned it, even to the value it already had. +#[test] +fn the_outcome_says_whether_the_script_assigned_event_value() { + let ev = Event::field("Calculate", "total", "7"); + let o = go("event.value = event.value;", &ev); + assert_eq!((o.value.as_str(), o.value_set), ("7", true)); + let o = go("event.value = 2; event.value += 3; typeof event.value;", &ev); + assert_eq!((o.value.as_str(), o.value_set, o.result.as_deref()), ("5", true, Some("number"))); + let o = go("var v = event.value; this.getField('price').value = 3;", &ev); + assert_eq!((o.value.as_str(), o.value_set), ("7", false)); + let o = go("Object.keys(event).indexOf('value') >= 0;", &ev); + assert_eq!(o.result.as_deref(), Some("true"), "value stays an enumerable property of event"); +} + #[test] fn numbers_with_leading_zeros_stay_text() { let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", "")); @@ -73,6 +88,22 @@ fn scripts_change_other_fields_and_their_properties() { assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]); } +#[test] +fn widget_rect_reads_and_writes_a_four_number_array() { + let o = go( + "var t = getField('total'); t.rect = [10, 20, 110, 50]; event.value = getField('total').rect.join(',');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "10,20,110,50"); + assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0])); + let o = go("getField('total').rect = [110, 50, 10, 20];", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.changed.iter().find(|f| f.name == "total").and_then(|f| f.rect), Some([10.0, 20.0, 110.0, 50.0])); + let o = go("getField('total').rect = [0, 0, Infinity, 10];", &Event::field("Mouse Up", "agree", "")); + assert!(o.error.is_some()); + assert!(o.changed.iter().all(|f| f.name != "total")); +} + #[test] fn choice_and_check_box_object_model() { let o = go( @@ -792,6 +823,19 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("7")); } + /// On screen every page is a sheet of its own: `xfa.layout.sheet` counts it from 0, as + /// `absPage` does (forms show `sheet(this) + 1`), and `sheetCount` is the page count. + #[test] + fn the_sheet_is_the_page_on_screen() { + let o = go( + "[xfa.layout.sheet(this) + 1, xfa.layout.sheetCount(), xfa.layout.absPage(this)].join(',')", + "form1[0].page1[0].total[0]", + "calculate", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("2,3,1")); + } + #[test] fn field_names_never_shadow_javascript_builtins() { let mut f = form(); @@ -1011,6 +1055,23 @@ mod formcalc { run_formcalc(script, &ev, &doc, &form(), Limits::default()) } + /// `xfa.layout.sheet` and `sheetCount` give what the JavaScript ones give. + #[test] + fn the_sheet_is_the_page_on_screen() { + assert_eq!(calc("xfa.layout.sheet($) + 1"), "2"); + assert_eq!(calc("xfa.layout.sheetCount()"), "3"); + } + + /// A layout method's arguments are evaluated, once each, for what they do: a message box + /// in one still shows. + #[test] + fn layout_method_arguments_are_evaluated_once() { + let o = go("xfa.layout.sheet($host.messageBox(\"Hi\"), $host.messageBox(\"Yo\")) + 1", "form1[0].page1[0].total[0]", "calculate"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("2")); + assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 2, "each argument once: {:?}", o.effects); + } + /// The value of a script run as a calculate on `total`. fn calc(script: &str) -> String { let o = go(script, "form1[0].page1[0].total[0]", "calculate"); diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 971cf12fc1..5ac7fec5ef 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -174,6 +174,11 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) + // On screen every page is a sheet of its own: the sheet is the page, counted from 0 + // as absPage counts (a form shows `sheet(this) + 1` as the sheet number), and the + // sheet count is the page count. + .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("sheet"), 1) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("sheetCount"), 0) .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index d52d31dcc6..7ff8bfdcf1 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -452,6 +452,13 @@ pub struct Listing { pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Vertex pairs one listing compares for self-intersection in all: an area's boundary check +/// is quadratic in its vertices, and many areas (or many annotations sharing one indirect vertex +/// array) must not multiply that into a stall. +const MAX_LISTED_WORK: usize = 50_000_000; +/// Scales already read by one listing, by the indirect `/Measure` object: one scale shared by +/// many annotations is read (and its ratio decoded) once, failures included. +type Scales = std::collections::HashMap>; /// Points and text one listing keeps, as counted by [`retained`]: about a thousand /// measurements of [`MAX_POINTS`] points each. const MAX_LISTED_BYTES: usize = 32 << 20; @@ -462,8 +469,18 @@ fn retained(m: &Measurement) -> usize { .into_iter() .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) } -fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { - let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; +/// The scale a measurement annotation reads with, through the listing's cache: a scale given by +/// reference is read once, and its error kept, however many annotations name it. +fn scale_of(doc: &Document, d: &Dict, scales: &mut Scales) -> Result { + let measure = d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?; + match measure.as_ref() { + Some(r) => scales.entry(r).or_insert_with(|| Scale::read(doc, measure).map_err(|e| e.to_string())).clone().map_err(|e| invalid(&e)), + None => Scale::read(doc, measure), + } +} + +fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize, scales: &mut Scales) -> Result { + let scale = scale_of(doc, d, scales)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; let o = doc.resolve(d.get(key).ok_or_else(|| invalid("measurement has no vertices"))?); let a = o.as_array().ok_or_else(|| invalid("invalid measurement vertices"))?; @@ -482,7 +499,9 @@ pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; let mut kept = 0usize; + let mut work = 0usize; let mut seen = std::collections::HashSet::new(); + let mut scales = Scales::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -507,7 +526,29 @@ pub fn list(doc: &Document) -> Listing { Some(b"PolygonDimension") => Kind::Area, _ => continue, }; - match read_measurement(doc, d, kind, page, index) { + // An area's boundary check compares every pair of edges: charge it before reading the + // vertices, so a listing stops at its work bound rather than at a stall. Other kinds + // read their vertices once. + let vertices = d.get(if kind == Kind::Distance { b"L".as_slice() } else { b"Vertices" }).map(|v| doc.resolve(v)); + let n = vertices.as_deref().and_then(Object::as_array).map_or(0, |a| a.len() / 2).min(MAX_POINTS); + // An area's boundary check compares every pair of edges: charge it before reading the + // vertices, so a listing stops at its work bound rather than at a stall. The scale is + // resolved first, as `read_measurement` reads it: one that is rejected reads nothing at + // all, so it must not spend the work that later, valid measurements need. + if kind == Kind::Area { + if let Err(e) = scale_of(doc, d, &mut scales) { + out.unsupported.push(Unsupported { page, index, reason: e.to_string() }); + continue; + } + work = work.saturating_add(n.saturating_mul(n)); + } else { + work = work.saturating_add(n.max(1)); + } + if work > MAX_LISTED_WORK { + out.truncated = true; + return out; + } + match read_measurement(doc, d, kind, page, index, &mut scales) { Ok(m) => { kept = kept.saturating_add(retained(&m)); if kept > MAX_LISTED_BYTES { diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index b26c389e96..c39d811d9f 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -1,7 +1,7 @@ //! Bounded path extraction and snapping in PDF user space, including nested Form XObjects. use crate::{Point, Result, check_points, distance, invalid}; -use pdfcraft_content::{Matrix, parse}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_content::{Matrix, ops}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use serde::Serialize; use std::collections::HashSet; @@ -199,7 +199,9 @@ struct Walker<'a> { doc: &'a Document, geometry: Geometry, seen: HashSet, - bytes: usize, + /// Decoded bytes left for the page: every filter stage's output counts, so a stream that + /// expands and then shrinks pays for the expansion each time it is drawn. + budget: DecodeBudget, streams: usize, } impl Walker<'_> { @@ -215,46 +217,44 @@ impl Walker<'_> { for part in arr { let s = self.doc.resolve(part); if let Object::Stream(s) = s.as_ref() { - let remaining = MAX_BYTES.saturating_sub(self.bytes).saturating_sub(joined.len()); + let remaining = self.budget.remaining(); if remaining == 0 { self.geometry.truncated = true; break; } // An undecodable part is skipped; the parts before it still snap. - match s.decoded_within(remaining) { + match self.budget.decode_within(s, remaining) { Ok(data) => { joined.extend(data); // A space, as the renderer joins pieces: a trailing comment runs on. joined.push(b' '); } - Err(_) => self.decode_failed(remaining), + Err(_) => self.decode_failed(), } } } return self.ops(&joined, resources, initial, depth); } let Object::Stream(stream) = o.as_ref() else { return Ok(()) }; - let remaining = MAX_BYTES.saturating_sub(self.bytes); + let remaining = self.budget.remaining(); if remaining == 0 { self.geometry.truncated = true; return Ok(()); } - match stream.decoded_within(remaining) { + match self.budget.decode_within(stream, remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.decode_failed(remaining); + self.decode_failed(); Ok(()) } } } - /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, - /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. - fn decode_failed(&mut self, remaining: usize) { + /// A failed decode has paid the budget for what it decoded before failing (all of it, past + /// the bound): repeating a failing stream cannot repeat that work for free. + fn decode_failed(&mut self) { self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); - self.bytes = self.bytes.saturating_add(remaining); } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { - self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); let mut matrix = initial; let mut stack = Vec::new(); @@ -268,7 +268,8 @@ impl Walker<'_> { let (x, y) = m.apply(x, y); [x, y] }; - for op in parse(data).ops { + // 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 { @@ -425,6 +426,11 @@ impl Walker<'_> { self.seen.insert(r); } self.walk(o, res, m, depth + 1)?; + // A form that hit a bound ends this walk too: more `Do`s would + // only repeat the work of copying its resources. + if self.geometry.truncated { + break; + } if let Some(r) = r { self.seen.remove(&r); } @@ -449,7 +455,8 @@ pub fn geometry(doc: &Document, page_index: usize) -> Result { 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(), bytes: 0, streams: 0 }; + let mut walker = + Walker { doc, geometry: Geometry::default(), seen: HashSet::new(), budget: DecodeBudget::new(MAX_BYTES, usize::MAX), streams: 0 }; // 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)?; diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 88906910c7..f265f086ec 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -471,3 +471,77 @@ fn a_stream_over_the_byte_budget_uses_it_up_once() { assert!(g.truncated); assert_eq!(g.segments.len(), 1, "the parts before it still snap"); } + +/// A content stream drawn again and again pays for every filter stage's output each time, not +/// only for what the last stage leaves: `/ASCIIHexDecode /RunLengthDecode` turns 4 MiB of hex +/// into 2 MiB and then 1 MiB, and eight draws of it are 24 MiB of decoding against a 16 MiB bound. +#[test] +fn repeated_content_pays_for_every_filter_stage() { + let hex = "0058".repeat(1 << 20); + let pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .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(); + assert!(g.truncated, "eight draws decode past the page's bound"); +} + +/// One scale shared by many measurements is read once, and the self-intersection check of many +/// areas is charged against one work bound rather than run in full for each. +#[test] +fn listing_reads_a_shared_scale_once_and_bounds_boundary_checks() { + let measure = "<< /Type /Measure /Subtype /RL /R (1 pt = 1 pt) /X [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /D [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /A [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] >>"; + // A convex 2,048-gon: every one of its edges is checked against every other. + let n = MAX_POINTS; + let verts: Vec = (0..n) + .map(|i| { + let a = i as f64 / n as f64 * std::f64::consts::TAU; + format!("{:.3} {:.3}", 300.0 + 100.0 * a.cos(), 400.0 + 100.0 * a.sin()) + }) + .collect(); + let verts = verts.join(" "); + let areas = 40; + let annots: Vec = (0..areas).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{}] >>", annots.join(" "))) + .stream("", "") + .obj(measure); + for _ in 0..areas { + pdf = pdf.obj(format!("<< /Type /Annot /Subtype /Polygon /IT /PolygonDimension /Rect [0 0 612 792] /Vertices [{verts}] /Measure 5 0 R >>")); + } + let d = Document::open(Arc::new(pdf.build())).unwrap(); + let l = list(&d); + // 40 areas of 2,048 vertices are 168 million pairs: the listing stops at its bound, with the + // areas before it listed. + assert!(l.truncated, "{} listed", l.measurements.len()); + assert!((5..areas).contains(&l.measurements.len()), "{} listed", l.measurements.len()); + assert!(l.unsupported.is_empty(), "{:?}", l.unsupported); +} + +/// Perimeters and distances read their vertices once: only areas pay for the pairwise boundary +/// check, so many maximal perimeters list in full. +#[test] +fn perimeters_are_charged_linear_work() { + let measure = "<< /Type /Measure /Subtype /RL /R (1 pt = 1 pt) /X [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /D [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] /A [<< /Type /NumberFormat /U (pt) /C 1 /D 100 >>] >>"; + let verts: Vec = (0..MAX_POINTS).map(|i| format!("{} {}", 10 + i % 500, 10 + i / 500 * 3 + (i % 2))).collect(); + let verts = verts.join(" "); + let lines = 40; + let annots: Vec = (0..lines).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut pdf = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{}] >>", annots.join(" "))) + .stream("", "") + .obj(measure); + for _ in 0..lines { + pdf = pdf.obj(format!("<< /Type /Annot /Subtype /PolyLine /IT /PolyLineDimension /Rect [0 0 612 792] /Vertices [{verts}] /Measure 5 0 R >>")); + } + let d = Document::open(Arc::new(pdf.build())).unwrap(); + let l = list(&d); + assert!(!l.truncated, "{} listed", l.measurements.len()); + assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first()); +} 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/Cargo.toml b/crates/optimize/Cargo.toml index d1a759d468..dd9802e303 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -22,6 +22,10 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } # The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0). zune-jpeg = "0.5" +# Lets other programs go first while a copy is being made. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +thread-priority = "3.1" + [dev-dependencies] pdfcraft-render = { workspace = true } proptest.workspace = true diff --git a/crates/optimize/README.md b/crates/optimize/README.md index fd7c60ae85..4c0549b1c8 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -121,6 +121,12 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going 1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused` counts those the image step read. Streams it only stores with Flate (below) aren't counted. +- **Threads**: pictures are worked out on up to half the machine's processors (at most 8, one on + the web), and as many as 1 GiB holds of the largest picture. Those threads go at low priority + where the system allows it, so other programs go first; pictures done on the calling thread keep + its priority. The document only changes in order on one thread, and what a picture costs is paid + in that order, so the file written, and the pictures the run's decoding allowance refuses, are + the same with any number. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded 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/fragments.rs b/crates/optimize/src/fragments.rs index b5662a2bb6..34c8f9ca76 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -243,14 +243,22 @@ fn merge_page( images: &mut ImageBudget, ) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; + let resources = page_resources(doc, page).unwrap_or_default(); + let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + // Reading a heavy page takes seconds, so not one that has no picture that could be a fragment. + if !xobjects.iter().any(|(name, _)| fragment_image(doc, &xobjects, name).is_some()) { + return Ok(0); + } // A page whose content isn't all known is left alone: rewriting it would drop the rest. let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + // Nor one that doesn't draw enough things for a run, each fragment being a `Do`. + if data.windows(2).filter(|w| *w == b"Do").count() < MIN_FRAGMENTS { + return Ok(0); + } let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } - let resources = page_resources(doc, page).unwrap_or_default(); - let mut xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); @@ -339,6 +347,9 @@ pub(crate) fn run( budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { for p in pages { + if !progress(Stage::Reading { done: 0, total: pages.len() }) { + return Err(OptimizeError::Cancelled); + } report.fragments_merged += merge_page(doc, *p, report, progress, budget)?; } Ok(()) @@ -405,6 +416,35 @@ mod tests { assert_eq!(budget.refused(), 1); } + /// A page drawing too few things for a run isn't parsed for one, even with a picture that could + /// be a fragment. + #[test] + fn too_few_draws_for_a_run_read_no_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let drawn = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(MIN_FRAGMENTS - 1); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX)); + assert_eq!(merged.ok(), Some(0)); + assert_eq!(GROUP_READS.with(|c| c.get()), 0); + } + /// Many images drawn on top of each other are not one picture; looking for runs among them reads /// a bounded number of groups per operator, not the rest of the page from every group. #[test] diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 264e1a2b9b..f72579778a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -4,6 +4,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; +use std::sync::atomic::{AtomicU64, Ordering}; + use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; @@ -250,13 +252,20 @@ fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { /// decode within its budget or is damaged partway, every image is given `INFINITY` (never /// resampled), since its largest use may be among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - walk_pages(doc, pages).0 + let Ok((ppi, ..)) = walk_pages(doc, pages, &mut |_| true) else { return HashMap::new() }; + ppi } +/// What a walk of the pages found: each image's resolution, the images under remapped device +/// spaces, and the references followed. +type PagesWalked = (HashMap, HashSet, Read); + /// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is /// in force, by object number (all of them when a use may have been missed), and the references /// followed to reach them. -fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSet, Read) { +/// +/// `tick` hears how many pages are done, and stops the walk by returning false. +fn walk_pages(doc: &Document, pages: &[ObjRef], tick: &mut dyn FnMut(usize) -> bool) -> Result { let mut walk = Walk { doc, path: Vec::new(), @@ -269,7 +278,10 @@ fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSe defaults: HashSet::new(), read: Read::default(), }; - for p in pages { + for (done, p) in pages.iter().enumerate() { + if !tick(done) { + return Err(OptimizeError::Cancelled); + } let Some(dict) = doc.get(*p).as_dict().cloned() else { continue }; let page = pdfcraft_model::Page { obj: *p, dict }; walk.resources_above(*p); @@ -288,7 +300,7 @@ fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSe walk.out.values_mut().for_each(|v| *v = f64::INFINITY); walk.defaults.extend(walk.out.keys().map(|r| r.num)); } - (walk.out, walk.defaults, walk.read) + Ok((walk.out, walk.defaults, walk.read)) } /// Whether image `r` is one [`one_bit`] may make grey: eight-bit `DeviceRGB`, not in an image codec. @@ -752,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; } @@ -761,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)?; @@ -1330,7 +1346,7 @@ pub(crate) fn run( progress: &mut dyn FnMut(Stage) -> bool, budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { - let (ppi, mut defaults, read) = walk_pages(doc, pages); + let (ppi, mut defaults, read) = walk_pages(doc, pages, &mut |done| progress(Stage::Reading { done, total: pages.len() }))?; // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an // indexed image's palette says which of its samples a resampled one keeps, which they may // change: one used where the walk didn't look may be drawn so. @@ -1347,93 +1363,73 @@ pub(crate) fn run( }) .collect(); let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); - refs.sort_by_key(|(r, _)| r.num); + refs.sort_by_key(|(r, _)| (r.num, r.generation)); let total = refs.len(); let mut jbig2 = false; let mut group: Vec = Vec::new(); - // Holds a JBIG2 image for `share`, which codes the group once it is full. - let mut hold = |doc: &mut Document, image: ObjRef, size: (Option, Option), bits: Option>, report: &mut Report| { - let side = |v: Option| v.and_then(|v| u32::try_from(v).ok()); - let (Some(bits), Some(width), Some(height)) = (bits, side(size.0), side(size.1)) else { return }; - hold_in(&mut group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report)); + // By number: a repaired object answers to two generations. + let mut smask_uses: HashMap = HashMap::new(); + for (r, _) in &refs { + if let Some(m) = smask_of(doc, *r) { + *smask_uses.entry(m.num).or_default() += 1; + } + } + // Too few to be worth the threads, which keeps a small file's progress in step too. + let threads = if total < MIN_FOR_THREADS { + 1 + } else { + let largest = refs.iter().filter_map(|(r, _)| picture_pixels(doc, *r)).max().unwrap_or(0); + threads_for(crate::pool::workers(settings.workers), largest) }; - for (done, (r, ppi)) in refs.into_iter().enumerate() { - if !progress(Stage::Images { done, total }) { - return Err(OptimizeError::Cancelled); - } - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - report.images += 1; - let refused = budget.refused(); - // Packed from more bits a sample, it is lossless at one bit, but the grey or colour - // settings can make it smaller still (below). - let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); - let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) { - Some(m) if packed_from_more => (Some(m), None), - m => (None, m), - }; - if let Some((new, resampled, bits)) = mono { - report.image_bytes_before += s.raw.len(); - report.image_bytes_after += new.raw.len(); - if resampled { - report.images_resampled += 1; - } else { - report.images_recompressed += 1; + let mut done = 0; + while let Some(rest) = refs.get(done..).filter(|rest| !rest.is_empty()) { + let run = if threads > 1 { batch_len(doc, rest, &smask_uses, settings.mono.compression == MonoCompression::Jbig2) } else { 0 }; + let Some(batch) = rest.get(..run).filter(|batch| batch.len() > 1) else { + if !progress(Stage::Images { done, total }) { + return Err(OptimizeError::Cancelled); } - jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); - let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); - let bits = bits.filter(|_| shares(&new.dict)); - doc.set(r, Object::Stream(new)); - hold(doc, r, size, bits, report); - continue; - } - let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); - let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); - // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft - // mask resampled with it. - let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { - Some((_, m)) => Some(m.raw.len()), - None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { - Object::Stream(m) => m.raw.len(), - _ => 0, - }), - }; - let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); - let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| { - processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) - }); - if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) { - jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); - report.image_bytes_before += s.raw.len(); - report.image_bytes_after += new.raw.len(); - if resampled { - report.images_resampled += 1; - } else { - report.images_recompressed += 1; + if let Some((r, ppi)) = rest.first() { + let decision = decide(doc, *r, *ppi, defaults.contains(&r.num), settings, budget); + apply(doc, decision, report, &mut jbig2, &mut group); } - let size = (new.dict.int(b"Width"), new.dict.int(b"Height")); - let bits = bits.filter(|_| shares(&new.dict)); - doc.set(r, Object::Stream(new)); - hold(doc, r, size, bits, report); - continue; - } - let Some((new, smask)) = processed else { - // Read to be packed and again to be resampled, it is still one image, and it stayed. - report.images_refused += usize::from(budget.refused() > refused); + done += 1; continue; }; - let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); - report.image_bytes_before += s.raw.len(); - report.image_bytes_after += new.raw.len(); - if resized { - report.images_resampled += 1; - } else { - report.images_recompressed += 1; - } - doc.set(r, Object::Stream(new)); - if let Some((mr, m)) = smask { - doc.set(mr, Object::Stream(m)); + // Each thread works within what's left now, and pays for what it used in the document's + // order below: a picture the order can't pay for is done again as if there were no threads, + // so the same pictures are refused with any number of them. Once more than what's left has + // been used, no more are started. + let allowed = budget.work_left(); + let used = AtomicU64::new(0); + let tried = { + let (doc, defaults) = (&*doc, &defaults); + crate::pool::map( + batch, + threads, + |(r, ppi)| { + if used.load(Ordering::Relaxed) >= allowed { + return None; + } + let mut own = ImageBudget::with_work(allowed); + let decision = decide(doc, *r, *ppi, defaults.contains(&r.num), settings, &mut own); + let cost = allowed.saturating_sub(own.work_left()); + used.fetch_add(cost, Ordering::Relaxed); + (own.refused() == 0).then_some((decision, cost)) + }, + &mut |finished| progress(Stage::Images { done: done.saturating_add(finished), total }), + )? + }; + for ((r, ppi), tried) in batch.iter().zip(tried) { + let decision = match tried.flatten() { + Some((decision, cost)) if cost <= budget.work_left() => { + budget.charge(cost); + decision + } + _ => decide(doc, *r, *ppi, defaults.contains(&r.num), settings, budget), + }; + apply(doc, decision, report, &mut jbig2, &mut group); } + done += batch.len(); } share(doc, &mut group, report); if !progress(Stage::Images { done: total, total }) { @@ -1445,6 +1441,166 @@ pub(crate) fn run( Ok(()) } +/// A picture's soft mask, if it names one. +fn smask_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => s.dict.reference(b"SMask"), + _ => None, + } +} + +/// How many of `rest`, from the first, can be worked out beside each other. +fn batch_len(doc: &Document, rest: &[(ObjRef, f64)], smask_uses: &HashMap, holds_samples: bool) -> usize { + // One object under two generations: the second must see what the first left. Under one of them + // it can be a picture, and under the other another's soft mask. + let mut numbers = HashSet::new(); + let mut samples = 0usize; + rest.iter() + .take(PICTURES_AT_ONCE) + .take_while(|(r, _)| { + // Results wait for the end of the batch, samples kept to share a dictionary with them. + samples = samples.saturating_add(if holds_samples { held_samples(doc, *r) } else { 0 }); + // A soft mask two pictures share is changed by the first of them, and the second must + // see that, so they don't run beside others. + samples <= SHARED_BYTES + && !smask_uses.contains_key(&r.num) + && numbers.insert(r.num) + && smask_of(doc, *r).is_none_or(|m| smask_uses.get(&m.num) == Some(&1)) + }) + .count() +} + +/// The most bytes of 1-bit samples picture `r` could keep to share a dictionary: whole bytes a row. +fn held_samples(doc: &Document, r: ObjRef) -> usize { + let object = doc.get(r); + let Object::Stream(s) = &*object else { return 0 }; + let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()); + let Some((w, h)) = side(b"Width").zip(side(b"Height")) else { return 0 }; + if w.saturating_mul(h) > SHARED_PIXELS { + return 0; + } + usize::try_from(w.div_ceil(8).saturating_mul(h)).unwrap_or(usize::MAX) +} + +/// Below this many pictures, one thread does them. +const MIN_FOR_THREADS: usize = 16; + +/// How many pictures run beside each other before what they used is paid for. +const PICTURES_AT_ONCE: usize = 32; + +/// What the pictures being worked out may hold in memory together. +const PICTURE_MEMORY: u64 = 1 << 30; + +/// What a picture holds while it is worked out, for each pixel: decoded, and resampled or encoded. +const PICTURE_BYTES_PER_PIXEL: u64 = 8; + +/// A picture's pixels, if it is small enough to be decoded at all. +fn picture_pixels(doc: &Document, r: ObjRef) -> Option { + let object = doc.get(r); + let Object::Stream(s) = &*object else { return None }; + let (w, h) = (u64::try_from(s.dict.int(b"Width")?).ok()?, u64::try_from(s.dict.int(b"Height")?).ok()?); + w.checked_mul(h).filter(|pixels| *pixels <= MAX_PIXELS) +} + +/// How many pictures of `largest` pixels to work on at once: `workers`, or as many as +/// [`PICTURE_MEMORY`] holds, and at least one. +fn threads_for(workers: usize, largest: u64) -> usize { + let fit = PICTURE_MEMORY / largest.saturating_mul(PICTURE_BYTES_PER_PIXEL).max(1); + workers.min(usize::try_from(fit).unwrap_or(usize::MAX)).max(1) +} + +/// What's to become of one picture. +enum Decision { + /// Not an image stream after all. + NotAnImage, + /// An image that stays as it is, `refused` if the allowance couldn't pay for it. + Stays { refused: bool }, + Replace { + image: ObjRef, + before: usize, + new: Stream, + resampled: bool, + smask: Option<(ObjRef, Stream)>, + /// Its 1-bit samples, if it may share a JBIG2 dictionary ([`share`]). + bits: Option>, + }, +} + +/// Do what was decided for one picture, and count it. +fn apply(doc: &mut Document, decision: Decision, report: &mut Report, jbig2: &mut bool, group: &mut Vec) { + let (image, before, new, resampled, smask, bits) = match decision { + Decision::NotAnImage => return, + Decision::Stays { refused } => { + report.images += 1; + report.images_refused += usize::from(refused); + return; + } + Decision::Replace { image, before, new, resampled, smask, bits } => (image, before, new, resampled, smask, bits), + }; + report.images += 1; + *jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + report.image_bytes_before += before; + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + let side = |key: &[u8]| new.dict.int(key).and_then(|v| u32::try_from(v).ok()); + let held = bits.zip(side(b"Width")).zip(side(b"Height")); + doc.set(image, Object::Stream(new)); + if let Some((mr, m)) = smask { + doc.set(mr, Object::Stream(m)); + } + // A JBIG2 image waits for `share`, which codes the group once it is full. + if let Some(((bits, width), height)) = held { + hold_in(group, Held { image, bits, width, height }, SHARED_BYTES, |group| share(doc, group, report)); + } +} + +/// Work out what's to become of picture `r`, changing nothing yet. +fn decide(doc: &Document, r: ObjRef, ppi: f64, defaults: bool, settings: &Settings, budget: &mut ImageBudget) -> Decision { + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { return Decision::NotAnImage }; + let refused = budget.refused(); + // Packed from more bits a sample, it is lossless at one bit, but the grey or colour + // settings can make it smaller still (below). + let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); + let (packed, mono) = match mono(doc, s, ppi, defaults, &settings.mono, budget) { + Some(m) if packed_from_more => (Some(m), None), + m => (None, m), + }; + if let Some((new, resampled, bits)) = mono { + let bits = bits.filter(|_| shares(&new.dict)); + return Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask: None, bits }; + } + let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); + // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft + // mask resampled with it. + let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { + Some((_, m)) => Some(m.raw.len()), + None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { + Object::Stream(m) => m.raw.len(), + _ => 0, + }), + }; + let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); + let packed_wins = packed.as_ref().is_some_and(|(p, _, _)| { + processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) + }); + if let Some((new, resampled, bits)) = packed.filter(|_| packed_wins) { + let bits = bits.filter(|_| shares(&new.dict)); + return Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask: None, bits }; + } + let Some((new, smask)) = processed else { + // Read to be packed and again to be resampled, it is still one image, and it stayed. + return Decision::Stays { refused: budget.refused() > refused }; + }; + let resampled = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); + Decision::Replace { image: r, before: s.raw.len(), new, resampled, smask, bits: None } +} + /// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 /// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2 /// §7.7.2). A full save with object streams writes a 1.5 header anyway. @@ -1492,6 +1648,37 @@ mod dimension_tests { assert_eq!((count, group.len()), (SHARED_IMAGES, 0), "coded on the 16th"); } + #[test] + fn a_batch_holds_no_more_samples_than_a_group_may() { + let mut doc = Document::new_empty(); + let mut add = |width: i64, height: i64| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + (doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))), 72.0) + }; + // Eight megabytes of samples each, and one byte a pixel when one pixel wide. + let large: Vec<_> = (0..12).map(|_| add(8192, 8192)).collect(); + let thin: Vec<_> = (0..8).map(|_| add(1, 32 << 20)).collect(); + let small: Vec<_> = (0..40).map(|_| add(100, 100)).collect(); + // Past what may share a dictionary, so never held. + let huge: Vec<_> = (0..12).map(|_| add(16384, 16384)).collect(); + let none = HashMap::new(); + assert_eq!(batch_len(&doc, &large, &none, true), 8); + assert_eq!(batch_len(&doc, &thin, &none, true), 2); + assert_eq!(batch_len(&doc, &small, &none, true), PICTURES_AT_ONCE); + assert_eq!(batch_len(&doc, &huge, &none, true), huge.len()); + assert_eq!(batch_len(&doc, &large, &none, false), large.len(), "nothing is held but for JBIG2"); + } + + #[test] + fn threads_are_limited_by_the_memory_of_the_largest_picture() { + assert_eq!(threads_for(8, 1_000_000), 8); + assert_eq!(threads_for(8, 40_000_000), 3); + assert_eq!(threads_for(8, MAX_PIXELS), 1); + assert_eq!(threads_for(8, 0), 8); + } + /// Only what redaction can store again without the dictionary shares one: an image mask, or /// DeviceGray named directly, of at most 2^26 pixels. #[test] @@ -1609,6 +1796,63 @@ mod dimension_tests { assert_eq!(changed, 2, "the third is as it was"); } + /// An allowance that runs out refuses the same pictures with any number of threads as it does + /// one after another. + #[test] + fn the_allowance_refuses_the_same_pictures_with_any_number_of_threads() { + let picture = |shade: u32| { + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(400)); + dict.set(b"Height".to_vec(), Object::Int(400)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let samples: Vec = (0..400u32 * 400).map(|i| (i.wrapping_mul(shade + 3) % 251) as u8).collect(); + Stream::flate(dict, &samples) + }; + let build = || { + let mut doc = Document::new_empty(); + // A few of different sizes, so what's left when the allowance ends depends on the order. + let refs: Vec = (0..40u32).map(|i| doc.add(Object::Stream(picture(i)))).collect(); + let mut xobjects = Dict::new(); + let mut content = String::from("q 72 0 0 72 0 0 cm "); + for (i, r) in refs.iter().enumerate() { + xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r)); + content.push_str(&format!("/I{i} Do ")); + } + content.push('Q'); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + (doc, refs, page) + }; + let run_with = |work: u64, workers: usize| { + let (mut doc, refs, page) = build(); + let settings = Settings { workers: Some(workers), ..Settings::default() }; + let (mut report, mut budget) = (Report::default(), ImageBudget::with_work(work)); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + let raws: Vec> = refs.iter().map(|r| if let Object::Stream(s) = &*doc.get(*r) { s.raw.to_vec() } else { Vec::new() }).collect(); + (format!("{report:?}"), budget.work_left(), raws) + }; + let all = u64::MAX - run_with(u64::MAX, 1).1; + // Past one batch of what threads run together, and part of the way through a picture. + let work = all / 40 * 37 + all / 80; + let one = run_with(work, 1); + assert!(one.0.contains("images_refused: 3"), "{}", one.0); + for workers in [2, 4, 8] { + let many = run_with(work, workers); + assert_eq!(many.0, one.0, "{workers} threads count the same"); + assert_eq!(many.1, one.1, "{workers} threads leave the same allowance"); + assert!(many.2 == one.2, "{workers} threads store the same pictures"); + } + } + /// Images that aren't JBIG2 share the run's allowance too, both on the way to being resampled /// and on the way to being packed to a bit a sample: a file of filter bombs would hold a run /// as long as one of JBIG2, and those past it stay as they are. diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index ef6423ab7d..275c6f32a6 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -32,6 +32,7 @@ mod audit; pub mod fragments; mod images; mod links; +mod pool; mod samples; mod unused; @@ -41,6 +42,7 @@ use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; +pub use pool::give_way_to_other_programs; pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; #[derive(Debug, thiserror::Error)] @@ -106,6 +108,8 @@ impl Compatibility { /// Where an optimization is, reported to [`optimize_with_progress`]'s callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { + /// Reading what pages draw: `done` of `total` read, none yet while looking for fragments. + Reading { done: usize, total: usize }, /// About to process image `done` (0-based) of `total`; `done == total` once all are done. Images { done: usize, total: usize }, /// Merging the fragments of a page picture into one image (only when a page has some). @@ -181,6 +185,8 @@ impl ImageSettings { pub struct Settings { /// Target PDF version (`Retain` by default). pub compatibility: Compatibility, + /// Threads for pictures: `None` picks from the machine. The result is the same with any number. + pub workers: Option, /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). pub merge_fragments: bool, pub color: ImageSettings, @@ -217,6 +223,7 @@ impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + workers: None, compatibility: Compatibility::Retain, merge_fragments: true, color: ImageSettings::REDUCE, diff --git a/crates/optimize/src/pool.rs b/crates/optimize/src/pool.rs new file mode 100644 index 0000000000..57dcc7fdb1 --- /dev/null +++ b/crates/optimize/src/pool.rs @@ -0,0 +1,262 @@ +//! Work over many pages or images on several threads, the results in order, so that what is +//! written is the same however many threads did it. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{RecvTimeoutError, channel}; +use std::time::Duration; + +use crate::OptimizeError; + +/// Threads beyond this add more memory than speed: each holds a page or an image being worked on. +const MAX_WORKERS: usize = 8; +/// How long Cancel can wait while every thread is busy with something long. +const CANCEL_CHECK: Duration = Duration::from_millis(100); + +/// Let other programs go first on this thread: the wait for a copy matters less than whatever else +/// is open. A system that refuses leaves it as it was. +pub fn give_way_to_other_programs() { + #[cfg(not(target_arch = "wasm32"))] + let _ = thread_priority::set_current_thread_priority(thread_priority::ThreadPriority::Min); +} + +/// How many threads to use: `asked`, or half the machine's processors up to [`MAX_WORKERS`], so a +/// bigger machine goes faster and every machine keeps room for other programs. One on the web, which +/// has no threads here. +pub(crate) fn workers(asked: Option) -> usize { + if cfg!(target_arch = "wasm32") { + return 1; + } + asked.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| (n.get() / 2).clamp(1, MAX_WORKERS))).max(1) +} + +/// `work` on each of `items`, results in the order of `items`. `tick` hears how many are done, and +/// stops the run by returning false. With more than one worker an item whose work panics gets +/// `None`, and the caller decides what becomes of it; with one, the panic goes on up as it does for +/// any other work. +pub(crate) fn map( + items: &[T], + workers: usize, + work: impl Fn(&T) -> R + Sync, + tick: &mut dyn FnMut(usize) -> bool, +) -> Result>, OptimizeError> { + let mut out: Vec> = Vec::with_capacity(items.len()); + if workers <= 1 || items.len() <= 1 { + for (done, item) in items.iter().enumerate() { + if !tick(done) { + return Err(OptimizeError::Cancelled); + } + out.push(Some(work(item))); + } + return Ok(out); + } + out.resize_with(items.len(), || None); + let (next, stop) = (AtomicUsize::new(0), AtomicBool::new(false)); + let mut stopped = false; + std::thread::scope(|scope| { + let (results, finished) = channel(); + for _ in 0..workers.min(items.len()) { + let results = results.clone(); + let (next, stop, work) = (&next, &stop, &work); + // A thread that can't start only leaves more for the others, or for below. + let _ = std::thread::Builder::new().name("pdfcraft-optimize-worker".into()).spawn_scoped(scope, move || { + give_way_to_other_programs(); + while !stop.load(Ordering::Relaxed) { + let i = next.fetch_add(1, Ordering::Relaxed); + let Some(item) = items.get(i) else { break }; + let result = catch_unwind(AssertUnwindSafe(|| work(item))).ok(); + if results.send((i, result)).is_err() { + break; + } + } + }); + } + drop(results); + let mut done = 0; + loop { + if !stopped && !tick(done) { + stopped = true; + stop.store(true, Ordering::Relaxed); + } + match finished.recv_timeout(CANCEL_CHECK) { + Ok((i, result)) => { + if let Some(slot) = out.get_mut(i) { + *slot = result; + } + done += 1; + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } + }); + if stopped { + return Err(OptimizeError::Cancelled); + } + // No thread could start, or the last of them stopped early: the rest are done here. + let claimed = next.load(Ordering::Relaxed).min(items.len()); + for (done, (item, slot)) in items.iter().zip(out.iter_mut()).enumerate().skip(claimed) { + if !tick(done) { + return Err(OptimizeError::Cancelled); + } + *slot = Some(work(item)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + #[test] + fn results_keep_their_order_whatever_finishes_first() { + let items: Vec = (0..200).collect(); + let out = map( + &items, + 4, + |i| { + // The early ones finish last. + std::thread::sleep(Duration::from_micros(200u64.saturating_sub(*i))); + i * 2 + }, + &mut |_| true, + ) + .unwrap(); + assert_eq!(out, items.iter().map(|i| Some(i * 2)).collect::>()); + } + + #[test] + fn one_worker_does_them_in_order_on_this_thread() { + let here = std::thread::current().id(); + let order = Mutex::new(Vec::new()); + let mut ticks = Vec::new(); + map( + &[1, 2, 3], + 1, + |i| { + assert_eq!(std::thread::current().id(), here); + order.lock().unwrap().push(*i); + }, + &mut |done| { + ticks.push(done); + true + }, + ) + .unwrap(); + assert_eq!(*order.lock().unwrap(), [1, 2, 3]); + assert_eq!(ticks, [0, 1, 2]); + } + + #[test] + fn several_workers_share_the_work() { + let threads = Mutex::new(std::collections::HashSet::new()); + map( + &[0; 64], + 4, + |_| { + threads.lock().unwrap().insert(std::thread::current().id()); + std::thread::sleep(Duration::from_millis(5)); + }, + &mut |_| true, + ) + .unwrap(); + assert!(threads.lock().unwrap().len() > 1, "it really ran on more than one"); + } + + #[test] + fn stopping_stops_and_says_so() { + let ran = AtomicUsize::new(0); + let r = map( + &[0; 500], + 4, + |_| { + ran.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(Duration::from_millis(1)); + }, + &mut |done| done < 8, + ); + assert!(matches!(r, Err(OptimizeError::Cancelled))); + assert!(ran.load(Ordering::Relaxed) < 500, "the rest never started"); + } + + /// Cancel is heard while every thread is busy with something long, not only between items. + #[test] + fn a_stop_gets_through_while_every_thread_is_busy() { + let mut asked_while_busy = 0; + map(&[0; 2], 2, |_| std::thread::sleep(Duration::from_millis(600)), &mut |done| { + if done == 0 { + asked_while_busy += 1; + } + true + }) + .unwrap(); + assert!(asked_while_busy >= 3, "asked {asked_while_busy} times before anything finished"); + + let started = std::time::Instant::now(); + let mut asked = 0; + let r = map(&[0; 4], 2, |_| std::thread::sleep(Duration::from_millis(400)), &mut |_| { + asked += 1; + asked < 3 + }); + assert!(matches!(r, Err(OptimizeError::Cancelled))); + assert!(started.elapsed() < Duration::from_millis(1500), "the items not started never ran"); + } + + #[test] + fn an_item_that_panics_is_left_alone_and_the_rest_are_done() { + let items: Vec = (0..32).collect(); + let out = map( + &items, + 4, + |i| { + assert_ne!(*i, 7, "this one panics"); + *i + }, + &mut |_| true, + ) + .unwrap(); + for (i, got) in out.iter().enumerate() { + assert_eq!(*got, (i != 7).then_some(i as u32), "item {i}"); + } + } + + /// The workers give way, and the thread that asked keeps its own priority. + #[cfg(windows)] + #[test] + fn workers_give_way_to_other_programs() { + use thread_priority::get_current_thread_priority; + let here = get_current_thread_priority().unwrap(); + let theirs = Mutex::new(Vec::new()); + map( + &[0; 8], + 2, + |_| { + theirs.lock().unwrap().push(get_current_thread_priority().unwrap()); + std::thread::sleep(Duration::from_millis(2)); + }, + &mut |_| true, + ) + .unwrap(); + assert_eq!(get_current_thread_priority().unwrap(), here); + let theirs = theirs.into_inner().unwrap(); + assert_eq!(theirs.len(), 8); + assert!(theirs.iter().all(|p| *p != here), "{theirs:?} against {here:?}"); + } + + #[test] + fn nothing_to_do_is_fine() { + let none: [u8; 0] = []; + assert!(map(&none, 4, |_| 1, &mut |_| true).unwrap().is_empty()); + assert_eq!(map(&[5], 4, |i| *i, &mut |_| true).unwrap(), [Some(5)]); + } + + #[test] + fn the_web_and_small_machines_get_a_sensible_count() { + assert_eq!(workers(Some(3)), if cfg!(target_arch = "wasm32") { 1 } else { 3 }); + assert_eq!(workers(Some(0)), 1, "never none"); + let picked = workers(None); + assert!((1..=MAX_WORKERS).contains(&picked), "{picked}"); + } +} 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/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs index c4dfabc9d9..4e08185d77 100644 --- a/crates/optimize/tests/jpeg_color_transform.rs +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -395,9 +395,9 @@ fn a_jpeg_header_that_takes_more_than_a_few_steps_to_read_is_left_as_it_is() { #[test] fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { // One JPEG with 8 MB of stray bytes after SOI, drawn 64 times at the same place (the case - // reported on #3012). Fragment merging reads its header for each group it tries, before the - // first progress call. The header scan stops at the first stray byte, so that call is the - // first image, and cancelling there stops Optimize. + // reported on #3012). Fragment merging reads its header for each group it tries. The header + // scan stops at the first stray byte, so the first image comes soon, and cancelling there stops + // Optimize. Cancelling while pages are read stops it sooner still. let (w, h) = (128, 8); let jpeg = after_soi(&rgb_jpeg(&photo(w, h), w, h), &vec![0; 8_000_000]); let mut doc = Document::new_empty(); @@ -411,8 +411,16 @@ fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { let mut stages = Vec::new(); let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |stage| { stages.push(stage); + !matches!(stage, Stage::Images { .. }) + }); + assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); + assert_eq!(stages.last(), Some(&Stage::Images { done: 0, total: 1 })); + assert_eq!(stages.first(), Some(&Stage::Reading { done: 0, total: 1 }), "it can be stopped from the first call"); + let mut stages = 0; + let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |_| { + stages += 1; false }); assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); - assert_eq!(stages, [Stage::Images { done: 0, total: 1 }]); + assert_eq!(stages, 1); } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2785ddac6c..c4fb865198 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2647,7 +2647,9 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() { }; let mut seen = Vec::new(); let report = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| { - seen.push(s); + if !matches!(s, Stage::Reading { .. }) { + seen.push(s); + } true }) .unwrap(); @@ -2665,7 +2667,10 @@ fn progress_is_reported_per_image_and_a_refusal_cancels() { // Stopping after the first image. let mut calls = 0; - let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |_| { + let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| { + if matches!(s, Stage::Reading { .. }) { + return true; + } calls += 1; calls < 2 }); @@ -4289,3 +4294,135 @@ fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() { assert_eq!(stream(&doc, img), before, "{what}"); } } + +/// Pictures run on several threads: what comes out doesn't depend on how many. +#[test] +fn the_same_file_comes_out_with_any_number_of_threads() { + let build = || { + let mut doc = Document::new_empty(); + let shared_mask = image(&mut doc, 1200, 1200, 1, false, None); + // Under a soft mask they share, at different sizes on the page: the second must start from + // the mask the first left. Each needs the mask at a different size. + let first = image(&mut doc, 1200, 1200, 3, true, Some(shared_mask)); + let second = image(&mut doc, 1000, 1000, 3, true, Some(shared_mask)); + page(&mut doc, &[("Im0", first)], "q 144 0 0 144 100 100 cm /Im0 Do Q"); + page(&mut doc, &[("Im0", second)], "q 180 0 0 180 100 100 cm /Im0 Do Q"); + // Enough others for the threads. + for page_no in 0..22u32 { + let img = image(&mut doc, 500 + page_no, 500, 3, true, None); + page(&mut doc, &[("Im0", img)], "q 72 0 0 72 100 100 cm /Im0 Do Q"); + } + doc + }; + let run = |workers| { + let mut doc = build(); + let report = optimize(&mut doc, &Settings { workers: Some(workers), ..Settings::default() }).unwrap(); + assert_eq!(report.images, 24, "{report:?}"); + assert!(report.images_resampled + report.images_recompressed > 16, "{report:?}"); + (write_full(&doc, &SaveOptions::default()).unwrap(), format!("{report:?}")) + }; + let (one, one_report) = run(1); + for workers in [2, 4, 7] { + let (many, many_report) = run(workers); + assert_eq!(many_report, one_report, "{workers} threads count the same"); + assert!(many == one, "{workers} threads write the same bytes"); + } +} + +/// A table that disagrees with an object's header leaves it reachable under two generations, as +/// one object: each use decides from what the one before it left, with any number of threads. +#[test] +fn one_object_under_two_generations_comes_out_the_same_with_any_number_of_threads() { + let picture = |size: usize, extra: &str| { + let samples: Vec = (0..size * size).map(|i| ((i % size) * 255 / size) as u8 ^ ((i / size) as u8 & 7)).collect(); + let dict = format!( + "<< /Type /XObject /Subtype /Image /Width {size} /Height {size} /BitsPerComponent 8 /ColorSpace /DeviceGray{extra} /Length {} >>\nstream\n", + samples.len() + ); + [dict.as_bytes(), &samples, b"\nendstream"].concat() + }; + let build = || { + // 1 the catalog, 2 the pages, 3 the mask, 4 and 5 under it, 6 the picture, 7 a picture that is + // also 8's mask, then the others. + let mut objects = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), Vec::new()]; + objects.push(picture(1200, "")); + objects.push(picture(1200, " /SMask 3 0 R")); + objects.push(picture(1000, " /SMask 3 1 R")); + objects.push(picture(1200, "")); + objects.push(picture(1200, "")); + objects.push(picture(1000, " /SMask 7 1 R")); + // Small under one generation, large under the other: they want different sizes. + let mut draws = vec![ + ("4 0 R".to_string(), 144), + ("5 0 R".to_string(), 180), + ("6 0 R".to_string(), 72), + ("6 1 R".to_string(), 576), + ("7 0 R".to_string(), 72), + ("8 0 R".to_string(), 180), + ]; + for i in 0..22 { + objects.push(picture(500 + i, "")); + draws.push((format!("{} 0 R", objects.len()), 72)); + } + let mut kids = String::new(); + for (drawn, size) in &draws { + let content = format!("q {size} 0 0 {size} 10 10 cm /Im0 Do Q"); + objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes()); + let contents = objects.len(); + objects.push( + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {contents} 0 R /Resources << /XObject << /Im0 {drawn} >> >> >>" + ) + .into_bytes(), + ); + kids.push_str(&format!("{} 0 R ", objects.len())); + } + objects[1] = format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", draws.len()).into_bytes(); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1); + for (i, object) in objects.iter().enumerate() { + let num = i + 1; + // Headers say generation 0, and the table says 1 of these three. + let generation = u8::from(num == 3 || num == 6 || num == 7); + table.push_str(&format!("{:010} {generation:05} n \n", bytes.len())); + bytes.extend(format!("{num} 0 obj\n").as_bytes()); + bytes.extend(object); + bytes.extend(b"\nendobj\n"); + } + let start = bytes.len(); + bytes.extend(table.as_bytes()); + bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objects.len() + 1).as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + for (num, generation) in [(3, 0), (3, 1), (6, 0), (6, 1), (7, 0), (7, 1)] { + assert!(matches!(&*doc.get(ObjRef { num, generation }), Object::Stream(_)), "{num} {generation} finds the object"); + } + doc + }; + let run = |workers| { + let mut doc = build(); + let report = optimize(&mut doc, &Settings { workers: Some(workers), ..Settings::default() }).unwrap(); + (write_full(&doc, &SaveOptions::default()).unwrap(), format!("{report:?}")) + }; + let (one, one_report) = run(1); + for workers in [2, 4, 7] { + let (many, many_report) = run(workers); + assert_eq!(many_report, one_report, "{workers} threads count the same"); + assert!(many == one, "{workers} threads write the same bytes"); + } +} + +/// Pages too small for a run aren't read, and that must not cost the smallest run there can be. +#[test] +fn the_fewest_strips_that_make_a_picture_are_merged() { + use pdfcraft_optimize::fragments::MIN_FRAGMENTS; + let keep = Settings { + gray: ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }, + ..Settings::default() + }; + let (mut doc, _) = printed_page(MIN_FRAGMENTS as _, |_| false, |_, s| s); + let r = optimize(&mut doc, &keep).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (1, MIN_FRAGMENTS), "{r:?}"); + let (mut doc, _) = printed_page((MIN_FRAGMENTS - 1) as _, |_| false, |_, s| s); + let r = optimize(&mut doc, &keep).unwrap(); + assert_eq!((r.merged_images, r.fragments_merged), (0, 0), "{r:?}"); +} 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 636ed61991..6ec4d3bd92 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -10,7 +10,9 @@ //! - link and GoTo destinations that target a copied page point at the copy. Links to pages that //! were not copied are dropped, so they don't dangle; //! - form-field widgets keep their field hierarchy, and the top-level fields are registered in the -//! destination `/AcroForm`; +//! destination `/AcroForm`. When only some of a document's pages are copied, a field brings only +//! the kids that lead to a widget on those pages: the rest of the form stays behind with its +//! values; //! - structure-tree links (`/StructParent(s)`) and article beads (`/B`) are removed, because the //! source structure tree is not copied; //! - named destinations are resolved against the source's name tree and become explicit ones; @@ -18,6 +20,7 @@ //! `/OCProperties`, keeping their default on/off state. use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -34,8 +37,8 @@ pub use combine::{ CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, extract_pages, split, split_ranges, }; -use copy::SKIP_ON_PAGE; pub(crate) use copy::{Copier, copy_object}; +use copy::{MAX_COPY_DEPTH, SKIP_ON_PAGE}; use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; pub use fields::{combine_field_conflicts, field_name_conflicts}; @@ -171,6 +174,27 @@ struct Imported { renamed_fields: Vec<(String, String)>, } +/// The annotations on `pages` and, above each widget among them, its fields up to the top one. +/// A field copied with those pages keeps only these kids: its other kids sit on pages left behind, +/// and carry their own values and appearances. +fn fields_on(src: &Document, pages: impl IntoIterator) -> HashSet { + let mut kept = HashSet::new(); + for page in pages { + let page = src.get(page); + let Some(annots) = page.as_dict().and_then(|d| d.get(b"Annots")).map(|a| src.resolve(a)) else { continue }; + for a in annots.as_array().into_iter().flatten() { + let mut at = a.as_ref().or_else(|| src.resolve(a).as_dict().and_then(|d| d.reference(b"Parent"))); + // Up the field tree, as deep as a copy may go, and once: a node seen already has its + // fields above it in the set. + for _ in 0..MAX_COPY_DEPTH { + let Some(r) = at.filter(|r| kept.insert(*r)) else { break }; + at = src.get(r).as_dict().and_then(|d| d.reference(b"Parent")); + } + } + } + kept +} + /// `import_pages`, also returning what was copied from where. fn import_pages_mapped( dst: &mut Document, @@ -199,6 +223,9 @@ fn import_pages_mapped( (*page, new, inherited.clone()) }) .collect(); + if src_pages.iter().collect::>().len() < source.len() { + copier.kept = Some(Rc::new(fields_on(src, copier.pages.keys().copied()))); + } let mut annot_pages = HashMap::new(); let mut new_pages = Vec::new(); let mut done: HashMap = HashMap::new(); @@ -208,7 +235,11 @@ fn import_pages_mapped( // popup, a widget's place). Destinations still map to the pages copied. let repeat = done.contains_key(&page); let new = if repeat { dst.add(Object::Null) } else { new }; - let mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone())); + let mut own = repeat.then(|| { + let mut c = Copier::new(src, HashMap::new(), copier.pages.clone()); + c.kept = copier.kept.clone(); + c + }); let src_dict = src.get(page).as_dict().cloned().unwrap_or_default(); let resources = resources_with_ancestors(src, &src_dict); let mut d = Dict::new(); diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index 5e4be54150..96d206d249 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,6 @@ -use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; +use std::rc::Rc; + +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. @@ -10,7 +12,7 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; /// Real documents stay well under this (a page's resources, fonts and descriptors, nested /// form XObjects and field trees are a few levels each); a chain of thousands of indirect /// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; +pub(super) const MAX_COPY_DEPTH: usize = 256; /// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's /// appearance, placement and page. Field state (name, value, flags, default appearance) moves to @@ -56,11 +58,15 @@ pub(crate) struct Copier<'a> { pub(super) fields: Vec, /// Optional content groups copied: (source, destination). pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, + /// When only some pages are copied: the annotations on them and the fields above each, the + /// only kids a copied field keeps (see `fields_on`). `None` keeps every kid. Shared, so the + /// copiers made for a repeated page or an action don't each copy it. + pub(super) kept: Option>>, } impl<'a> Copier<'a> { pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { - Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new(), kept: None } } /// Copy an indirect object (once), returning its destination reference. @@ -93,11 +99,19 @@ impl<'a> Copier<'a> { if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { return Ok(o.clone()); } + if self.acts_on_none(o) { + return Ok(Object::Null); + } self.descend()?; let copied = match o { 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; @@ -112,6 +126,8 @@ impl<'a> Copier<'a> { fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); + // A form action lists the fields it submits, resets or hides. + let lists_fields = matches!(d.name(b"S"), Some(b"SubmitForm" | b"ResetForm" | b"Hide")); let mut out = Dict::new(); for (k, v) in d.iter() { if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { @@ -122,6 +138,34 @@ impl<'a> Copier<'a> { out.set(k.clone(), v.clone()); continue; } + if is_field + && k.as_slice() == b"Kids" + && let Some(kept) = &self.kept + { + let kids: Vec = self + .src + .resolve(v) + .as_array() + .into_iter() + .flatten() + .filter(|kid| kid.as_ref().is_none_or(|r| kept.contains(&r))) + .cloned() + .collect(); + out.set(k.clone(), self.copy_value(dst, &Object::Array(kids), None)?); + continue; + } + // Of some of the pages, an action lists only the fields on them: one left behind would + // come along with its value. Fields named by text stay. + if lists_fields + && matches!(k.as_slice(), b"Fields" | b"T") + && let Some(kept) = &self.kept + { + let listed = self.src.resolve(v); + let entries = listed.as_array().cloned().unwrap_or_else(|| vec![v.clone()]); + let entries: Vec = entries.into_iter().filter(|e| e.as_ref().is_none_or(|r| kept.contains(&r))).collect(); + out.set(k.clone(), self.copy_value(dst, &Object::Array(entries), None)?); + continue; + } out.set(k.clone(), self.copy_value(dst, v, None)?); } if let Some(this) = this { @@ -136,6 +180,34 @@ impl<'a> Copier<'a> { Ok(out) } + /// Whether `o` is a reset or submit action that names only fields left behind (see `kept`). + /// Its copy would list none, and readers (ours too) may take an empty list for every field, + /// which is what an omitted one means (ISO 32000-2 §12.7.6). So it is not copied, nor are the + /// actions after it, as with a link to a page left behind. A list of fields to leave out + /// (Include/Exclude flag set) loses nothing. + fn acts_on_none(&self, o: &Object) -> bool { + let Some(kept) = &self.kept else { return false }; + let held; + let action = match o { + // One copied already is not such an action, and needn't be fetched again. + Object::Ref(r) if !self.map.contains_key(r) => { + held = self.src.resolve(o); + held.as_dict() + } + Object::Dict(d) => Some(d), + _ => None, + }; + let Some(a) = action.filter(|a| matches!(a.name(b"S"), Some(b"ResetForm" | b"SubmitForm"))) else { return false }; + if a.get(b"Flags").and_then(|f| self.src.resolve(f).as_int()).is_some_and(|f| f & 1 != 0) { + return false; + } + let Some(v) = a.get(b"Fields") else { return false }; + let listed = self.src.resolve(v); + // As `copy_dict` reads the list: a lone entry stands for a list of one. + let entries = listed.as_array().map_or(std::slice::from_ref(v), Vec::as_slice); + !entries.is_empty() && entries.iter().all(|e| e.as_ref().is_some_and(|r| !kept.contains(&r))) + } + /// Map a source destination (explicit array or reference to one) to the destination /// document; `None` if it targets a page that was not copied. pub(super) fn map_dest(&self, dest: &Object) -> Option { @@ -196,11 +268,13 @@ impl<'a> Copier<'a> { d.remove(b"A"); } } + } else if self.acts_on_none(&action) { + d.remove(b"A"); } else { // Other actions (URI, Launch, JavaScript…) carry no page references. The // copier lends its map out and takes it back with the new copies in it, // so actions sharing an object share its one copy. - let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let mut c = Copier { kept: self.kept.clone(), ..Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()) }; let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); self.map = c.map; d.set(b"A".to_vec(), copied?); 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 7eb677a84c..6e8f2df4be 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1731,6 +1731,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 +1965,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 +2369,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 +2558,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 { @@ -4241,3 +4382,74 @@ fn an_outline_deeper_than_the_listing_is_exported_as_truncated() { crate::import_outline(&mut target, &[], 0, &tree.nodes, false).unwrap(); assert_eq!(crate::bookmarks(&target).len(), 33); } + +/// Does any object in `doc` hold the string value `/V (value)`? +fn holds_value(doc: &Document, value: &str) -> bool { + doc.scan_objects() + .into_iter() + .any(|(_, o)| o.as_dict().and_then(|d| d.get(b"V")).and_then(|v| v.as_string()).is_some_and(|v| v.to_text() == value)) +} + +/// A field with a widget on each of two pages: extracting one page brings that widget and the field +/// above it, not the other kid with its value. +#[test] +fn extracting_a_page_leaves_the_rest_of_its_form_behind() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [8 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [9 0 R] >>".into(), // 4 + raw("0 0 m"), // 5 + raw("0 0 m"), // 6 + "<< /T (applicant) /Kids [8 0 R 9 0 R] >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Parent 7 0 R /P 3 0 R /Rect [0 0 50 20] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /Parent 7 0 R /P 4 0 R /Rect [0 0 50 20] >>".into(), // 9 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(holds_value(&part, "Ada")); + assert!(!holds_value(&part, "90000"), "the widget on the page left behind came along with its value"); + let form = part.resolve(catalog(&part).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let fields = part.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let applicant = part.resolve(&fields[0]).as_dict().cloned().unwrap(); + assert_eq!(part.resolve(applicant.get(b"Kids").unwrap()).as_array().map(Vec::len), Some(1)); + // With every page, the whole form comes along, as before. + let both = full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()); + assert!(holds_value(&both, "Ada") && holds_value(&both, "90000")); +} + +/// A form action lists fields by reference: extracting the page with the button doesn't bring +/// the fields it submits, resets or hides from a page left behind, with their values. A reset of +/// only those would list none, which reads as every field: it goes. +#[test] +fn a_form_action_brings_no_field_from_a_page_left_behind() { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R 8 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Annots [7 0 R 9 0 R] >>".into(), // 3 + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Annots [8 0 R] >>".into(), // 4 + raw("0 0 m"), // 5 + raw("0 0 m"), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (send) /P 3 0 R /Rect [0 0 50 20] \ + /A << /S /SubmitForm /F (https://example.invalid/) /Fields [8 0 R (other)] \ + /Next << /S /ResetForm /Fields [8 0 R] /Next << /S /Hide /T 8 0 R >> >> >> >>" + .into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (salary) /V (90000) /P 4 0 R /Rect [0 0 50 20] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (clear) /P 3 0 R /Rect [0 30 50 50] \ + /A << /S /ResetForm /Fields [8 0 R] >> /AA << /U << /S /ResetForm /Flags 1 /Fields [8 0 R] >> >> >>" + .into(), // 9 + ]; + let src = open(build(&b, "/Root 1 0 R")); + let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap()); + assert!(!holds_value(&part, "90000"), "a field the action lists came along from the page left behind"); + let submit = part.resolve(annots(&part, 0)[0].get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(part.resolve(submit.get(b"Fields").unwrap()).as_array().map(Vec::len), Some(1), "the field named by text stays"); + assert!(submit.get(b"Next").is_none_or(|n| part.resolve(n).as_dict().is_none()), "the reset after the submit would clear every field"); + let clear = annots(&part, 0).remove(1); + assert!(clear.get(b"A").is_none(), "the clear button would clear every field"); + // A reset of every field but the one left behind still resets them all. + let aa = part.resolve(clear.get(b"AA").unwrap()).as_dict().cloned().unwrap(); + assert!(aa.get(b"U").is_some_and(|u| part.resolve(u).as_dict().is_some())); + // With every page, the actions still list it. + assert!(holds_value(&full_roundtrip(&extract_pages(&src, &[0, 1]).unwrap()), "90000")); +} 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/prc-render/README.md b/crates/prc-render/README.md index 000d673179..8534455f08 100644 --- a/crates/prc-render/README.md +++ b/crates/prc-render/README.md @@ -15,8 +15,16 @@ assemble(&PrcFile) -> Result, RenderError> // every placement's place(&TriangleMesh, &Matrix, colour, triangle_colours) -> WorldMesh Camera::fit(&Bounds, direction, up, perspective, aspect) / Camera::fit_meshes(&[WorldMesh], ...) render(&[WorldMesh], &Camera, &RenderOptions) -> Result +nav::Orbit::named(NamedView, perspective, Upright) / nav::Orbit::looking(direction, up, perspective) +orbit.turn(yaw, pitch) / pan_by([right, up]) / zoom_at(&Bounds, reach, aspect, factor, [right, up]) / anchor_at(&Camera, point) / centre_on / refit +orbit.camera(&Bounds, nav::reach(&[WorldMesh], &Bounds), aspect) -> Result ``` +- `nav` is a viewer's orbit control without the GUI: the fitted view holds the sphere round the model's centre that + holds every drawn vertex, so turning or reshaping the picture never changes the model's size. Once `anchor_at` picks a + model point the view turns about it, and the point keeps its place on the picture. Pitch stays 0.01 rad short of + the up axis, zoom within 0.05–50 of the fitted view, pan within 1,000 model radii; non-finite input is ignored. + - With no product tree, each tessellated mesh is drawn where it lies, in the default colour. - A triangle's own colour wins over its mesh's, which wins over `RenderOptions::colour`. - Opaque triangles are drawn first, then translucent ones blended over them; alpha 0 is not drawn. diff --git a/crates/prc-render/src/lib.rs b/crates/prc-render/src/lib.rs index 9cfd49efbe..cfb22e7fd8 100644 --- a/crates/prc-render/src/lib.rs +++ b/crates/prc-render/src/lib.rs @@ -2,7 +2,8 @@ //! //! Layer L3: depends only on `pdfcraft-prc`. [`assemble`] turns a parsed [`pdfcraft_prc::PrcFile`] //! into world-space meshes; [`render`] draws them from a [`Camera`] with one depth-tested sample per -//! pixel centre and a light at the camera. Single-threaded and GUI-free, so it runs unchanged on wasm32. +//! pixel centre and a light at the camera; [`nav`] turns a viewer's input into that camera. +//! Single-threaded and GUI-free, so it runs unchanged on wasm32. //! The image size is capped by [`MAX_RENDER_PIXELS`], the drawing work by [`MAX_RENDER_WORK`] and the //! assembled model by [`MAX_ASSEMBLED_TRIANGLES`] and [`MAX_ASSEMBLED_VERTICES`]; bad input is an //! error, never a panic. @@ -10,6 +11,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod assemble; +pub mod nav; mod raster; mod render; mod vec3; diff --git a/crates/prc-render/src/nav/axes.rs b/crates/prc-render/src/nav/axes.rs new file mode 100644 index 0000000000..d89c1eb8c6 --- /dev/null +++ b/crates/prc-render/src/nav/axes.rs @@ -0,0 +1,150 @@ +//! Which of the model's axes points up, and which way the Front view looks. + +/// One signed model axis. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + /// +x. + PosX, + /// +y. + PosY, + /// +z. + PosZ, + /// -x. + NegX, + /// -y. + NegY, + /// -z. + NegZ, +} + +impl Axis { + /// Every axis, in [`Axis::token`] order `+x +y +z -x -y -z`. + pub const ALL: [Axis; 6] = [Axis::PosX, Axis::PosY, Axis::PosZ, Axis::NegX, Axis::NegY, Axis::NegZ]; + + /// The unit vector it names. + #[must_use] + pub fn vector(self) -> [f64; 3] { + match self { + Axis::PosX => [1.0, 0.0, 0.0], + Axis::PosY => [0.0, 1.0, 0.0], + Axis::PosZ => [0.0, 0.0, 1.0], + Axis::NegX => [-1.0, 0.0, 0.0], + Axis::NegY => [0.0, -1.0, 0.0], + Axis::NegZ => [0.0, 0.0, -1.0], + } + } + + /// Its token: `+x` … `-z`. + #[must_use] + pub fn token(self) -> &'static str { + match self { + Axis::PosX => "+x", + Axis::PosY => "+y", + Axis::PosZ => "+z", + Axis::NegX => "-x", + Axis::NegY => "-y", + Axis::NegZ => "-z", + } + } + + /// The axis whose [`Axis::token`] is `token`. + #[must_use] + pub fn from_token(token: &str) -> Option { + Axis::ALL.into_iter().find(|a| a.token() == token) + } + + /// Whether `other` lies along a different line through the origin. + #[must_use] + pub fn perpendicular(self, other: Axis) -> bool { + self.line() != other.line() + } + + /// 0, 1 or 2 for the x, y or z line. + fn line(self) -> u8 { + match self { + Axis::PosX | Axis::NegX => 0, + Axis::PosY | Axis::NegY => 1, + Axis::PosZ | Axis::NegZ => 2, + } + } + + /// The Front view's look when this axis is up: along +y for z or x up (the CAD convention), + /// along -z for y up (the convention of y-up exporters). + fn default_front(self) -> Axis { + match self { + Axis::PosY | Axis::NegY => Axis::NegZ, + _ => Axis::PosY, + } + } +} + +/// The up axis and the Front view's look direction; always perpendicular. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Upright { + up: Axis, + front: Axis, +} + +impl Default for Upright { + /// z up, Front looking along +y. + fn default() -> Self { + Upright { up: Axis::PosZ, front: Axis::PosY } + } +} + +impl Upright { + /// The up axis. + #[must_use] + pub fn up(self) -> Axis { + self.up + } + + /// The Front view's look direction. + #[must_use] + pub fn front(self) -> Axis { + self.front + } + + /// `up` as the up axis, keeping the Front look when it is still perpendicular, else that + /// axis's conventional one. + #[must_use] + pub fn with_up(self, up: Axis) -> Upright { + let front = if up.perpendicular(self.front) { self.front } else { up.default_front() }; + Upright { up, front } + } + + /// `front` as the Front view's look, or unchanged when it lies along up. + #[must_use] + pub fn with_front(self, front: Axis) -> Upright { + if self.up.perpendicular(front) { Upright { front, ..self } } else { self } + } + + /// The Front looks a choice can offer: the four perpendicular to up. + pub fn fronts(self) -> impl Iterator { + Axis::ALL.into_iter().filter(move |a| self.up.perpendicular(*a)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_new_up_keeps_a_front_that_still_fits_and_replaces_one_that_does_not() { + let start = Upright::default(); + assert_eq!(start.with_up(Axis::PosX).front(), Axis::PosY); + assert_eq!(start.with_up(Axis::PosY).front(), Axis::NegZ); + assert_eq!(start.with_front(Axis::NegZ), start); + assert_eq!(start.fronts().count(), 4); + } + + #[test] + fn every_axis_is_a_signed_unit_vector_with_a_token_that_reads_back() { + assert_eq!(Axis::NegY.vector(), [0.0, -1.0, 0.0]); + assert_eq!(Axis::PosZ.vector(), [0.0, 0.0, 1.0]); + for a in Axis::ALL { + assert_eq!(Axis::from_token(a.token()), Some(a)); + } + assert_eq!(Axis::from_token("z"), None); + } +} diff --git a/crates/prc-render/src/nav/mod.rs b/crates/prc-render/src/nav/mod.rs new file mode 100644 index 0000000000..8e238108bd --- /dev/null +++ b/crates/prc-render/src/nav/mod.rs @@ -0,0 +1,9 @@ +//! Camera navigation for a model view, without a GUI: an [`Orbit`] turned, panned and zoomed by a +//! viewer's input (or by `model_render`'s moves) gives the [`crate::Camera`] to draw from, and an +//! [`Upright`] says which model axis points up and which way the Front view looks. + +mod axes; +mod orbit; + +pub use axes::{Axis, Upright}; +pub use orbit::{NamedView, Orbit, PITCH_LIMIT, ZOOM_RANGE, reach}; diff --git a/crates/prc-render/src/nav/orbit.rs b/crates/prc-render/src/nav/orbit.rs new file mode 100644 index 0000000000..c9d6d9e413 --- /dev/null +++ b/crates/prc-render/src/nav/orbit.rs @@ -0,0 +1,398 @@ +//! Orbiting: a turn about an up axis, a zoom and a pan, each relative to the view that fits the +//! model or, once [`Orbit::anchor_at`] picks a model point, relative to that point. +//! +//! The fitted view frames the sphere round the model's centre that holds every vertex +//! ([`reach`]), so turning the model or reshaping the picture never changes its size. + +use std::f64::consts::{FRAC_PI_2, TAU}; + +use super::axes::Upright; +use crate::WorldMesh; +use crate::render::{Bounds, Camera, Projection, RenderError, vertices}; +use crate::vec3::{Vec3, add, cross, dot, length, normalize, scale, sub}; + +/// How far the view may look up or down, radians: kept off ±90° so it never looks along the up axis. +pub const PITCH_LIMIT: f64 = FRAC_PI_2 - 0.01; +/// The zoom range, as a multiple of the fitted view. +pub const ZOOM_RANGE: (f64, f64) = (0.05, 50.0); +/// How far the pan may move the target, in model radii. +const PAN_LIMIT: f64 = 1e3; +/// The perspective field of view, degrees, vertically. +const FOV_Y: f64 = 30.0; +/// The clear space round the fitted sphere, as a multiple of its radius. +const MARGIN: f64 = 1.05; + +/// A named view, turned about an [`Upright`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NamedView { + /// From the front, right and above, as an isometric drawing looks. + Isometric, + /// Along the Front direction. + Front, + /// From the right, a quarter turn from Front. + Right, + /// Looking down the up axis, just short of it. + Top, + /// Against the Front direction. + Back, +} + +impl NamedView { + /// Every named view, in [`NamedView::token`] order. + pub const ALL: [NamedView; 5] = [NamedView::Isometric, NamedView::Front, NamedView::Right, NamedView::Top, NamedView::Back]; + + /// Its token: `iso`, `front`, `right`, `top` or `back`. + #[must_use] + pub fn token(self) -> &'static str { + match self { + NamedView::Isometric => "iso", + NamedView::Front => "front", + NamedView::Right => "right", + NamedView::Top => "top", + NamedView::Back => "back", + } + } + + /// The view whose [`NamedView::token`] is `token`. + #[must_use] + pub fn from_token(token: &str) -> Option { + NamedView::ALL.into_iter().find(|v| v.token() == token) + } + + /// (yaw, pitch) in degrees: yaw 0 looks along Front, positive pitch looks down. + fn angles(self) -> (f64, f64) { + match self { + NamedView::Isometric => (45.0, 35.264), + NamedView::Front => (0.0, 0.0), + NamedView::Right => (90.0, 0.0), + NamedView::Top => (0.0, 89.0), + NamedView::Back => (180.0, 0.0), + } + } +} + +/// How far from parallel two unit vectors must be to give a direction across them. +const PARALLEL: f64 = 1e-9; + +/// The model point an orbit turns about: its depth along the look, and its offset right and up +/// from the look's axis in model units. An orthographic view shows the height a perspective one +/// would at that depth, so switching projection keeps the scale. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Anchor { + point: Vec3, + distance: f64, + offset: [f64; 2], +} + +impl Anchor { + /// The point on the look's axis level with the anchor, for picture axes `right` and `up`. + fn base(&self, right: Vec3, up: Vec3) -> Vec3 { + let [x, y] = self.offset; + sub(sub(self.point, scale(right, x)), scale(up, y)) + } +} + +/// Yaw 0, pitch 0 looks along `front`, and `up` appears upward; both unit and perpendicular. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Axes { + front: Vec3, + up: Vec3, +} + +impl Axes { + /// The unit direction `yaw` round and `pitch` down from `front`. + fn look(&self, yaw: f64, pitch: f64) -> Vec3 { + let (sy, cy) = yaw.sin_cos(); + let (sp, cp) = pitch.sin_cos(); + let right = cross(self.front, self.up); + add(add(scale(right, -sy * cp), scale(self.front, cy * cp)), scale(self.up, -sp)) + } + + /// The yaw and pitch [`Axes::look`] turns into `direction`, the pitch held inside + /// [`PITCH_LIMIT`]; `None` for a zero or non-finite direction. + fn angles(&self, direction: Vec3) -> Option<(f64, f64)> { + let d = normalize(direction)?; + let pitch = (-dot(d, self.up)).clamp(-1.0, 1.0).asin(); + let yaw = (-dot(d, cross(self.front, self.up))).atan2(dot(d, self.front)); + Some((yaw, pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT))) + } + + /// The picture's right and up when looking along `dir`; `None` when `dir` lies along up. + fn picture(&self, dir: Vec3) -> Option<(Vec3, Vec3)> { + let right = normalize(cross(dir, self.up))?; + Some((right, cross(right, dir))) + } +} + +/// Where the camera is, relative to the fitted view or to the point turned about. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Orbit { + yaw: f64, + pitch: f64, + zoom: f64, + pan: [f64; 2], + perspective: bool, + axes: Axes, + anchor: Option, +} + +impl Orbit { + /// `view`, turned about `upright`. + #[must_use] + pub fn named(view: NamedView, perspective: bool, upright: Upright) -> Orbit { + let (yaw, pitch) = view.angles(); + let axes = Axes { front: upright.front().vector(), up: upright.up().vector() }; + Orbit { yaw: yaw.to_radians(), pitch: pitch.to_radians(), zoom: 1.0, pan: [0.0; 2], perspective, axes, anchor: None } + } + + /// Looking along `direction`, turning about `up` with the part along `direction` removed; `None` + /// when either is zero or not finite, or they are parallel. + #[must_use] + pub fn looking(direction: [f64; 3], up: [f64; 3], perspective: bool) -> Option { + let front = normalize(direction)?; + let up = normalize(up)?; + // Parallel unit vectors leave a rounding residue, not a direction. + let across = sub(up, scale(front, dot(up, front))); + if length(across) < PARALLEL { + return None; + } + let up = normalize(across)?; + Some(Orbit { yaw: 0.0, pitch: 0.0, zoom: 1.0, pan: [0.0; 2], perspective, axes: Axes { front, up }, anchor: None }) + } + + /// Radians round the up axis, in `[0, 2π)` once turned. + #[must_use] + pub fn yaw(&self) -> f64 { + self.yaw + } + + /// Radians above the horizon, looking down when positive; within [`PITCH_LIMIT`]. + #[must_use] + pub fn pitch(&self) -> f64 { + self.pitch + } + + /// The zoom, as a multiple of the fitted view; within [`ZOOM_RANGE`]. + #[must_use] + pub fn zoom(&self) -> f64 { + self.zoom + } + + /// The target's shift along the picture's right and up, in model radii. + #[must_use] + pub fn pan(&self) -> [f64; 2] { + self.pan + } + + /// Whether the projection is perspective rather than orthographic. + #[must_use] + pub fn perspective(&self) -> bool { + self.perspective + } + + /// The point turned about, once [`Orbit::anchor_at`] has picked one. + #[must_use] + pub fn anchor(&self) -> Option<[f64; 3]> { + self.anchor.map(|a| a.point) + } + + /// Switch projection; an anchored view keeps its scale. + pub fn set_perspective(&mut self, perspective: bool) { + self.perspective = perspective; + } + + /// The unit direction the camera looks along. + #[must_use] + pub fn direction(&self) -> [f64; 3] { + self.axes.look(self.yaw, self.pitch) + } + + /// Turn by `yaw` round the up axis and `pitch` down, radians; non-finite turns are ignored. + pub fn turn(&mut self, yaw: f64, pitch: f64) { + if yaw.is_finite() && pitch.is_finite() { + self.yaw = (self.yaw + yaw).rem_euclid(TAU); + self.pitch = (self.pitch + pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + } + + /// Move the target by `[right, up]` model radii; non-finite moves are ignored. + pub fn pan_by(&mut self, [right, up]: [f64; 2]) { + if right.is_finite() && up.is_finite() { + self.set_pan([self.pan[0] + right, self.pan[1] + up]); + } + } + + /// Look along `direction`, fitted again; a direction along the up axis stops just short of it, + /// and a zero or non-finite one changes nothing. + pub fn look_along(&mut self, direction: [f64; 3]) { + if let Some((yaw, pitch)) = self.axes.angles(direction) { + (self.yaw, self.pitch) = (yaw, pitch); + self.refit(); + } + } + + /// Back to the view that fits the whole model, keeping the direction: no zoom, pan or anchor. + pub fn refit(&mut self) { + (self.zoom, self.pan, self.anchor) = (1.0, [0.0; 2], None); + } + + /// Turn about `point` from now on without moving the picture; `camera` is this orbit's + /// camera, and `point` stays where it shows on it. Whether the point was taken: not when it + /// is not finite, or lies at or behind the eye of a perspective view. + pub fn anchor_at(&mut self, camera: &Camera, point: [f64; 3]) -> bool { + let dir = self.direction(); + let Some((right, image_up)) = self.axes.picture(dir) else { return false }; + let v = sub(point, camera.eye); + let offset = [dot(v, right), dot(v, image_up)]; + let distance = match camera.projection { + Projection::Orthographic { height } => height / 2.0 / half_fov().tan(), + Projection::Perspective { .. } => dot(v, dir), + }; + let taken = distance.is_finite() && distance > 0.0 && offset.iter().all(|c| c.is_finite()); + if taken { + self.anchor = Some(Anchor { point, distance, offset }); + (self.zoom, self.pan) = (1.0, [0.0; 2]); + } + taken + } + + /// Pan so `point` sits at the picture's centre, keeping the turn and the zoom. + pub fn centre_on(&mut self, bounds: &Bounds, point: [f64; 3]) { + let Some((right, image_up)) = self.axes.picture(self.direction()) else { return }; + let centre = self.anchor.map_or_else(|| bounds.centre(), |a| a.base(right, image_up)); + let off = sub(point, centre); + let r = radius(bounds); + let pan = [dot(off, right) / r, dot(off, image_up) / r]; + if pan.iter().all(|c| c.is_finite()) { + self.set_pan(pan); + } + } + + /// Multiply the zoom by `factor` (held within [`ZOOM_RANGE`]), keeping the model point under + /// the pointer where it is on the picture. `offset` is the pointer's position from the + /// picture's centre, right and up, in picture heights; `bounds`, `reach` and `aspect` are as + /// [`Orbit::camera`] takes them. Exact on the plane through the target facing the camera, the + /// plane the pan moves in. A factor that is not positive and finite changes nothing. + pub fn zoom_at(&mut self, bounds: &Bounds, reach: f64, aspect: f64, factor: f64, offset: [f64; 2]) { + if !(factor.is_finite() && factor > 0.0 && offset.iter().all(|c| c.is_finite())) { + return; + } + let before = self.zoom; + let after = (before * factor).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + let fitted = Orbit { zoom: 1.0, pan: [0.0; 2], ..*self }; + // What one picture height spans at zoom 1, in model radii. + let span = fitted.camera(bounds, reach, aspect).map_or(2.0, |c| visible_height(&c)) / radius(bounds); + let shift = span * (1.0 / before - 1.0 / after); + let [x, y] = offset; + self.set_pan([self.pan[0] + x * shift, self.pan[1] + y * shift]); + self.zoom = after; + } + + /// The camera for a model in `bounds` whose every vertex lies within `reach` of its centre + /// ([`reach`]; [`Bounds::radius`] stands in when it is not positive), for a picture of `aspect` + /// (width / height; 1 stands in when it is not positive). + /// + /// # Errors + /// [`RenderError::Camera`] when the view does not form: a reach or a position out of the + /// finite range. + pub fn camera(&self, bounds: &Bounds, reach: f64, aspect: f64) -> Result { + let dir = self.direction(); + let (right, image_up) = self.axes.picture(dir).ok_or(RenderError::Camera("the view looks along its up axis"))?; + let up = self.axes.up; + let mut camera = match self.anchor { + Some(a) => about(a, dir, (right, image_up), up, self.perspective), + None => { + let reach = match reach { + r if !r.is_finite() => return Err(RenderError::Camera("the model's reach is out of range")), + r if r > 0.0 => r, + _ => radius(bounds), + }; + fitted(bounds.centre(), reach, dir, up, self.perspective, aspect) + } + }; + let [x, y] = self.pan; + let target = add(camera.target, scale(add(scale(right, x), scale(image_up, y)), radius(bounds))); + camera.eye = add(target, scale(sub(camera.eye, camera.target), 1.0 / self.zoom)); + camera.target = target; + if let Projection::Orthographic { height } = camera.projection { + camera.projection = Projection::Orthographic { height: height / self.zoom }; + } + let finite = camera.eye.iter().chain(&camera.target).all(|c| c.is_finite()) + && match camera.projection { + Projection::Orthographic { height } => height.is_finite() && height > 0.0, + Projection::Perspective { .. } => true, + }; + if !finite { + return Err(RenderError::Camera("the view is out of range")); + } + camera.basis()?; + Ok(camera) + } + + /// The pan, each part held within [`PAN_LIMIT`]; a non-finite pan changes nothing. + fn set_pan(&mut self, pan: [f64; 2]) { + if pan.iter().all(|c| c.is_finite()) { + self.pan = pan.map(|c| c.clamp(-PAN_LIMIT, PAN_LIMIT)); + } + } +} + +/// How far the farthest vertex the meshes draw lies from `bounds`' centre: the radius of the +/// sphere [`Orbit::camera`] fits. Half the box's diagonal when there is none, and infinite when a +/// distance overflows, which [`Orbit::camera`] refuses. +#[must_use] +pub fn reach(meshes: &[WorldMesh], bounds: &Bounds) -> f64 { + let c = bounds.centre(); + let far = vertices(meshes).map(|p| length(sub(p, c))).fold(0.0_f64, |far, d| if d.is_finite() { far.max(d) } else { f64::INFINITY }); + if far > 0.0 { far } else { radius(bounds) } +} + +/// Half the vertical field of view, radians. +fn half_fov() -> f64 { + (FOV_Y / 2.0).to_radians() +} + +/// The camera looking along `dir` that holds the sphere of `reach` round `centre` in a picture of +/// `aspect`, whichever way it looks; only the picture's shorter side changes the model's size. +fn fitted(centre: Vec3, reach: f64, dir: Vec3, up: Vec3, perspective: bool, aspect: f64) -> Camera { + let r = reach * MARGIN; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let half = half_fov().min((half_fov().tan() * aspect).atan()); + let (distance, projection) = if perspective { + (r / half.sin(), Projection::Perspective { fov_y: FOV_Y }) + } else { + (2.0 * r, Projection::Orthographic { height: 2.0 * r / aspect.min(1.0) }) + }; + Camera { eye: sub(centre, scale(dir, distance)), target: centre, up, projection } +} + +/// The camera looking along `dir` that shows `anchor`'s point at its depth and offset. +fn about(anchor: Anchor, dir: Vec3, (right, image_up): (Vec3, Vec3), up: Vec3, perspective: bool) -> Camera { + let base = anchor.base(right, image_up); + let projection = if perspective { + Projection::Perspective { fov_y: FOV_Y } + } else { + Projection::Orthographic { height: 2.0 * anchor.distance * half_fov().tan() } + }; + Camera { eye: sub(base, scale(dir, anchor.distance)), target: base, up, projection } +} + +/// The model's radius, or 1 for a model with no extent. +fn radius(bounds: &Bounds) -> f64 { + match bounds.radius() { + r if r.is_finite() && r > 0.0 => r, + _ => 1.0, + } +} + +/// How much of the target plane the picture shows vertically, in model units. +fn visible_height(camera: &Camera) -> f64 { + match camera.projection { + Projection::Orthographic { height } => height, + Projection::Perspective { fov_y } => 2.0 * length(sub(camera.eye, camera.target)) * (fov_y.min(179.0).to_radians() / 2.0).tan(), + } +} + +#[cfg(test)] +#[path = "orbit_tests.rs"] +mod tests; diff --git a/crates/prc-render/src/nav/orbit_tests.rs b/crates/prc-render/src/nav/orbit_tests.rs new file mode 100644 index 0000000000..90c9e76452 --- /dev/null +++ b/crates/prc-render/src/nav/orbit_tests.rs @@ -0,0 +1,287 @@ +use super::super::axes::Axis; +use super::*; + +fn cube() -> Bounds { + Bounds { min: [-1.0; 3], max: [1.0; 3] } +} + +fn unit(v: Vec3) -> Vec3 { + normalize(v).unwrap() +} + +fn parallel(a: Vec3, b: Vec3) -> bool { + (dot(unit(a), unit(b)) - 1.0).abs() < 1e-9 +} + +fn gap(c: &Camera) -> f64 { + length(sub(c.eye, c.target)) +} + +/// Looking along +x and down, with +y up: no named view's axes. +fn corner() -> Orbit { + Orbit::looking([0.6, 0.0, -0.8], [0.0, 1.0, 0.0], true).unwrap() +} + +#[test] +fn the_named_views_look_the_way_their_names_say() { + let look = |v| Orbit::named(v, false, Upright::default()).direction(); + let front = look(NamedView::Front); + assert!((front[1] - 1.0).abs() < 1e-9, "front looks along +y: {front:?}"); + let right = look(NamedView::Right); + assert!((right[0] + 1.0).abs() < 1e-9, "right looks along -x, from +x: {right:?}"); + let top = look(NamedView::Top); + assert!(top[2] < -0.99, "top looks down: {top:?}"); + let iso = look(NamedView::Isometric); + assert!(iso[0] < 0.0 && iso[1] > 0.0 && iso[2] < 0.0, "{iso:?}"); + for v in NamedView::ALL { + assert_eq!(NamedView::from_token(v.token()), Some(v)); + } +} + +#[test] +fn with_y_up_the_named_views_turn_about_y() { + let upright = Upright::default().with_up(Axis::PosY); + let front = Orbit::named(NamedView::Front, false, upright).direction(); + assert!(front[2] < -0.999, "front looks along -z: {front:?}"); + let top = Orbit::named(NamedView::Top, false, upright).direction(); + assert!(top[1] < -0.99, "top looks down -y: {top:?}"); +} + +#[test] +fn a_view_of_its_own_looks_along_its_direction_with_its_own_up() { + let camera = corner().camera(&cube(), 1.0, 1.5).unwrap(); + assert!(parallel(sub(camera.target, camera.eye), [0.6, 0.0, -0.8])); + assert!(parallel(camera.up, [0.0, 1.0, 0.0]), "{:?}", camera.up); + // An up that is not perpendicular to the look keeps only its perpendicular part. + let tilted = Orbit::looking([1.0, 0.0, 0.0], [1.0, 0.0, 1.0], true).unwrap(); + assert!(parallel(tilted.axes.up, [0.0, 0.0, 1.0])); + assert!(parallel(tilted.direction(), [1.0, 0.0, 0.0])); +} + +#[test] +fn a_view_with_its_up_along_its_look_or_out_of_range_is_refused() { + assert!(Orbit::looking([0.0, 0.0, 2.0], [0.0, 0.0, 1.0], true).is_none()); + assert!(Orbit::looking([0.0; 3], [0.0, 0.0, 1.0], true).is_none()); + assert!(Orbit::looking([f64::NAN, 0.0, 1.0], [0.0, 1.0, 0.0], true).is_none()); + assert!(Orbit::looking([f64::MAX, f64::MAX, 0.0], [0.0, 0.0, f64::MAX], true).is_some()); + for v in [[1.0, 1.0, 1.0], [0.1, 0.7, 0.3], [-3.0, 1e-3, 7.0]] { + assert!(Orbit::looking(v, v, true).is_none(), "{v:?} along itself"); + assert!(Orbit::looking(v, scale(v, -2.0), true).is_none(), "{v:?} against itself"); + } +} + +#[test] +fn zoom_brings_the_eye_closer_and_pan_moves_the_target() { + let bounds = cube(); + let mut orbit = Orbit::named(NamedView::Front, true, Upright::default()); + let fitted = orbit.camera(&bounds, 1.0, 1.0).unwrap(); + orbit.zoom_at(&bounds, 1.0, 1.0, 2.0, [0.0, 0.0]); + orbit.pan_by([0.5, 0.0]); + let moved = orbit.camera(&bounds, 1.0, 1.0).unwrap(); + assert!((gap(&moved) - gap(&fitted) / 2.0).abs() < 1e-9); + assert!(moved.target != fitted.target); + assert!(moved.target[2].abs() < 1e-9, "a sideways pan keeps the height"); +} + +/// The model point on the target plane at `offset` (picture heights, right and up from the centre). +fn under(orbit: &Orbit, bounds: &Bounds, aspect: f64, offset: [f64; 2]) -> Vec3 { + let camera = orbit.camera(bounds, 2.0, aspect).unwrap(); + let (right, up) = orbit.axes.picture(orbit.direction()).unwrap(); + let h = visible_height(&camera); + add(camera.target, scale(add(scale(right, offset[0]), scale(up, offset[1])), h)) +} + +#[test] +fn a_wheel_zoom_keeps_the_point_under_the_pointer_still() { + let bounds = Bounds { min: [-1.0, -2.0, -0.5], max: [3.0, 1.0, 2.0] }; + for start in + [Orbit::named(NamedView::Isometric, true, Upright::default()), Orbit::named(NamedView::Isometric, false, Upright::default()), corner()] + { + let mut orbit = start; + orbit.pan_by([0.1, -0.2]); + let (aspect, offset) = (1.6, [0.3, -0.15]); + let before = under(&orbit, &bounds, aspect, offset); + orbit.zoom_at(&bounds, 2.0, aspect, 2.5, offset); + let after = under(&orbit, &bounds, aspect, offset); + for (b, a) in before.iter().zip(after) { + assert!((b - a).abs() < 1e-9, "{before:?} vs {after:?}"); + } + assert!((orbit.zoom() - 2.5).abs() < 1e-12); + } +} + +#[test] +fn a_wheel_zoom_at_the_centre_does_not_pan_and_stays_in_range() { + let mut orbit = Orbit::named(NamedView::Front, true, Upright::default()); + orbit.zoom_at(&cube(), 1.0, 1.0, 3.0, [0.0, 0.0]); + assert_eq!(orbit.pan(), [0.0, 0.0]); + orbit.zoom_at(&cube(), 1.0, 1.0, 1e300, [0.0, 0.0]); + assert_eq!(orbit.zoom(), ZOOM_RANGE.1); + orbit.zoom_at(&cube(), 1.0, 1.0, 1e-300, [0.0, 0.0]); + assert_eq!(orbit.zoom(), ZOOM_RANGE.0); +} + +#[test] +fn turning_or_widening_the_picture_keeps_the_models_size() { + let bounds = Bounds { min: [-4.0, -1.0, 0.0], max: [4.0, 1.0, 0.5] }; + let mut orbit = Orbit::named(NamedView::Isometric, true, Upright::default()); + let first = orbit.camera(&bounds, 4.0, 1.5).unwrap(); + for yaw in [0.3, 0.8, 0.9] { + orbit.turn(yaw, 0.0); + for aspect in [1.2, 1.5, 3.0] { + let c = orbit.camera(&bounds, 4.0, aspect).unwrap(); + assert!((gap(&c) - gap(&first)).abs() < 1e-9, "{yaw} {aspect}"); + assert_eq!(c.target, first.target); + } + } +} + +#[test] +fn a_turn_holds_the_pitch_off_the_up_axis_and_ignores_non_finite_input() { + let mut orbit = Orbit::named(NamedView::Front, true, Upright::default()); + orbit.turn(-1.0, 10.0); + assert_eq!(orbit.pitch(), PITCH_LIMIT); + assert!((0.0..TAU).contains(&orbit.yaw())); + assert!(orbit.camera(&cube(), 1.0, 1.0).is_ok(), "the top-most view still forms"); + let held = orbit; + orbit.turn(f64::NAN, 0.0); + orbit.pan_by([f64::INFINITY, 0.0]); + orbit.zoom_at(&cube(), 1.0, 1.0, f64::NAN, [0.0, 0.0]); + orbit.zoom_at(&cube(), 1.0, 1.0, -2.0, [0.0, 0.0]); + orbit.look_along([0.0; 3]); + assert_eq!(orbit, held); +} + +#[test] +fn a_far_pan_is_held_and_an_out_of_range_model_is_an_error_not_a_panic() { + let mut orbit = Orbit::named(NamedView::Front, true, Upright::default()); + orbit.pan_by([1e300, -1e300]); + assert_eq!(orbit.pan(), [PAN_LIMIT, -PAN_LIMIT]); + let huge = Bounds { min: [-f64::MAX; 3], max: [f64::MAX; 3] }; + assert!(orbit.camera(&huge, f64::MAX, 1.0).is_err()); + let flat = Bounds { min: [0.0; 3], max: [0.0; 3] }; + assert!(orbit.camera(&flat, 0.0, f64::NAN).is_ok(), "an empty extent falls back to a unit radius"); + assert!(orbit.camera(&cube(), f64::INFINITY, 1.0).is_err(), "an overflowing reach is refused, not replaced"); + assert!(orbit.camera(&cube(), f64::NAN, 1.0).is_err()); + // A shift that overflows to NaN (a zoom about a model at the edge of the range) leaves the pan. + orbit.set_pan([f64::NAN, 0.0]); + assert_eq!(orbit.pan(), [PAN_LIMIT, -PAN_LIMIT]); +} + +#[test] +fn looking_along_the_up_axis_stops_just_short_of_it_and_refits() { + let mut orbit = Orbit::named(NamedView::Front, true, Upright::default()); + orbit.zoom_at(&cube(), 1.0, 1.0, 2.0, [0.1, 0.1]); + orbit.look_along([0.0, 0.0, -1.0]); + assert_eq!(orbit.pitch(), PITCH_LIMIT); + assert_eq!((orbit.zoom(), orbit.pan()), (1.0, [0.0, 0.0])); +} + +#[test] +fn an_anchor_keeps_the_picture_then_turns_about_its_point() { + let bounds = cube(); + for perspective in [true, false] { + let mut orbit = Orbit::named(NamedView::Isometric, perspective, Upright::default()); + orbit.zoom_at(&bounds, 1.5, 1.4, 1.7, [0.0, 0.0]); + orbit.pan_by([0.2, -0.1]); + let before = orbit.camera(&bounds, 1.5, 1.4).unwrap(); + let point = add(before.target, scale(orbit.direction(), 0.4)); + assert!(orbit.anchor_at(&before, point)); + assert_eq!(orbit.anchor(), Some(point)); + let after = orbit.camera(&bounds, 1.5, 1.4).unwrap(); + if perspective { + for (a, b) in after.eye.iter().zip(before.eye) { + assert!((a - b).abs() < 1e-9); + } + } else { + assert!((visible_height(&after) - visible_height(&before)).abs() < 1e-9); + } + orbit.turn(0.8, 0.4); + let turned = orbit.camera(&bounds, 1.5, 1.4).unwrap(); + for (t, p) in turned.target.iter().zip(point) { + assert!((t - p).abs() < 1e-9); + } + } +} + +#[test] +fn a_point_behind_the_eye_is_not_turned_about() { + let mut orbit = Orbit::named(NamedView::Front, true, Upright::default()); + let camera = orbit.camera(&cube(), 1.0, 1.0).unwrap(); + let behind = sub(camera.eye, orbit.direction()); + assert!(!orbit.anchor_at(&camera, behind)); + assert!(!orbit.anchor_at(&camera, [f64::NAN, 0.0, 0.0])); + assert_eq!(orbit.anchor(), None); +} + +#[test] +fn a_refit_forgets_the_point_turned_about() { + let bounds = cube(); + let mut orbit = Orbit::named(NamedView::Isometric, true, Upright::default()); + let fitted = orbit.camera(&bounds, 1.5, 1.4).unwrap(); + assert!(orbit.anchor_at(&fitted, [0.3, 0.2, 0.1])); + orbit.zoom_at(&bounds, 1.5, 1.4, 2.0, [0.0, 0.0]); + orbit.refit(); + assert_eq!(orbit.anchor(), None); + assert_eq!(orbit.camera(&bounds, 1.5, 1.4).unwrap(), fitted); +} + +#[test] +fn centring_on_a_point_puts_it_at_the_target() { + let bounds = cube(); + for mut orbit in [Orbit::named(NamedView::Isometric, true, Upright::default()), corner()] { + let point = [0.5, -0.25, 0.75]; + orbit.centre_on(&bounds, point); + let c = orbit.camera(&bounds, 1.0, 1.0).unwrap(); + // The target is the point moved along the look, so the point shows at the centre. + assert!(parallel(sub(point, c.eye), sub(c.target, c.eye)) || length(sub(point, c.target)) < 1e-9); + } +} + +/// Where `p` shows on `c`'s picture: right and up of the centre, as a share of the visible height +/// at `p`'s depth. +fn shown(c: &Camera, p: Vec3) -> [f64; 2] { + let f = unit(sub(c.target, c.eye)); + let r = unit(cross(f, c.up)); + let u = cross(r, f); + let v = sub(p, c.eye); + let h = match c.projection { + Projection::Perspective { fov_y } => 2.0 * dot(v, f) * (fov_y / 2.0).to_radians().tan(), + Projection::Orthographic { height } => height, + }; + [dot(v, r) / h, dot(v, u) / h] +} + +#[test] +fn a_pressed_point_off_centre_keeps_its_place_as_the_view_turns() { + let bounds = cube(); + for perspective in [true, false] { + let mut orbit = Orbit::named(NamedView::Isometric, perspective, Upright::default()); + orbit.zoom_at(&bounds, 1.5, 1.4, 1.3, [0.0, 0.0]); + let before = orbit.camera(&bounds, 1.5, 1.4).unwrap(); + let f = orbit.direction(); + let (r, u) = orbit.axes.picture(f).unwrap(); + let point = add(before.target, add(add(scale(r, 0.3), scale(u, 0.2)), scale(f, 0.4))); + let at = shown(&before, point); + assert!(orbit.anchor_at(&before, point)); + for (yaw, pitch) in [(0.0, 0.0), (0.8, 0.4), (-1.9, -1.1)] { + orbit.turn(yaw, pitch); + let c = orbit.camera(&bounds, 1.5, 1.4).unwrap(); + let now = shown(&c, point); + for (n, a) in now.iter().zip(at) { + assert!((n - a).abs() < 1e-9, "{perspective} {now:?} {at:?}"); + } + } + } +} + +#[test] +fn a_vertex_too_far_to_measure_gives_an_infinite_reach_not_a_near_one() { + let mut mesh = pdfcraft_prc::TriangleMesh::default(); + mesh.positions = vec![[-f64::MAX, -f64::MAX, 0.0], [f64::MAX, f64::MAX, 0.0], [f64::MAX, -f64::MAX, 0.0], [0.0, 0.0, 0.0]]; + mesh.triangles = vec![[0, 1, 2], [3, 3, 3]]; + let meshes = [WorldMesh::new(mesh)]; + let bounds = Bounds::of(&meshes).unwrap(); + assert_eq!(reach(&meshes, &bounds), f64::INFINITY); + assert!(Orbit::named(NamedView::Front, true, Upright::default()).camera(&bounds, reach(&meshes, &bounds), 1.0).is_err()); +} diff --git a/crates/prc-render/src/render.rs b/crates/prc-render/src/render.rs index bcaf8acced..f369bd3a93 100644 --- a/crates/prc-render/src/render.rs +++ b/crates/prc-render/src/render.rs @@ -86,7 +86,7 @@ impl Bounds { /// Every corner of the triangles [`render`] draws: those not fully transparent whose three /// corners exist and are finite. -fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { +pub(crate) fn vertices(meshes: &[WorldMesh]) -> impl Iterator + Clone + '_ { meshes .iter() .flat_map(|m| m.mesh.triangles.iter().enumerate().filter(|&(t, _)| !invisible(m, t)).filter_map(|(_, tri)| corners(&m.mesh, tri)).flatten()) diff --git a/crates/prc-render/src/vec3.rs b/crates/prc-render/src/vec3.rs index bdbed70aed..da8fb87f3c 100644 --- a/crates/prc-render/src/vec3.rs +++ b/crates/prc-render/src/vec3.rs @@ -3,6 +3,11 @@ /// A point or direction, `[x, y, z]`. pub(crate) type Vec3 = [f64; 3]; +/// `a + b`. +pub(crate) fn add([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { + [ax + bx, ay + by, az + bz] +} + /// `a - b`. pub(crate) fn sub([ax, ay, az]: Vec3, [bx, by, bz]: Vec3) -> Vec3 { [ax - bx, ay - by, az - bz] diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 5fdec67fac..1cbe9a6ed3 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -23,6 +23,7 @@ pub mod icc; mod xmp; +mod xmp_types; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; @@ -63,18 +64,25 @@ impl Level { /// another language says it from [`pattern`](Issue::pattern) and [`value`](Issue::value) instead: /// the pattern is wording of ours, listed in [`WORDINGS`], and the value — when there is one — is /// the document's own name for something, a PDF keyword that reads the same in every language. +/// A wording that names more than one thing has a named hole, such as `{object}`, for each, so a +/// language can put them in its own order; [`values`](Issue::values) fills those. /// [`Issue::said`] does that. #[derive(Clone, Debug, PartialEq)] pub struct Issue { /// The ISO 19005-2 clause. pub clause: &'static str, pub message: String, - /// The problem as it is worded here, with `{}` where the document's own name goes. + /// The problem as it is worded here, with `{}` or a named hole where the document's own name goes. pub pattern: &'static str, /// What goes in the pattern's `{}`: an annotation subtype, an action, a font, a page number. pub value: Option, + /// What goes in each of the pattern's named holes, by the hole's name (`"{object}"`, braces + /// included). + pub values: Vec<(&'static str, String)>, /// 0-based page, when the problem is on a page. pub page: Option, + /// The indirect object the problem is in, when that is what locates it: a metadata stream. + pub object: Option, /// Whether [`convert`] can fix it. pub fixable: bool, } @@ -86,14 +94,43 @@ impl Issue { /// font called `{}` cannot turn into anything else. With [`in_english`] this is /// [`message`](Issue::message). pub fn said(&self, words: Words) -> String { - let said = words(self.pattern); - match &self.value { - Some(v) => said.replacen("{}", v, 1), - None => said, - } + fill(&words(self.pattern), self.value.as_deref(), &self.values) } } +/// `wording` with its holes filled: the first `{}` with `value`, and each named hole, such as +/// `{object}`, with its entry in `values`. A hole with nothing for it stays as it is written. +/// +/// The values go in during one pass over the wording, so a value with braces in it is never read +/// as a hole. +fn fill(wording: &str, value: Option<&str>, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len().saturating_add(16)); + let mut value = value; + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // A brace with nothing closing it is just text. `}` is one byte, so the end of the hole is a + // char boundary; `get` rather than slicing all the same. + let Some(hole) = from_brace.find('}').and_then(|close| from_brace.get(..=close)) else { + out.push_str(from_brace); + return out; + }; + if hole == "{}" + && let Some(v) = value.take() + { + out.push_str(v); + } else if let Some((_, v)) = values.iter().find(|(name, _)| *name == hole) { + out.push_str(v); + } else { + out.push_str(hole); + } + rest = from_brace.get(hole.len()..).unwrap_or_default(); + } + out.push_str(rest); + out +} + /// How an interface says one of our wordings in its own language: given the English in [`WORDINGS`], /// the same wording in that language. [`in_english`] keeps the English. pub type Words<'a> = &'a dyn Fn(&str) -> String; @@ -103,7 +140,8 @@ pub fn in_english(wording: &str) -> String { wording.to_owned() } -/// Every problem [`verify`] can report, in English, with `{}` where the document's own name goes. +/// Every problem [`verify`] can report, in English, with `{}` or a named hole, such as `{object}`, +/// where the document's own name goes. /// /// An interface that shows them in another language translates these, which is the whole reason they /// are gathered here; `wordings_are_complete` fails if a rule says something that is not on the @@ -113,6 +151,8 @@ pub const WORDINGS: &[&str] = &[ "The document has no XMP metadata", "The metadata doesn't identify the file as {}", "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 document as a whole. "The document is encrypted", "Device colour is used, but there is no PDF/A output intent", @@ -427,15 +467,24 @@ thread_local! { /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { - let mut out = Vec::new(); + // Shared by `issue` and `located_issue`, each of which takes the list out, adds one issue and + // puts it back. + let out = std::cell::Cell::new(Vec::new()); + let push = |i: Issue| { + let mut list = out.take(); + list.push(i); + out.set(list); + }; // Each problem is worded here and named by the document there: the wording goes on the list in // `WORDINGS` for an interface to translate, and the name goes in its `{}` as it stands. let mut issue = |clause: &'static str, pattern: &'static str, value: Option, page: Option, fixable: bool| { - let message = match &value { - Some(v) => pattern.replacen("{}", v, 1), - None => pattern.to_owned(), - }; - out.push(Issue { clause, message, pattern, value, page, fixable }); + let message = fill(pattern, value.as_deref(), &[]); + push(Issue { clause, message, pattern, value, values: Vec::new(), page, object: None, fixable }); + }; + // A problem in one indirect object, worded with named holes. + let located_issue = |clause: &'static str, pattern: &'static str, values: Vec<(&'static str, String)>, object: ObjRef, fixable: bool| { + let message = fill(pattern, None, &values); + push(Issue { clause, message, pattern, value: None, values, page: None, object: Some(object), fixable }); }; let cat = catalog(doc); // 6.1.3 / 6.6: identification and metadata. @@ -460,6 +509,32 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // 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| { + mistyped.extend(xmp_types::mistyped(packet).into_iter().map(|((name, written), ty)| (object, name, written, ty))); + }); + for (object, name, written, ty) in mistyped { + let mut values = vec![("{property}", name.clone()), ("{object}", object.to_string()), ("{type}", ty)]; + if written == name { + located_issue( + "6.6.2.3.1", + "The XMP property {property} in metadata stream {object} should have the value type {type}", + values, + object, + false, + ); + } else { + values.push(("{written}", written)); + located_issue( + "6.6.2.3.1", + "The XMP property {property} in metadata stream {object}, written {written}, should have the value type {type}", + values, + object, + false, + ); + } + } let used = uses_device_colour(doc, &objs); match pdfa_intent_profile(doc) { None if used.any() => { @@ -516,6 +591,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } 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 diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 52e6903e60..e691adae84 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -78,12 +78,14 @@ fn wordings_are_complete() { let source = include_str!("lib.rs"); let mut said = Vec::new(); let mut rest = source; - while let Some(at) = rest.find("issue(\"") { - rest = &rest[at + 7..]; + // `issue(` and `located_issue(`, written on one line or, when rustfmt breaks it, on several. + while let Some(at) = rest.find("issue(") { + rest = &rest[at + 6..]; + let Some(clause) = rest.trim_start().strip_prefix('"') else { continue }; // Step over the clause, then take the wording that follows it. - let Some(end) = rest.find('"') else { break }; - rest = &rest[end + 1..]; - let Some(text) = rest.strip_prefix(", \"") else { continue }; + let Some(end) = clause.find('"') else { break }; + rest = &clause[end + 1..]; + let Some(text) = rest.strip_prefix(',').map(str::trim_start).and_then(|r| r.strip_prefix('"')) else { continue }; let Some(end) = text.find('"') else { break }; said.push(&text[..end]); rest = text; @@ -488,3 +490,344 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); } } + +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/""# +); + +/// The mistyped predefined properties of a packet whose one description holds `body` (its +/// attributes, then `>` and its elements). +fn mistyped(body: &str) -> Vec { + let xmp = format!( + r#""# + ); + let parsed = xmp::parse(&xmp).expect("parses"); + xmp_types::mistyped(&parsed).into_keys().map(|(name, _)| name).collect() +} + +/// [`mistyped`] for the property `p` written as an element holding `v`. +fn element(p: &str, v: &str) -> Vec { + mistyped(&format!("><{p}>{v}")) +} + +/// PDF/A-2 6.6.2.3.1, simple values: their lexical forms (ISO 16684-1 8.2.1), as elements and attributes. +#[test] +fn xmp_simple_values_have_their_lexical_forms() { + // Simple types, as elements and as attributes. + assert!(element("crs:Contrast", "-25").is_empty()); + assert_eq!(element("crs:Contrast", "25.0"), ["crs:Contrast"]); + assert_eq!(element("crs:Contrast", "4/1"), ["crs:Contrast"]); + assert!(element("crs:Exposure", "+.5").is_empty() && element("crs:Exposure", "5").is_empty()); + assert_eq!(element("crs:Exposure", "5."), ["crs:Exposure"]); + assert_eq!(element("crs:Exposure", "1e3"), ["crs:Exposure"]); + assert!(element("pdf:Trapped", "True").is_empty()); + assert_eq!(element("pdf:Trapped", "true"), ["pdf:Trapped"]); + assert_eq!(mistyped(r#"crs:AutoContrast="Yes">"#), ["crs:AutoContrast"]); + assert!(mistyped(r#"crs:AutoContrast="False">"#).is_empty()); + for good in + ["2016", "2016-02", "2016-02-01", "2016-02-01T13:19Z", "2016-02-01T13:19:21+01:00", "2016-02-01T13:19:21.5-08:00", "2016-02-01T13:19:21"] + { + assert!(element("xmp:CreateDate", good).is_empty(), "{good}"); + } + for bad in [ + "16-02-01", + "2016-13-01", + "2016-02-32", + "2016-02T13:19", + "2016-02-01T25:00", + "2016-02-01T13:19:21+1:00", + "Feb 1 2016", + "2016-02-01T13:19Z01:00", + ] { + assert_eq!(element("xmp:CreateDate", bad), ["xmp:CreateDate"], "{bad}"); + } + assert!(element("xmpDM:videoPixelAspectRatio", "1001/30000").is_empty()); + assert_eq!(element("xmpDM:videoPixelAspectRatio", "1.5"), ["xmpDM:videoPixelAspectRatio"]); + // The whole text of a value counts, however a comment splits it. + assert_eq!(element("crs:Contrast", "5.0"), ["crs:Contrast"]); + assert!(element("pdf:Trapped", "True").is_empty()); + // rdf:resource makes a URI, which only a text type takes. + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // A GUID is a simple value but not a URI (ISO 16684-1 8.2.2.3). + assert!(element("xmpMM:DocumentID", "uuid:1234").is_empty()); + assert_eq!(mistyped(r#">"#), ["xmpMM:DocumentID"]); + assert_eq!( + mistyped(r#">"#), + ["xmpMM:DerivedFrom"] + ); +} + +/// PDF/A-2 6.6.2.3.1, arrays: the kind, every item, and the languages of a language alternative. +#[test] +fn xmp_arrays_have_their_kind_and_item_types() { + // Text can't be an array or a structure; a simple value can be given as rdf:resource. + assert_eq!(element("crs:RawFileName", "a"), ["crs:RawFileName"]); + assert_eq!(mistyped(r#">1"#), ["crs:RawFileName"]); + assert!(mistyped(r#">"#).is_empty()); + // Arrays: the kind and every item. + assert!(element("dc:creator", "AB").is_empty()); + assert_eq!(element("dc:creator", "A"), ["dc:creator"]); + assert_eq!(element("dc:creator", "A"), ["dc:creator"]); + assert_eq!(element("dc:date", "2016-02-01yesterday"), ["dc:date"]); + assert_eq!(element("dc:date", "yesterday"), ["dc:date"]); + // Only white space around an array's items. + assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]); + assert_eq!(element("dc:creator", "strayA"), ["dc:creator"]); + // rdf:parseType or rdf:resource on the property makes its value something other than the array. + assert_eq!(mistyped(r#">A"#), ["dc:creator"]); + assert_eq!(mistyped(r#">A"#), ["dc:creator"]); + assert!(element("dc:creator", "\n \n A\n \n").is_empty()); + // An rdf:resource element is empty. + assert_eq!(mistyped(r#">x"#), ["dc:source"]); + // Language alternatives: an rdf:Alt whose items all carry xml:lang. + assert!(element("dc:title", r#"T"#).is_empty()); + assert_eq!(element("dc:title", "T"), ["dc:title"]); + assert_eq!(element("dc:title", "T"), ["dc:title"]); +} + +/// PDF/A-2 6.6.2.3.1, structures in their three RDF/XML forms, nested, and qualified values. +#[test] +fn xmp_structures_have_their_field_types() { + // Structures in their three forms, with each known field checked. + assert!(mistyped(r#">d2016"#).is_empty()); + assert!(mistyped(r#">"#).is_empty()); + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(element("xmpMM:DerivedFrom", "uuid:1234"), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + assert_eq!( + mistyped(r#">letter"#), + ["xmpMM:DerivedFrom"] + ); + assert_eq!(mistyped(r#">letter"#), ["xmpMM:DerivedFrom"]); + // Predefined properties inside a Pantry item keep their types. + let pantry = |v: &str| { + mistyped(&format!( + r#">{v}"# + )) + }; + assert!(pantry("5").is_empty()); + assert_eq!(pantry("high"), ["xmpMM:Pantry"]); + assert_eq!( + mistyped(r#">soon"#), + ["xmpMM:DerivedFrom"] + ); + assert_eq!(mistyped(r#">"#), ["xmpMM:DerivedFrom"]); + assert_eq!(mistyped(r#">8.5 in"#), ["xmpTPg:MaxPageSize"]); + // Nested: an array of structures holding a structure. + let marker = |start: &str| format!(r#">{start}"#); + assert!( + mistyped(&marker( + r#"501/25"# + )) + .is_empty() + ); + assert!(mistyped(&marker("50f")).is_empty()); + assert_eq!(mistyped(&marker(r#"5.5"#)), ["xmpDM:markers"]); + // A qualified value is checked by its rdf:value. + assert!(mistyped(r#">5q"#).is_empty()); + assert_eq!(mistyped(r#">five"#), ["crs:Contrast"]); + // rdf:value as an attribute of the description. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // rdf:value as an attribute of an empty property element. + assert!(mistyped(r#">"#).is_empty()); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + // Qualifiers that are predefined properties keep their types. + assert_eq!( + mistyped(r#">5high"#), + ["crs:Contrast"] + ); + assert_eq!(mistyped(r#">"#), ["crs:Contrast"]); + assert!(mistyped(r#">"#).is_empty()); + // Nesting past any type's depth doesn't have the type. + let nested = |levels: usize| { + let open = r#""#.repeat(levels); + let close = "".repeat(levels); + mistyped(&format!(r#">{open}5{close}"#)) + }; + assert!(nested(3).is_empty()); + assert_eq!(nested(40), ["crs:Contrast"]); +} + +/// PDF/A-2 6.6.2.3.1 through verify: one issue per mistyped property, not fixable. +#[test] +fn xmp_value_types_are_reported_by_verify() { + let metadata = |body: &str| { + let xmp = format!(r#""#); + format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()) + }; + let issues = |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.6.2.3.1").collect() }; + // Every mistyped property, once per metadata stream, by stream and then by name; not fixable. + let catalog = metadata(r#"crs:Contrast="high" crs:Exposure="bright""#); + let page = metadata(r#"crs:Contrast="higher""#); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &catalog, + "<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>", + &page, + ], + "", + ); + let found = issues(&doc); + let messages: Vec<&str> = found.iter().map(|i| i.message.as_str()).collect(); + assert_eq!( + messages, + [ + "The XMP property crs:Contrast in metadata stream 3 0 R should have the value type Integer", + "The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real", + "The XMP property crs:Contrast in metadata stream 5 0 R should have the value type Integer" + ] + ); + let objects: Vec> = found.iter().map(|i| i.object).collect(); + assert_eq!(objects, [Some(ObjRef::new(3, 0)), Some(ObjRef::new(3, 0)), Some(ObjRef::new(5, 0))]); + assert!(found.iter().all(|i| !i.fixable && i.page.is_none())); + // Said in another language, the property and the stream go where that language puts them, and + // what the document wrote goes in as it stands. + let reordered = |w: &str| w.replace("The XMP property {property} in metadata stream {object}", "In {object}, {property}"); + assert_eq!(found[0].said(&in_english), found[0].message); + assert_eq!(found[0].said(&reordered), "In 3 0 R, crs:Contrast should have the value type Integer"); + // A UTF-16 packet, either byte order, is read. + for big in [true, false] { + let xmp = format!( + r#""# + ); + let mut data = if big { vec![0xFE, 0xFF] } else { vec![0xFF, 0xFE] }; + data.extend(xmp.encode_utf16().flat_map(|u| if big { u.to_be_bytes() } else { u.to_le_bytes() })); + let hex: String = data.iter().map(|b| format!("{b:02X}")).collect(); + let stream = format!("<< /Type /Metadata /Subtype /XML /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + assert_eq!(issues(&doc).len(), 1, "big-endian: {big}"); + } + // A metadata stream of a page counts as well as the catalog's. + let good = metadata(r#"crs:Contrast="5""#); + let doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &good, + "<< /Type /Page /Parent 2 0 R /Metadata 5 0 R >>", + &page, + ], + "", + ); + let found = issues(&doc); + assert_eq!(found.iter().map(|i| i.object).collect::>(), [Some(ObjRef::new(5, 0))]); +} + +/// 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] +fn wordings_fill_their_holes_once() { + let values = [("{a}", "{b}".to_owned()), ("{b}", "B".to_owned())]; + assert_eq!(fill("x {a} {b} {c} {} {}", Some("{a}"), &values), "x {b} B {c} {a} {}"); + assert_eq!(fill("{}", None, &[]), "{}"); + assert_eq!(fill("open { and é {a", Some("v"), &values), "open { and é {a"); + assert_eq!(fill("é{}ü", Some("v"), &[]), "évü"); +} + +/// PDF/A-2 6.6.2.3.1: a mistyped property is reported with the type the XMP Specification gives it, +/// as the specification writes it. +#[test] +fn xmp_mistyped_properties_name_their_type() { + let types = |body: &str| -> Vec<(String, String)> { + let xmp = format!( + r#""# + ); + xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_iter().map(|((name, _), ty)| (name, ty)).collect() + }; + let t = |name: &str, ty: &str| (name.to_owned(), ty.to_owned()); + assert_eq!( + types(r#"crs:Contrast="high" crs:Exposure="bright" xmp:CreateDate="never">"#), + [t("crs:Contrast", "Integer"), t("crs:Exposure", "Real"), t("xmp:CreateDate", "Date")] + ); + assert_eq!( + types(">CyanT"), + [t("dc:title", "Lang Alt"), t("xmpTPg:PlateNames", "seq Text")] + ); + assert_eq!( + types(">A4h"), + [t("xmpMM:History", "seq ResourceEvent"), t("xmpTPg:MaxPageSize", "Dimensions")] + ); + assert_eq!(types(">p"), [t("xmpMM:Pantry", "bag struct")]); +} + +/// PDF/A-2 6.6.2.3.1: a property is known by its namespace URI, whatever prefix the packet binds +/// to it, and is named by the namespace's preferred prefix and as the packet writes it. A property +/// a predefined namespace doesn't define, and a namespace that isn't predefined, are left to the +/// clauses about them. +#[test] +fn xmp_properties_are_known_by_namespace_uri_and_named_as_written() { + let crs = "http://ns.adobe.com/camera-raw-settings/1.0/"; + let found = |body: &str| -> Vec<(String, String)> { + let xmp = format!( + r#""# + ); + xmp_types::mistyped(&xmp::parse(&xmp).expect("parses")).into_keys().collect() + }; + let n = |name: &str, written: &str| (name.to_owned(), written.to_owned()); + // Another prefix for the namespace, as an attribute and as an element, and no prefix at all. + assert_eq!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="high">"#)), [n("crs:Contrast", "c:Contrast")]); + assert_eq!(found(&format!(r#">bright"#)), [n("crs:Exposure", "c:Exposure")]); + assert_eq!(found(&format!(r#">bright"#)), [n("crs:Exposure", "Exposure")]); + assert!(found(&format!(r#"xmlns:c="{crs}" c:Contrast="5">"#)).is_empty()); + // The preferred prefix is written as it is. + assert_eq!(found(r#"crs:Contrast="high">"#), [n("crs:Contrast", "crs:Contrast")]); + // A long prefix is repeated only so far. + let long = "p".repeat(100); + let said = found(&format!(r#"xmlns:{long}="{crs}" {long}:Contrast="high">"#)); + assert_eq!(said.len(), 1); + assert_eq!(said[0].1, format!("{}…:Contrast", "p".repeat(64))); + // The preferred prefix bound to another namespace is not that namespace. + assert!(found(r#">high"#).is_empty()); + assert!(found(r#"xmlns:dc="http://example.com/not-dc/" dc:title="T">"#).is_empty()); + // A property a predefined namespace doesn't define, with any value. + assert!(found(r#"crs:NotAProperty="high" dc:notAProperty="T">"#).is_empty()); + assert!(found(">x").is_empty()); + // A namespace that isn't predefined, with a predefined property's local name. + assert!(found(r#"xmlns:ex="http://example.com/ns/" ex:Contrast="high">"#).is_empty()); + assert!(found(r#">T"#).is_empty()); + + // Through verify, the written name is said when it differs. + let xmp = format!( + 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.1").map(|i| i.message).collect(); + assert_eq!( + messages, + [ + "The XMP property crs:Contrast in metadata stream 3 0 R, written c:Contrast, should have the value type Integer", + "The XMP property crs:Exposure in metadata stream 3 0 R should have the value type Real", + ] + ); +} + +/// PDF/A-2 6.6.2.3.1, the RDF/XML value forms XMP allows (ISO 16684-1 7.9.2.5): one `rdf:value` +/// in a qualified value, no parse type but `Resource`, and an `rdf:resource` element left empty. +#[test] +fn xmp_value_forms_are_those_xmp_allows() { + // A qualified value has exactly one rdf:value. + assert_eq!( + mistyped(r#">5five"#), + ["crs:Contrast"] + ); + assert_eq!( + mistyped(r#">5"#), + ["crs:Contrast"] + ); + assert!(mistyped(r#">5"#).is_empty()); + // Only parseType="Resource" has a place in XMP; an rdf:resource element holds no element. + assert_eq!(mistyped(r#">5"#), ["crs:Contrast"]); + assert_eq!(mistyped(r#">"#), ["xmpMM:DerivedFrom"]); + assert_eq!( + mistyped(r#">"#), + ["xmpMM:DerivedFrom"] + ); +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 7a54d70a1b..b2ea91c2f6 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -29,13 +29,18 @@ pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option Result, ()> { +/// `xmp` parsed within the depth, attribute and node limits; `None` when it can't be. +pub(crate) fn parse(xmp: &str) -> Option> { if !xml_limits_ok(xmp) { - return Err(()); + return None; } let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000, ..Default::default() }; - let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?; + roxmltree::Document::parse_with_options(xmp, options).ok() +} + +/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits. +fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> { + let doc = parse(xmp).ok_or(())?; for node in doc.descendants().filter(roxmltree::Node::is_element) { if let Some(value) = node.attribute((namespace, local)) { return Ok(Some(value.trim().to_owned())); diff --git a/crates/preflight/src/xmp_types.rs b/crates/preflight/src/xmp_types.rs new file mode 100644 index 0000000000..3563a671db --- /dev/null +++ b/crates/preflight/src/xmp_types.rs @@ -0,0 +1,780 @@ +//! PDF/A-2 6.6.2.3.1: a property of a predefined XMP schema has the value type the XMP +//! Specification gives it. +//! +//! 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 +//! (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 +//! document is read, not only the catalog's, within one decode allowance. +//! +//! 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. +//! 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. + +use std::collections::BTreeMap; + +use pdfcraft_cos::{DecodeBudget, ObjRef, Object}; +use roxmltree::Node; + +/// How deep arrays, structures and qualifiers are followed. No type nests nearly this deep, so a +/// value nested deeper doesn't have its type. +const MAX_DEPTH: usize = 32; +/// The decoded bytes all metadata streams of a document may take together. +const MAX_METADATA_BYTES: usize = 64 << 20; + +const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const XML: &str = "http://www.w3.org/XML/1998/namespace"; + +/// A value type. +#[derive(Debug, Clone, Copy)] +enum Ty { + /// Any simple value: Text and the types derived from it (AgentName, Choice of Text, Locale, + /// MIMEType, ProperName, RenditionClass, URI, URL, XPath, and the like). + Text, + /// A GUID: any simple value, but not a URI (ISO 16684-1 8.2.2.3), so not an `rdf:resource`. + Guid, + Integer, + Real, + Boolean, + Date, + Rational, + /// An alternative array of text items, each with an `xml:lang` (ISO 16684-1 8.2.2.4). + LangAlt, + Seq(&'static Ty), + Bag(&'static Ty), + Alt(&'static Ty), + /// A structure: the name the specification gives its type, and its fields. + Struct(&'static str, &'static [Field]), + /// A structure whose fields aren't given (`xmpMM:Pantry`). + AnyStruct, + /// Any of these: where editions of the specification differ. + OneOf(&'static [Ty]), +} + +/// A structure field: namespace, local name, type. +type Field = (&'static str, &'static str, Ty); + +/// A predefined namespace: URI, preferred prefix, properties. +struct Schema { + uri: &'static str, + prefix: &'static str, + properties: &'static [(&'static str, Ty)], +} + +use Ty::{Boolean, Date, Integer, LangAlt, Rational, Real, Text}; + +const ST_DIM: &str = "http://ns.adobe.com/xap/1.0/sType/Dimensions#"; +// Part 2 1.2.2.3 prints the Font field namespace as "http:ns.adobe.com/xap/1.0/sType/Font#"; the +// other sType namespaces show the intended form. +const ST_FNT: &str = "http://ns.adobe.com/xap/1.0/sType/Font#"; +const XMP_G: &str = "http://ns.adobe.com/xap/1.0/g/"; +const XMP_G_IMG: &str = "http://ns.adobe.com/xap/1.0/g/img/"; +const ST_EVT: &str = "http://ns.adobe.com/xap/1.0/sType/ResourceEvent#"; +const ST_REF: &str = "http://ns.adobe.com/xap/1.0/sType/ResourceRef#"; +const ST_VER: &str = "http://ns.adobe.com/xap/1.0/sType/Version#"; +const ST_JOB: &str = "http://ns.adobe.com/xap/1.0/sType/Job#"; +const XMP_DM: &str = "http://ns.adobe.com/xmp/1.0/DynamicMedia/"; +const PHOTOSHOP: &str = "http://ns.adobe.com/photoshop/1.0/"; + +/// Part 2 Table 3. +const COLORANT: &[Field] = &[ + (XMP_G, "A", Integer), + (XMP_G, "B", Integer), + (XMP_G, "L", Real), + (XMP_G, "black", Real), + (XMP_G, "cyan", Real), + (XMP_G, "magenta", Real), + (XMP_G, "yellow", Real), + (XMP_G, "blue", Integer), + (XMP_G, "green", Integer), + (XMP_G, "red", Integer), + (XMP_G, "mode", Text), + (XMP_G, "swatchName", Text), + (XMP_G, "type", Text), +]; +/// Part 2 Table 4. +const DIMENSIONS: &[Field] = &[(ST_DIM, "h", Real), (ST_DIM, "w", Real), (ST_DIM, "unit", Text)]; +/// Part 2 Table 5. +const FONT: &[Field] = &[ + (ST_FNT, "childFontFiles", Ty::Seq(&Text)), + (ST_FNT, "composite", Boolean), + (ST_FNT, "fontFace", Text), + (ST_FNT, "fontFamily", Text), + (ST_FNT, "fontFileName", Text), + (ST_FNT, "fontName", Text), + (ST_FNT, "fontType", Text), + (ST_FNT, "versionString", Text), +]; +/// Part 2 Table 6. +const THUMBNAIL: &[Field] = &[(XMP_G_IMG, "format", Text), (XMP_G_IMG, "height", Integer), (XMP_G_IMG, "width", Integer), (XMP_G_IMG, "image", Text)]; +/// Part 2 Table 8. +const RESOURCE_EVENT: &[Field] = &[ + (ST_EVT, "action", Text), + (ST_EVT, "changed", Text), + (ST_EVT, "instanceID", Ty::Guid), + (ST_EVT, "parameters", Text), + (ST_EVT, "softwareAgent", Text), + (ST_EVT, "when", Date), +]; +/// ISO 16684-1 Table 3 and Part 2 Table 9. +const RESOURCE_REF: &[Field] = &[ + (ST_REF, "alternatePaths", Ty::Seq(&Text)), + (ST_REF, "documentID", Ty::Guid), + (ST_REF, "filePath", Text), + (ST_REF, "fromPart", Text), + (ST_REF, "instanceID", Ty::Guid), + (ST_REF, "lastModifyDate", Date), + (ST_REF, "manager", Text), + (ST_REF, "managerVariant", Text), + (ST_REF, "manageTo", Text), + (ST_REF, "manageUI", Text), + (ST_REF, "maskMarkers", Text), + (ST_REF, "partMapping", Text), + (ST_REF, "renditionClass", Text), + (ST_REF, "renditionParams", Text), + (ST_REF, "toPart", Text), + (ST_REF, "versionID", Text), +]; +/// Part 2 Table 10. +const VERSION: &[Field] = &[ + (ST_VER, "comments", Text), + (ST_VER, "event", Ty::Struct("ResourceEvent", RESOURCE_EVENT)), + (ST_VER, "modifier", Text), + (ST_VER, "modifyDate", Date), + (ST_VER, "version", Text), +]; +/// Part 2 Table 11. +const JOB: &[Field] = &[(ST_JOB, "id", Text), (ST_JOB, "name", Text), (ST_JOB, "url", Text)]; +/// Part 2 Table 12. +const BEAT_SPLICE_STRETCH: &[Field] = + &[(XMP_DM, "riseInDecibel", Real), (XMP_DM, "riseInTimeDuration", Ty::Struct("Time", TIME)), (XMP_DM, "useFileBeatsMarker", Boolean)]; +/// Part 2 Table 13. +const CUE_POINT_PARAM: &[Field] = &[(XMP_DM, "key", Text), (XMP_DM, "value", Text)]; +/// A marker's start and duration: FrameCount (text) in Part 2 Table 16, a Time structure in the +/// September 2005 edition that PDF/A-2 cites. Either is taken. +const FRAME_COUNT_OR_TIME: Ty = Ty::OneOf(&[Text, Ty::Struct("Time", TIME)]); +/// Part 2 Table 16. +const MARKER: &[Field] = &[ + (XMP_DM, "comment", Text), + (XMP_DM, "cuePointParams", Ty::Seq(&Ty::Struct("CuePointParam", CUE_POINT_PARAM))), + (XMP_DM, "cuePointType", Text), + (XMP_DM, "duration", FRAME_COUNT_OR_TIME), + (XMP_DM, "location", Text), + (XMP_DM, "name", Text), + (XMP_DM, "probability", Real), + (XMP_DM, "speaker", Text), + (XMP_DM, "startTime", FRAME_COUNT_OR_TIME), + (XMP_DM, "target", Text), + (XMP_DM, "type", Text), +]; +/// Part 2 Table 17. +const MEDIA: &[Field] = &[ + (XMP_DM, "duration", Ty::Struct("Time", TIME)), + (XMP_DM, "managed", Boolean), + (XMP_DM, "path", Text), + (XMP_DM, "startTime", Ty::Struct("Time", TIME)), + (XMP_DM, "track", Text), + (XMP_DM, "webStatement", Text), +]; +/// Part 2 Table 18. +const PROJECT_LINK: &[Field] = &[(XMP_DM, "path", Text), (XMP_DM, "type", Text)]; +/// Part 2 Table 19. +const RESAMPLE_STRETCH: &[Field] = &[(XMP_DM, "quality", Text)]; +/// Part 2 Table 20. +const TIME: &[Field] = &[(XMP_DM, "scale", Rational), (XMP_DM, "value", Integer)]; +/// Part 2 Table 21. +const TIMECODE: &[Field] = &[(XMP_DM, "timeFormat", Text), (XMP_DM, "timeValue", Text)]; +/// Part 2 Table 22. +const TIME_SCALE_STRETCH: &[Field] = &[(XMP_DM, "frameOverlappingPercentage", Real), (XMP_DM, "frameSize", Real), (XMP_DM, "quality", Text)]; +/// Part 2 Table 23. +const TRACK: &[Field] = &[ + (XMP_DM, "frameRate", Text), + (XMP_DM, "markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), + (XMP_DM, "trackName", Text), + (XMP_DM, "trackType", Text), +]; +/// Part 2 Table 31. +const ANCESTOR: &[Field] = &[(PHOTOSHOP, "AncestorID", Text)]; +/// Part 2 Table 32. +const LAYER: &[Field] = &[(PHOTOSHOP, "LayerName", Text), (PHOTOSHOP, "LayerText", Text)]; + +const SCHEMAS: &[Schema] = &[ + // ISO 16684-1 Table 4. + Schema { + uri: "http://purl.org/dc/elements/1.1/", + prefix: "dc", + properties: &[ + ("contributor", Ty::Bag(&Text)), + ("coverage", Text), + ("creator", Ty::Seq(&Text)), + ("date", Ty::Seq(&Date)), + ("description", LangAlt), + ("format", Text), + ("identifier", Text), + ("language", Ty::Bag(&Text)), + ("publisher", Ty::Bag(&Text)), + ("relation", Ty::Bag(&Text)), + ("rights", LangAlt), + ("source", Text), + ("subject", Ty::Bag(&Text)), + ("title", LangAlt), + ("type", Ty::Bag(&Text)), + ], + }, + // ISO 16684-1 Table 5, with Part 2 Table 24. + Schema { + uri: "http://ns.adobe.com/xap/1.0/", + prefix: "xmp", + properties: &[ + ("Advisory", Ty::Bag(&Text)), + ("BaseURL", Text), + ("CreateDate", Date), + ("CreatorTool", Text), + ("Identifier", Ty::Bag(&Text)), + ("Label", Text), + ("MetadataDate", Date), + ("ModifyDate", Date), + ("Nickname", Text), + ("Rating", Real), + ("Thumbnails", Ty::Alt(&Ty::Struct("Thumbnail", THUMBNAIL))), + ], + }, + // ISO 16684-1 Table 6. + Schema { + uri: "http://ns.adobe.com/xap/1.0/rights/", + prefix: "xmpRights", + properties: &[("Certificate", Text), ("Marked", Boolean), ("Owner", Ty::Bag(&Text)), ("UsageTerms", LangAlt), ("WebStatement", Text)], + }, + // ISO 16684-1 Table 7, with Part 2 Tables 25 and 26. + Schema { + uri: "http://ns.adobe.com/xap/1.0/mm/", + prefix: "xmpMM", + properties: &[ + ("DerivedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), + ("DocumentID", Ty::Guid), + ("History", Ty::Seq(&Ty::Struct("ResourceEvent", RESOURCE_EVENT))), + ("Ingredients", Ty::Bag(&Ty::Struct("ResourceRef", RESOURCE_REF))), + ("InstanceID", Ty::Guid), + ("ManagedFrom", Ty::Struct("ResourceRef", RESOURCE_REF)), + ("Manager", Text), + ("ManageTo", Text), + ("ManageUI", Text), + ("ManagerVariant", Text), + ("OriginalDocumentID", Ty::Guid), + ("Pantry", Ty::Bag(&Ty::AnyStruct)), + ("RenditionClass", Text), + ("RenditionParams", Text), + ("VersionID", Text), + ("Versions", Ty::Seq(&Ty::Struct("Version", VERSION))), + ("LastURL", Text), + ("RenditionOf", Ty::Struct("ResourceRef", RESOURCE_REF)), + ("SaveID", Integer), + ], + }, + // Part 2 Table 27. + Schema { uri: "http://ns.adobe.com/xap/1.0/bj/", prefix: "xmpBJ", properties: &[("JobRef", Ty::Bag(&Ty::Struct("Job", JOB)))] }, + // Part 2 Table 28. + Schema { + uri: "http://ns.adobe.com/xap/1.0/t/pg/", + prefix: "xmpTPg", + properties: &[ + ("Colorants", Ty::Seq(&Ty::Struct("Colorant", COLORANT))), + ("Fonts", Ty::Bag(&Ty::Struct("Font", FONT))), + ("MaxPageSize", Ty::Struct("Dimensions", DIMENSIONS)), + ("NPages", Integer), + ("PlateNames", Ty::Seq(&Text)), + ], + }, + // Part 2 Table 29. + Schema { + uri: XMP_DM, + prefix: "xmpDM", + properties: &[ + ("absPeakAudioFilePath", Text), + ("album", Text), + ("altTapeName", Text), + ("altTimecode", Ty::Struct("Timecode", TIMECODE)), + ("artist", Text), + ("audioModDate", Date), + ("audioChannelType", Text), + ("audioCompressor", Text), + ("audioSampleRate", Integer), + ("audioSampleType", Text), + ("beatSpliceParams", Ty::Struct("BeatSpliceStretch", BEAT_SPLICE_STRETCH)), + ("cameraAngle", Text), + ("cameraLabel", Text), + ("cameraModel", Text), + ("cameraMove", Text), + ("client", Text), + ("comment", Text), + ("composer", Text), + ("contributedMedia", Ty::Bag(&Ty::Struct("Media", MEDIA))), + ("copyright", Text), + ("director", Text), + ("directorPhotography", Text), + ("duration", Ty::Struct("Time", TIME)), + ("engineer", Text), + ("fileDataRate", Rational), + ("genre", Text), + ("good", Boolean), + ("instrument", Text), + ("introTime", Ty::Struct("Time", TIME)), + ("key", Text), + ("logComment", Text), + ("loop", Boolean), + ("numberOfBeats", Real), + ("markers", Ty::Seq(&Ty::Struct("Marker", MARKER))), + ("metadataModDate", Date), + ("outCue", Ty::Struct("Time", TIME)), + ("projectName", Text), + ("projectRef", Ty::Struct("ProjectLink", PROJECT_LINK)), + ("pullDown", Text), + ("relativePeakAudioFilePath", Text), + ("relativeTimestamp", Ty::Struct("Time", TIME)), + ("releaseDate", Date), + ("resampleParams", Ty::Struct("ResampleStretch", RESAMPLE_STRETCH)), + ("scaleType", Text), + ("scene", Text), + ("shotDate", Date), + ("shotDay", Text), + ("shotLocation", Text), + ("shotName", Text), + ("shotNumber", Text), + ("shotSize", Text), + ("speakerPlacement", Text), + ("startTimecode", Ty::Struct("Timecode", TIMECODE)), + ("stretchMode", Text), + ("takeNumber", Integer), + ("tapeName", Text), + ("tempo", Real), + ("timeScaleParams", Ty::Struct("TimeScaleStretch", TIME_SCALE_STRETCH)), + ("timeSignature", Text), + ("trackNumber", Integer), + ("Tracks", Ty::Bag(&Ty::Struct("Track", TRACK))), + ("videoAlphaMode", Text), + ("videoAlphaPremultipleColor", Ty::Struct("Colorant", COLORANT)), + ("videoAlphaUnityIsTransparent", Boolean), + ("videoColorSpace", Text), + ("videoCompressor", Text), + ("videoFieldOrder", Text), + ("videoFrameRate", Text), + ("videoFrameSize", Ty::Struct("Dimensions", DIMENSIONS)), + ("videoModDate", Date), + ("videoPixelDepth", Text), + ("videoPixelAspectRatio", Rational), + ("partOfCompilation", Boolean), + ("lyrics", Text), + ("discNumber", Text), + ], + }, + // Part 2 Table 30. + Schema { + uri: "http://ns.adobe.com/pdf/1.3/", + prefix: "pdf", + properties: &[("Keywords", Text), ("PDFVersion", Text), ("Producer", Text), ("Trapped", Boolean)], + }, + // Part 2 Table 33. + Schema { + uri: PHOTOSHOP, + prefix: "photoshop", + properties: &[ + ("AuthorsPosition", Text), + ("CaptionWriter", Text), + ("Category", Text), + ("City", Text), + ("ColorMode", Integer), + ("Country", Text), + ("Credit", Text), + ("DateCreated", Date), + ("DocumentAncestors", Ty::Bag(&Ty::Struct("Ancestor", ANCESTOR))), + ("Headline", Text), + ("History", Text), + ("ICCProfile", Text), + ("Instructions", Text), + ("Source", Text), + ("State", Text), + ("SupplementalCategories", Ty::Bag(&Text)), + ("TextLayers", Ty::Seq(&Ty::Struct("Layer", LAYER))), + ("TransmissionReference", Text), + ("Urgency", Integer), + ], + }, + // Part 2 Table 34. + Schema { + uri: "http://ns.adobe.com/camera-raw-settings/1.0/", + prefix: "crs", + properties: &[ + ("AutoBrightness", Boolean), + ("AutoContrast", Boolean), + ("AutoExposure", Boolean), + ("AutoShadows", Boolean), + ("BlueHue", Integer), + ("BlueSaturation", Integer), + ("Brightness", Integer), + ("CameraProfile", Text), + ("ChromaticAberrationB", Integer), + ("ChromaticAberrationR", Integer), + ("ColorNoiseReduction", Integer), + ("Contrast", Integer), + ("CropTop", Real), + ("CropLeft", Real), + ("CropBottom", Real), + ("CropRight", Real), + ("CropAngle", Real), + ("CropWidth", Real), + ("CropHeight", Real), + ("CropUnits", Integer), + ("Exposure", Real), + ("GreenHue", Integer), + ("GreenSaturation", Integer), + ("HasCrop", Boolean), + ("HasSettings", Boolean), + ("LuminanceSmoothing", Integer), + ("RawFileName", Text), + ("RedHue", Integer), + ("RedSaturation", Integer), + ("Saturation", Integer), + ("Shadows", Integer), + ("ShadowTint", Integer), + ("Sharpness", Integer), + ("Temperature", Integer), + ("Tint", Integer), + ("ToneCurve", Ty::Seq(&Text)), + ("ToneCurveName", Text), + ("Version", Text), + ("VignetteAmount", Integer), + ("VignetteMidpoint", Integer), + ("WhiteBalance", Text), + ], + }, +]; + +/// Calls `f` with every metadata stream among the indirect objects `objs` (ISO 32000-1 14.3.2) that +/// parses as XML, and the stream's object, each decoded once within one allowance. +pub(crate) fn for_each_packet(objs: &[(ObjRef, std::sync::Arc)], mut f: impl FnMut(ObjRef, &roxmltree::Document<'_>)) { + let mut budget = DecodeBudget::new(MAX_METADATA_BYTES, 0); + for (r, o) in objs { + let Object::Stream(s) = &**o else { continue }; + if s.dict.name(b"Type") != Some(b"Metadata") { + continue; + } + if budget.remaining() == 0 { + break; + } + let Ok(bytes) = budget.decode_within(s, crate::xmp::MAX_XMP_BYTES) else { continue }; + let text = unicode(&bytes); + if let Some(parsed) = crate::xmp::parse(&text) { + f(*r, &parsed); + } + } +} + +/// A packet's text in the Unicode encoding its byte order mark gives: UTF-16 big- or +/// little-endian, or UTF-8 (ISO 16684-1 7.3). +fn unicode(bytes: &[u8]) -> String { + let utf16 = |data: &[u8], big: bool| -> String { + let (units, _) = data.as_chunks::<2>(); + let units = units.iter().map(|&[a, b]| if big { u16::from_be_bytes([a, b]) } else { u16::from_le_bytes([a, b]) }); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + match bytes { + [0xFE, 0xFF, rest @ ..] => utf16(rest, true), + [0xFF, 0xFE, rest @ ..] => utf16(rest, false), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + _ => String::from_utf8_lossy(bytes).into_owned(), + } +} + +/// The properties of predefined schemas in the packet `doc` whose values don't have their type, in +/// name order: each by its name with the namespace's preferred prefix and by its name as the packet +/// writes it (see [`written`]), with the type it should have as [`named`] gives it. +pub(crate) fn mistyped(doc: &roxmltree::Document<'_>) -> BTreeMap<(String, String), String> { + let mut out = BTreeMap::new(); + let text = doc.input_text(); + let descriptions = doc.descendants().filter(|n| is(n, RDF, "Description") && n.parent_element().is_some_and(|p| is(&p, RDF, "RDF"))); + for d in descriptions { + for a in d.attributes() { + if let Some((schema, ty)) = a.namespace().and_then(|ns| property(ns, a.name())) + && !simple(ty, a.value()) + { + let qname = text.get(a.range_qname()).unwrap_or_default(); + out.insert((format!("{}:{}", schema.prefix, a.name()), written(qname, a.name())), named(ty)); + } + } + for p in d.children().filter(Node::is_element) { + let name = p.tag_name(); + if let Some((schema, ty)) = name.namespace().and_then(|ns| property(ns, name.name())) + && !fits(ty, &p, 0) + { + let qname = text.get(p.range()).unwrap_or_default().strip_prefix('<').unwrap_or_default(); + out.insert((format!("{}:{}", schema.prefix, name.name()), written(qname, name.name())), named(ty)); + } + } + } + out +} + +/// How long a prefix the packet writes is repeated. +const MAX_PREFIX_CHARS: usize = 64; + +/// The property `local` as the packet writes it, from the text its qualified name begins +/// (`qname`): `prefix:local`, or `local` alone in the default namespace. A prefix past +/// [`MAX_PREFIX_CHARS`] is cut, with `…`. +fn written(qname: &str, local: &str) -> String { + let qname = qname.split(|c: char| c.is_whitespace() || matches!(c, '>' | '/' | '=')).next().unwrap_or_default(); + let Some((prefix, _)) = qname.split_once(':') else { return local.to_owned() }; + let mut out: String = prefix.chars().take(MAX_PREFIX_CHARS).collect(); + if out.len() < prefix.len() { + out.push('…'); + } + 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`. +fn named(ty: Ty) -> String { + let array = |kind: &str, item: &Ty| format!("{kind} {}", named(*item)); + match ty { + Text => "Text".into(), + Ty::Guid => "GUID".into(), + Integer => "Integer".into(), + Real => "Real".into(), + Boolean => "Boolean".into(), + Date => "Date".into(), + Rational => "Rational".into(), + LangAlt => "Lang Alt".into(), + Ty::Seq(item) => array("seq", item), + Ty::Bag(item) => array("bag", item), + Ty::Alt(item) => array("alt", item), + Ty::Struct(name, _) => name.into(), + Ty::AnyStruct => "struct".into(), + Ty::OneOf(types) => types.iter().map(|t| named(*t)).collect::>().join(" or "), + } +} + +fn property(ns: &str, name: &str) -> Option<(&'static Schema, Ty)> { + let schema = SCHEMAS.iter().find(|s| s.uri == ns)?; + schema.properties.iter().find(|(n, _)| *n == name).map(|(_, t)| (schema, *t)) +} + +fn is(n: &Node<'_, '_>, ns: &str, name: &str) -> bool { + n.is_element() && n.tag_name().namespace() == Some(ns) && n.tag_name().name() == name +} + +/// The attributes of `n` that are fields or qualifiers, not RDF or XML syntax. +fn field_attributes<'a, 'i>(n: &Node<'a, 'i>) -> impl Iterator> { + n.attributes().filter(|a| !matches!(a.namespace(), Some(RDF) | Some(XML))) +} + +/// Whether the value of the property or array item element `e` has type `ty`. +fn fits(ty: Ty, e: &Node<'_, '_>, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + let deeper = depth + 1; + let children: Vec> = e.children().filter(Node::is_element).collect(); + // A qualified value: `rdf:value` holds the value itself (ISO 16684-1 7.9.2.5). + let resource = e.attribute((RDF, "parseType")) == Some("Resource"); + // `parseType="Resource"` is the only parse type XMP has (ISO 16684-1 7.9.2.5); an + // `rdf:resource` element is empty. + if e.attribute((RDF, "parseType")).is_some_and(|p| p != "Resource") || e.attribute((RDF, "resource")).is_some() && !children.is_empty() { + return false; + } + let description = match children.as_slice() { + [d] if is(d, RDF, "Description") => Some(*d), + _ => None, + }; + // `rdf:value` as an element of a `parseType="Resource"` node or `rdf:Description`, or as an + // attribute of the description or of an empty property element. Its qualifiers that are + // predefined properties keep their types too. + let node = if resource { Some(*e) } else { description }; + if let Some(n) = node { + // A qualified value has one `rdf:value`: an element, or an attribute in its place. + match n.children().filter(|c| is(c, RDF, "value")).collect::>().as_slice() { + [] => {} + [v] => return n.attribute((RDF, "value")).is_none() && blank(e) && blank(&n) && fits(ty, v, deeper) && predefined_ok(&n, deeper), + _ => return false, + } + } + let attribute_node = description.or_else(|| (children.is_empty() && !resource).then_some(*e)); + if let Some(n) = attribute_node + && let Some(v) = n.attribute((RDF, "value")) + { + 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 => { + // `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() { + return matches!(ty, Ty::Text) && children.is_empty() && blank(e); + } + children.is_empty() && !resource && field_attributes(e).next().is_none() && simple(ty, &text(e)) + } + Ty::LangAlt => match array(e, &children, "Alt") { + Some(items) => items.iter().all(|i| i.attribute((XML, "lang")).is_some() && fits(Ty::Text, i, deeper)), + None => false, + }, + Ty::Seq(item) | Ty::Bag(item) | Ty::Alt(item) => { + let kind = match ty { + Ty::Seq(_) => "Seq", + Ty::Bag(_) => "Bag", + _ => "Alt", + }; + array(e, &children, kind).is_some_and(|items| items.iter().all(|i| fits(*item, i, deeper))) + } + Ty::Struct(_, fields) => { + let Some(holder) = structure(e, &children, resource, description) else { return false }; + let attrs_ok = field_attributes(&holder).all(|a| match a.namespace().and_then(|ns| field(fields, ns, a.name())) { + Some(t) => simple(t, a.value()), + None => true, + }); + let elements_ok = holder.children().filter(Node::is_element).all(|f| { + let n = f.tag_name(); + match n.namespace().and_then(|ns| field(fields, ns, n.name())) { + Some(t) => fits(t, &f, deeper), + None => true, + } + }); + attrs_ok && elements_ok + } + // A structure of any fields, such as a Pantry item: predefined properties in it still have + // their types. + Ty::AnyStruct => structure(e, &children, resource, description).is_some_and(|holder| predefined_ok(&holder, deeper)), + Ty::OneOf(types) => types.iter().any(|t| fits(*t, e, depth)), + } +} + +/// Whether the fields of `holder`, as attributes and elements, that are predefined properties have +/// their types. +fn predefined_ok(holder: &Node<'_, '_>, depth: usize) -> bool { + let attrs_ok = field_attributes(holder).all(|a| a.namespace().and_then(|ns| property(ns, a.name())).is_none_or(|(_, t)| simple(t, a.value()))); + let elements_ok = holder.children().filter(Node::is_element).all(|f| { + let n = f.tag_name(); + n.namespace().and_then(|ns| property(ns, n.name())).is_none_or(|(_, t)| fits(t, &f, depth)) + }); + attrs_ok && elements_ok +} + +fn field(fields: &[Field], ns: &str, name: &str) -> Option { + fields.iter().find(|(n, l, _)| *n == ns && *l == name).map(|(_, _, t)| *t) +} + +/// The items of `e`'s value when it is an `rdf:` array. +fn array<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], kind: &str) -> Option>> { + match children { + // Items are `rdf:li` (ISO 16684-1 7.9.2.4); any other element, or character data around + // them, breaks the array, and so does `rdf:parseType` or `rdf:resource` on the property, + // which makes its value something else. + [a] if is(a, RDF, kind) && field_attributes(e).next().is_none() && blank(e) && blank(a) && !other_value_form(e) => { + let items: Vec> = a.children().filter(Node::is_element).collect(); + items.iter().all(|i| is(i, RDF, "li")).then_some(items) + } + _ => None, + } +} + +/// The node holding the fields of `e`'s value when it is a structure. +fn structure<'a, 'i>(e: &Node<'a, 'i>, children: &[Node<'a, 'i>], resource: bool, description: Option>) -> Option> { + if resource { + // A structure's fields are elements; character data has no place in it. + return text(e).trim().is_empty().then_some(*e); + } + if let Some(d) = description { + return (blank(e) && blank(&d)).then_some(d); + } + // Fields written as attributes of the property element, with no content. + let empty = text(e).trim().is_empty(); + (children.is_empty() && empty && e.attribute((RDF, "resource")).is_none() && field_attributes(e).next().is_some()).then_some(*e) +} + +/// Whether `e` says its value is a resource or a literal of another kind, not the element it holds. +fn other_value_form(e: &Node<'_, '_>) -> bool { + e.attribute((RDF, "parseType")).is_some() || e.attribute((RDF, "resource")).is_some() +} + +/// Whether `e` holds no character data but white space. +fn blank(e: &Node<'_, '_>) -> bool { + text(e).trim().is_empty() +} + +/// All the character data directly in `e`, however comments or other nodes split it. +fn text(e: &Node<'_, '_>) -> String { + e.children().filter(Node::is_text).filter_map(|t| t.text()).collect() +} + +/// Whether the simple value `v` has the simple type `ty`. +fn simple(ty: Ty, v: &str) -> bool { + match ty { + Ty::Integer => integer(v), + Ty::Real => real(v), + 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::Text | Ty::Guid => true, + Ty::OneOf(types) => types.iter().any(|t| simple(*t, v)), + _ => false, + } +} + +fn digits(s: &str) -> bool { + !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) +} + +/// ISO 16684-1 8.2.1.3. +fn integer(v: &str) -> bool { + digits(v.strip_prefix(['+', '-']).unwrap_or(v)) +} + +/// ISO 16684-1 8.2.1.4: an integer part, a fraction part, or both. +fn real(v: &str) -> bool { + let v = v.strip_prefix(['+', '-']).unwrap_or(v); + match v.split_once('.') { + Some((i, f)) => (i.is_empty() || digits(i)) && digits(f), + None => digits(v), + } +} + +/// ISO 16684-1 8.2.1.2: YYYY, YYYY-MM, YYYY-MM-DD, or a date with hh:mm, hh:mm:ss or hh:mm:ss.s +/// and an optional time zone (Z, +hh:mm, -hh:mm). +fn date(v: &str) -> bool { + let num = |s: &str, len: usize, max: u32| s.len() == len && digits(s) && s.parse::().is_ok_and(|n| n <= max); + let in_range = |s: &str, min: u32, max: u32| num(s, 2, max) && s.parse::().is_ok_and(|n| n >= min); + let (day, time) = match v.split_once('T') { + Some((d, t)) => (d, Some(t)), + None => (v, None), + }; + let parts: Vec<&str> = day.split('-').collect(); + let day_ok = match parts.as_slice() { + [y] => num(y, 4, 9999), + [y, m] => num(y, 4, 9999) && in_range(m, 1, 12), + [y, m, d] => num(y, 4, 9999) && in_range(m, 1, 12) && in_range(d, 1, 31), + _ => false, + }; + let Some(time) = time else { return day_ok }; + if parts.len() != 3 || !day_ok { + return false; + } + let (clock, zone) = match time.find(['Z', '+', '-']) { + Some(i) => (time.get(..i).unwrap_or_default(), time.get(i..).unwrap_or_default()), + None => (time, ""), + }; + let zone_ok = match zone { + "" | "Z" => true, + z => z.strip_prefix(['+', '-']).and_then(|z| z.split_once(':')).is_some_and(|(h, m)| num(h, 2, 23) && num(m, 2, 59)), + }; + let clock_ok = match clock.split(':').collect::>().as_slice() { + [h, m] => num(h, 2, 23) && num(m, 2, 59), + [h, m, s] => { + let (whole, fraction) = s.split_once('.').map_or((*s, None), |(w, f)| (w, Some(f))); + num(h, 2, 23) && num(m, 2, 59) && num(whole, 2, 59) && fraction.is_none_or(digits) + } + _ => false, + }; + zone_ok && clock_ok +} diff --git a/crates/print/README.md b/crates/print/README.md index 5db83312ca..e846243db5 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -13,7 +13,10 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() - **Size**: fit (to the sheet minus an 18 pt margin), actual size, shrink oversized, custom %; centred; auto orientation turns the sheet for landscape pages. - **Multiple**: 2/4/6/9/16 (or any n) pages per sheet, horizontal/vertical (reversed) order, - page borders, auto-rotation of pages that don't match the cell. + page borders, auto-rotation of pages that don't match the cell. With auto orientation a + predefined count turns the sheet to suit its grid (2 per sheet prints side by side on + landscape). A custom grid (`Layout::grid`, `doc_print` `cols`/`rows`, 1-99 each) is laid + out across and down the sheet as given and doesn't turn it: auto prints portrait. - **Cut and stack** (Multiple's page order): consecutive pages in each cell's pile, with aligned cut marks in the gutters. For example, 10 pages at 4 per sheet give `[1, 4, 7, 10]`, `[2, 5, 8, blank]`, `[3, 6, 9, blank]`. Print single-sided, diff --git a/crates/print/src/error.rs b/crates/print/src/error.rs index 4b1fe95f21..6f87c21c29 100644 --- a/crates/print/src/error.rs +++ b/crates/print/src/error.rs @@ -14,6 +14,9 @@ pub enum PrintError { /// A range token that is neither a page number nor a page label. #[error("{0:?} is not a page number or label")] NotAPage(String), + /// A range that names more pages than a listing will take, with the limit. + #[error("the range selects more than {0} pages")] + TooManySelected(usize), #[error("{0}")] Invalid(String), #[error("{0}")] @@ -33,9 +36,14 @@ pub fn in_english(wording: &str) -> String { /// What a person can get wrong about what to print, in English, with their holes still in them. /// -/// An interface that reports these in another language translates these three, which is the whole +/// An interface that reports these in another language translates these four, which is the whole /// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; +pub const WORDINGS: &[&str] = &[ + "there are no pages to print", + "page {n} is out of range (1–{count})", + "{page} is not a page number or label", + "the range selects more than {limit} pages", +]; impl PrintError { /// This error as one sentence, said in the language `words` translates into. @@ -51,6 +59,7 @@ impl PrintError { } // The token goes in quoted, the way it is typed, as the error itself prints it. PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + PrintError::TooManySelected(limit) => say(&words("the range selects more than {limit} pages"), &[("{limit}", limit.to_string())]), fault => fault.to_string(), } } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 067112b2e7..8fffbdf649 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -37,7 +37,7 @@ pub use error::{PrintError, WORDINGS, Words, in_english}; use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ - Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, cups_media, media_option, }; #[cfg(test)] diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index f0d8939da6..027158df01 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -1,7 +1,41 @@ //! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed), odd or even pages, reverse. +use std::collections::HashMap; + use crate::PrintError; +/// Most pages one range may select (more when the document itself has more): every selection +/// becomes a placement on a sheet, and repeating `1-` costs a few bytes of input for each copy of +/// the whole document. +pub const MAX_SELECTED: usize = 100_000; + +/// The first page with each label, looked up once per token instead of scanning every label. +struct Labels<'a> { + pages: HashMap<&'a str, usize>, + /// The longest label, in bytes: a token longer than that is no label, and is not hashed (a + /// dash-heavy token would otherwise hash every split of itself). + longest: usize, +} + +impl<'a> Labels<'a> { + fn new(labels: &'a [String]) -> Self { + let mut pages = HashMap::with_capacity(labels.len()); + for (i, l) in labels.iter().enumerate() { + pages.entry(l.as_str()).or_insert(i); + } + let longest = pages.keys().map(|l| l.len()).max().unwrap_or(0); + Labels { pages, longest } + } + + fn get(&self, token: &str) -> Option { + if token.len() > self.longest { None } else { self.pages.get(token).copied() } + } + + fn contains_key(&self, token: &str) -> bool { + self.get(token).is_some() + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Subset { #[default] @@ -11,12 +45,16 @@ pub enum Subset { } /// Resolve one range token: a page label, or a number (1-based). -fn page_of(tok: &str, count: usize, labels: &[String]) -> Result { +fn page_of(tok: &str, count: usize, labels: &Labels) -> Result { let t = tok.trim(); // Page labels win over numbers (Acrobat's "use logical page numbers"). - if let Some(i) = labels.iter().position(|l| l == t) { + if let Some(i) = labels.get(t) { return Ok(i); } + // `usize::from_str` accepts a leading '+', which is not part of a page number. + if !t.bytes().all(|b| b.is_ascii_digit()) { + return Err(PrintError::NotAPage(t.to_string())); + } match t.parse::() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), Ok(n) => Err(PrintError::OutOfRange(n, count)), @@ -27,12 +65,12 @@ fn page_of(tok: &str, count: usize, labels: &[String]) -> Result Option<(usize, usize)> { +fn hyphenated_label_range(part: &str, count: usize, labels: &Labels) -> Option<(usize, usize)> { for (split, _) in part.match_indices('-') { let (Some(first), Some(last)) = (part.get(..split), part.get(split + 1..)) else { continue }; let (first, last) = (first.trim(), last.trim()); // Avoid changing the meaning of ordinary numeric and open-ended ranges. - if !labels.iter().any(|l| l.contains('-') && (l == first || l == last)) { + if ![first, last].iter().any(|l| labels.contains_key(l) && l.contains('-')) { continue; } let from = if first.is_empty() { Some(0) } else { page_of(first, count, labels).ok() }; @@ -52,12 +90,22 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset match range.map(str::trim).filter(|r| !r.is_empty()) { None => pages.extend(0..count), Some(r) => { + let index = Labels::new(labels); + let labels = &index; + let limit = count.max(MAX_SELECTED); + let mut selected = 0usize; + // Checked before a span is expanded, so the list never grows past the limit. + let mut take = |from: usize, to: usize| { + selected = selected.saturating_add(from.abs_diff(to).saturating_add(1)); + if selected > limit { Err(PrintError::TooManySelected(limit)) } else { Ok(()) } + }; for part in r.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { // A complete label (e.g. "A-1") still wins over interpreting it as a range. // A range of such labels (e.g. "A-1-A-3") needs a later '-' split. - if !labels.iter().any(|l| l == part) + if !labels.contains_key(part) && let Some((from, to)) = hyphenated_label_range(part, count, labels) { + take(from, to)?; if from <= to { pages.extend(from..=to); } else { @@ -67,17 +115,24 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset } match part.split_once('-') { // A label may itself contain a dash ("A-1"): try the whole token first. - Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?), + Some(_) if labels.contains_key(part) => { + take(0, 0)?; + pages.push(page_of(part, count, labels)?); + } Some((a, b)) => { let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? }; let to = if b.trim().is_empty() { count.checked_sub(1).ok_or(PrintError::NoPages)? } else { page_of(b, count, labels)? }; + take(from, to)?; if from <= to { pages.extend(from..=to); } else { pages.extend((to..=from).rev()); } } - None => pages.push(page_of(part, count, labels)?), + None => { + take(0, 0)?; + pages.push(page_of(part, count, labels)?); + } } } } @@ -125,8 +180,42 @@ 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]); } + + /// Repeating a range is allowed up to the limit, and refused past it before the pages are + /// listed: 1,000 pages selected 100 times is the limit, once more is not. + #[test] + fn a_range_selects_at_most_the_limit() { + let whole = vec!["1-"; MAX_SELECTED / 1000].join(","); + assert_eq!(select_pages(1000, Some(&whole), &[], Subset::All, false).unwrap().len(), MAX_SELECTED); + let e = select_pages(1000, Some(&format!("{whole},1")), &[], Subset::All, false).unwrap_err(); + assert!(e.to_string().contains("more than 100000 pages"), "{e}"); + // A document longer than the limit can still be printed whole, once. + assert_eq!(select_pages(MAX_SELECTED + 1, Some("1-"), &[], Subset::All, false).unwrap().len(), MAX_SELECTED + 1); + } + + /// Labels resolve through one index: the first page with a label wins, and a hyphenated + /// label works as an endpoint, among many labels and many dashes. + #[test] + fn labels_resolve_through_the_index() { + let labels: Vec = (0..50_000).map(|i| format!("A-{i}")).collect(); + assert_eq!(select_pages(labels.len(), Some("A-3-A-5"), &labels, Subset::All, false).unwrap(), vec![3, 4, 5]); + let dashes = "-".repeat(10_000); + assert!(select_pages(labels.len(), Some(&dashes), &labels, Subset::All, false).is_err()); + // a token with no label as long as any of its halves costs no hashing of them + let long_token = format!("{}A-3", "-".repeat(200_000)); + assert!(select_pages(labels.len(), Some(&long_token), &labels, Subset::All, false).is_err()); + let twice = vec!["x".to_string(), "x".to_string()]; + assert_eq!(select_pages(2, Some("x"), &twice, Subset::All, false).unwrap(), vec![0]); + } } diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 0506eb5b18..ff860f5c65 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -69,6 +69,11 @@ pub enum Layout { order: PageOrder, border: bool, auto_rotate: bool, + /// A custom grid (the Print dialog's Custom pages per sheet): `cols` across and `rows` + /// down the sheet whatever its orientation, which is not picked for the grid, so Auto + /// prints portrait. A predefined count (`false`, [`Layout::multiple`]) is a grid on a + /// portrait sheet that turns to landscape when that suits it. + custom: bool, }, Booklet { subset: BookletSubset, @@ -107,10 +112,24 @@ impl Layout { (c, n.div_ceil(c).max(1)) } }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: false } + } + + /// A custom grid of `cols` across by `rows` down, each 1 to [`MAX_GRID`]; every cell holds a + /// page. `None` for a count outside that. + pub fn grid(cols: usize, rows: usize) -> Option { + if !(1..=MAX_GRID).contains(&cols) || !(1..=MAX_GRID).contains(&rows) { + return None; + } + // At most 99 × 99, so this cannot overflow. + let per_sheet = cols.checked_mul(rows)?; + Some(Layout::Multiple { cols, rows, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: true }) } } +/// The most columns, or rows, a custom pages-per-sheet grid can have. +pub const MAX_GRID: usize = 99; + /// Acrobat's Comments & Forms choices. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Content { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index 061a6ed5c3..fa34f5c6f8 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -164,8 +164,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P Layout::Label { flip } => { sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate, custom } => { + // A predefined count has at most 16 × 16 cells; a custom grid up to 99 each way. + let fits = if custom { cols <= crate::MAX_GRID && rows <= crate::MAX_GRID } else { cols.checked_mul(rows).is_some_and(|n| n <= 256) }; + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && fits); let Some(cells) = cells else { return Err(PrintError::Invalid("invalid number of pages per sheet".into())); }; @@ -177,7 +179,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P // A grid with more columns than rows wants a landscape sheet, and the reverse. let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { + let (gc, gr, sheet_landscape) = if custom { + // A custom grid is laid out as given, on the sheet as chosen (Auto: portrait). + (cols, rows, false) + } else if cols == rows { (cols, rows, page_landscape) } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 642af0b8c9..63d8a351e7 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -84,7 +84,7 @@ fn page_selection() { /// and the numbers apart from it, and says itself in English exactly as the error prints itself. #[test] fn wordings_are_said() { - let mistakes = [PrintError::NoPages, PrintError::OutOfRange(7, 5), PrintError::NotAPage("x".into())]; + let mistakes = [PrintError::NoPages, PrintError::OutOfRange(7, 5), PrintError::NotAPage("x".into()), PrintError::TooManySelected(1_000)]; for e in &mistakes { assert_eq!(e.said(&in_english), e.to_string(), "{e:?} says itself in English the way it prints itself"); } @@ -159,7 +159,10 @@ fn multiple_pages_per_sheet() { // Vertical order and borders. let v = layout( &sizes, - &settings(vec![0, 1, 2], Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false }), + &settings( + vec![0, 1, 2], + Layout::Multiple { cols: 2, rows: 2, per_sheet: 4, order: PageOrder::Vertical, border: true, auto_rotate: false, custom: false }, + ), ) .unwrap(); assert!(close(v[0].placed[1].matrix.0[4], v[0].placed[0].matrix.0[4]) && v[0].placed[1].matrix.0[5] < v[0].placed[0].matrix.0[5]); @@ -183,10 +186,11 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pages(4), [vec![0, 1, 2, 3], vec![4, 5, 6]]); assert_eq!(pages(2), [vec![0, 1], vec![2, 3], vec![4, 5], vec![6]]); // Cut-stack piles take the requested count too. - let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false }; + let stacked = Layout::Multiple { cols: 2, rows: 2, per_sheet: 3, order: PageOrder::CutStack, border: false, auto_rotate: false, custom: false }; assert_eq!(sheet_pages(&layout(&sizes, &settings((0..7).collect(), stacked)).unwrap()), [vec![0, 3, 6], vec![1, 4], vec![2, 5]]); // A count larger than the grid is capped by the grid; no pages per sheet is refused. - let grid = |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + let grid = + |per_sheet| Layout::Multiple { cols: 2, rows: 1, per_sheet, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: false }; assert_eq!(sheet_pages(&layout(&sizes[..3], &settings(vec![0, 1, 2], grid(9))).unwrap()), [vec![0, 1], vec![2]]); assert!(matches!(layout(&sizes, &settings(vec![0], grid(0))), Err(PrintError::Invalid(_)))); // The imposed PDF has one page per sheet. @@ -194,6 +198,34 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn a_custom_grid_is_laid_out_as_given_on_the_chosen_sheet() { + // Columns across and rows down whatever the sheet, which isn't turned for the grid (#6551). + let sizes = vec![(200.0, 300.0); 4]; + let at = |sheet: &Sheet, k: usize| (sheet.placed[k].matrix.0[4], sheet.placed[k].matrix.0[5]); + let grid = |cols, rows| Layout::grid(cols, rows).unwrap(); + for (orientation, size) in + [(Orientation::Auto, (612.0, 792.0)), (Orientation::Portrait, (612.0, 792.0)), (Orientation::Landscape, (792.0, 612.0))] + { + let s = |layout| Settings { orientation, ..settings((0..4).collect(), layout) }; + let across = layout(&sizes, &s(grid(2, 1))).unwrap(); + assert_eq!((across.len(), across[0].size), (2, size), "{orientation:?}"); + assert!(at(&across[0], 1).0 > at(&across[0], 0).0 && close(at(&across[0], 1).1, at(&across[0], 0).1), "side by side, {orientation:?}"); + let down = layout(&sizes, &s(grid(1, 2))).unwrap(); + assert_eq!(down[0].size, size, "{orientation:?}"); + assert!(close(at(&down[0], 1).0, at(&down[0], 0).0) && at(&down[0], 1).1 < at(&down[0], 0).1, "stacked, {orientation:?}"); + let three = layout(&sizes, &s(grid(3, 1))).unwrap(); + assert_eq!(sheet_pages(&three), [vec![0, 1, 2], vec![3]]); + } + // 1 to 99 each way, and a grid too fine for the paper is refused, not drawn. + assert!(Layout::grid(0, 1).is_none() && Layout::grid(1, 0).is_none() && Layout::grid(100, 1).is_none() && Layout::grid(1, usize::MAX).is_none()); + assert_eq!(layout(&sizes, &settings(vec![0], grid(17, 17))).unwrap()[0].placed.len(), 1); + assert!(matches!(layout(&sizes, &settings(vec![0], grid(99, 99))), Err(PrintError::Invalid(_)))); + let hostile = + Layout::Multiple { cols: 100, rows: 1, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: true }; + assert!(matches!(layout(&sizes, &settings(vec![0], hostile)), Err(PrintError::Invalid(_)))); +} + #[test] fn imposing_lists_the_pages_once() { let n = 150; @@ -450,7 +482,15 @@ fn driverless_printers_join_the_list_without_a_queue() { } fn cut_stack(cols: usize, rows: usize) -> Layout { - Layout::Multiple { cols, rows, per_sheet: cols.saturating_mul(rows), order: PageOrder::CutStack, border: false, auto_rotate: false } + Layout::Multiple { + cols, + rows, + per_sheet: cols.saturating_mul(rows), + order: PageOrder::CutStack, + border: false, + auto_rotate: false, + custom: false, + } } #[test] @@ -503,7 +543,7 @@ fn cutting_and_stacking_recovers_every_selected_page() { fn multiple_rejects_hostile_grids_without_panicking() { let sizes = [(200.0, 300.0)]; for (cols, rows) in [(usize::MAX, 2), (2, usize::MAX), (0, 2), (1, 0), (257, 1)] { - let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false }; + let mode = Layout::Multiple { cols, rows, per_sheet: 1, order: PageOrder::Horizontal, border: false, auto_rotate: false, custom: false }; assert!(matches!(layout(&sizes, &settings(vec![0], mode)), Err(PrintError::Invalid(_)))); } let tiny = Settings { paper: (72.0, 72.0), ..settings(vec![0], cut_stack(16, 16)) }; diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index f8088e0bba..7661b83ddb 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -26,9 +26,10 @@ pub enum Hidden { /// Optional content (layers) that is off: its content, then the layers themselves. HiddenLayers, Bookmarks, - /// Links, actions (open action, additional actions) and document JavaScript. + /// Links, actions (open action, additional actions, what bookmarks run) and document JavaScript. LinksActionsScripts, - /// Private data of other applications (`/PieceInfo`). + /// Private data of other applications (`/PieceInfo`), and prepress data on images and Forms + /// (`/OPI`, `/Alternates`). PrivateData, } @@ -173,11 +174,96 @@ fn outline_len_seen( n } +/// Takes out the action of outline item `d` when it does more than go to a place in this document +/// (a script, a launch, a web or remote link, a form submission); a jump with an action chained +/// after it (`/Next`) keeps only the jump. Items it links to that are written in place are cleaned +/// the same way, and items that are objects of their own go on `next`. Every link counts, not only +/// `/First` and `/Next`: a damaged outline may reach an item only backwards, and a full save keeps +/// that item too. `items` counts every item cleaned, inline ones included. Returns how many actions +/// went, or `None` past the depth limit or past [`OUTLINE_ITEM_BUDGET`] items. +fn clean_outline_item(doc: &Document, d: &mut Dict, next: &mut Vec, items: &mut usize, depth: usize) -> Option { + *items = items.saturating_add(1); + if depth > MAX_DIRECT_DEPTH || *items > OUTLINE_ITEM_BUDGET { + return None; + } + let mut n = 0; + if let Some(action) = d.get(b"A").and_then(|a| doc.resolve(a).as_dict().cloned()) { + match (action.name(b"S"), action.get(b"D")) { + (Some(b"GoTo"), _) if !action.contains(b"Next") => {} + (Some(b"GoTo"), Some(place)) => { + let mut jump = Dict::new(); + jump.set(b"S".to_vec(), Object::name("GoTo")); + jump.set(b"D".to_vec(), place.clone()); + d.set(b"A".to_vec(), Object::Dict(jump)); + n += 1; + } + _ => { + d.remove(b"A"); + n += 1; + } + } + } + for key in [&b"First"[..], b"Last", b"Next", b"Prev", b"Parent"] { + match d.get_mut(key) { + Some(Object::Ref(r)) => next.push(*r), + Some(Object::Dict(kid)) => n += clean_outline_item(doc, kid, next, items, depth + 1)?, + _ => {} + } + } + Some(n) +} + +/// [`clean_outline_item`] over the outline from the objects in `next`, after `items` items cleaned +/// already: the outline objects to write back, and how many actions went. Each object is read once; +/// `None` when the outline is larger than [`OUTLINE_ITEM_BUDGET`] items or nests in place past the +/// depth limit, so the removal can't be shown complete. +fn outline_without_actions(doc: &Document, mut next: Vec, mut items: usize) -> Option<(Vec<(ObjRef, Dict)>, usize)> { + let (mut out, mut n, mut seen) = (Vec::new(), 0, std::collections::HashSet::new()); + while let Some(r) = next.pop() { + if !seen.insert(r) { + continue; + } + if seen.len() > OUTLINE_ITEM_BUDGET { + return None; + } + let Some(mut d) = doc.get(r).as_dict().cloned() else { continue }; + let went = clean_outline_item(doc, &mut d, &mut next, &mut items, 0)?; + if went > 0 { + out.push((r, d)); + n += went; + } + } + Some((out, n)) +} + +/// How many outline actions Links, actions and JavaScripts would take out ([`clean_outline_item`]). +/// An outline too large or too deep to follow counts as one, so the removal runs and refuses it. +fn outline_action_count(doc: &Document, cat: &Dict) -> usize { + let (mut next, mut items) = (Vec::new(), 0); + let mut n = match cat.get(b"Outlines") { + Some(Object::Ref(r)) => { + next.push(*r); + 0 + } + Some(Object::Dict(o)) => clean_outline_item(doc, &mut o.clone(), &mut next, &mut items, 0).unwrap_or(1), + _ => return 0, + }; + n += outline_without_actions(doc, next, items).map_or(1, |(_, went)| went); + n +} + /// Entries that hold a category's data wherever they appear, not only on the catalog and pages: /// XMP on images, fonts and Forms, private data on Forms, associated files on pages and -/// annotations, additional actions on fields. -const OWNED: [(Hidden, &[u8]); 4] = - [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; +/// annotations, additional actions on fields. Images and Forms also carry prepress data: an OPI +/// proxy's link to the original file, and alternate images, which can be the unedited original. +const OWNED: [(Hidden, &[u8]); 6] = [ + (Hidden::Metadata, b"Metadata"), + (Hidden::PrivateData, b"PieceInfo"), + (Hidden::PrivateData, b"OPI"), + (Hidden::PrivateData, b"Alternates"), + (Hidden::Attachments, b"AF"), + (Hidden::LinksActionsScripts, b"AA"), +]; /// Keys whose dictionary values map names a producer chooses to objects (resources, glyph /// procedures, named destinations, role and class maps, private data by application). A name @@ -464,7 +550,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { // A graph nested too deeply to walk counts as holding everything, so the removal runs and // refuses it. let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); - let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); + let owned = |h: Hidden| OWNED.iter().zip(&held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::(); let mut doc2 = doc.clone(); HIDDEN .into_iter() @@ -502,6 +588,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + owned(Hidden::LinksActionsScripts) + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + + outline_action_count(doc, &cat) } Hidden::PrivateData => owned(Hidden::PrivateData), }; @@ -510,6 +597,34 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { .collect() } +/// Take from the outline of catalog `cat` every action that does more than go to a place in this +/// document (see [`outline_without_actions`]): bookmarks that stay keep where they go, not what +/// they run. An outline too large or nested too deep to follow is refused, not left half cleaned: +/// neither `cat` nor `doc` changes until the whole outline is cleaned. +fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), RedactError> { + let (mut next, mut items) = (Vec::new(), 0); + let root = match cat.get(b"Outlines") { + Some(Object::Dict(o)) => { + let mut o = o.clone(); + clean_outline_item(doc, &mut o, &mut next, &mut items, 0).ok_or(RedactError::TooDeep)?; + Some(o) + } + Some(Object::Ref(r)) => { + next.push(*r); + None + } + _ => None, + }; + let (objs, _) = outline_without_actions(doc, next, items).ok_or(RedactError::TooDeep)?; + if let Some(o) = root { + cat.set(b"Outlines".to_vec(), Object::Dict(o)); + } + for (r, d) in objs { + doc.set(r, Object::Dict(d)); + } + Ok(()) +} + /// Remove the chosen categories. Returns what was removed per category. /// /// # Errors @@ -628,6 +743,10 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { // The off layers leave the configuration (their content is gone already). diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index d038c94c93..804e3d26f6 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2853,6 +2853,73 @@ fn a_remove_hidden_copy_gets_a_new_identifier() { assert_ne!(ids[1], vec![0x22; 16], "the copy keeps the source's second identifier string"); } +/// Links, actions and JavaScripts takes the scripts out of bookmarks that stay: an item that runs +/// JavaScript loses its action, a jump with a script chained after it keeps only the jump, and a +/// plain jump is left alone. +#[test] +fn bookmarks_that_stay_lose_their_scripts() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b""), + b"<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>".to_vec(), + // 6 runs a script, 7 jumps and then runs one, 8 only jumps; 9 the script + b"<< /Title (Runs) /Parent 5 0 R /Next 7 0 R /A 9 0 R >>".to_vec(), + b"<< /Title (Jumps then runs) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoTo /D [3 0 R /Fit] /Next 9 0 R >> >>".to_vec(), + b"<< /Title (Jumps) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [3 0 R /Fit] >> >>".to_vec(), + b"<< /S /JavaScript /JS (app.alert('OUTLINESCRIPT')) >>".to_vec(), + ]); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 2))); + assert_eq!(remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(), [(Hidden::LinksActionsScripts, 2)]); + let item = |n: u32| doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap(); + assert!(!item(6).contains(b"A"), "{:?}", item(6)); + let jump = |n: u32| item(n).get(b"A").and_then(|a| doc.resolve(a).as_dict().cloned()).unwrap(); + assert_eq!((jump(7).name(b"S"), jump(7).contains(b"D"), jump(7).contains(b"Next")), (Some(&b"GoTo"[..]), true, false)); + assert_eq!(jump(8).name(b"S"), Some(&b"GoTo"[..])); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.windows(7).any(|w| w == b"(Jumps)"), "the bookmarks stay"); + assert!(!bytes.windows(13).any(|w| w == b"OUTLINESCRIPT"), "the bookmark's script survived the save"); +} + +/// A damaged outline may reach an item only backwards (`/Last`, `/Prev`, `/Parent`). The full save +/// keeps that item all the same, so its script goes too. +#[test] +fn bookmarks_reached_only_backwards_lose_their_scripts() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + // /First and /Next reach only 6; 7, 8 and 9 hang off its /Last, /Prev and /Parent and share script 10 + let mut doc = outlined_document(vec![ + b"<< /Title (Reached) /Last 7 0 R /Prev 8 0 R /Parent 9 0 R >>".to_vec(), + b"<< /Title (Last) /A 10 0 R >>".to_vec(), + b"<< /Title (Prev) /A 10 0 R >>".to_vec(), + b"<< /Title (Parent) /A 10 0 R >>".to_vec(), + b"<< /S /JavaScript /JS (app.alert('OUTLINESCRIPT')) >>".to_vec(), + ]); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 3))); + assert_eq!(remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(), [(Hidden::LinksActionsScripts, 3)]); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(bytes.windows(8).any(|w| w == b"(Parent)"), "the bookmarks stay"); + assert!(!bytes.windows(13).any(|w| w == b"OUTLINESCRIPT"), "a script reached only backwards survived the save"); +} + +/// Outline items written in place count towards the outline budget too: 60,000 items with an inline +/// child each are 120,000 items, so the outline is refused rather than walked past the budget. +#[test] +fn an_outline_past_the_budget_with_inline_items_is_refused() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let siblings = 60_000; + let mut items = Vec::with_capacity(siblings); + for i in 0..siblings { + let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() }; + items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes()); + } + let mut doc = outlined_document(items); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::LinksActionsScripts), Some((Hidden::LinksActionsScripts, 1))); + let out = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]); + assert!(matches!(out, Err(RedactError::TooDeep)), "{out:?}"); +} + /// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags /// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the /// annotations, the URL and the comment text included, while the page no longer lists them. @@ -4513,3 +4580,29 @@ fn a_redacted_form_xobject_drops_the_source_text_of_added_content() { let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file"); } + +/// Private data takes an image's prepress data too: its OPI link to the original file and its +/// alternate images, which can be the unedited original. +#[test] +fn private_data_takes_opi_links_and_alternate_images() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R /Fm 6 0 R >> >> >>".to_vec(), + stream("", b"q 50 0 0 50 20 20 cm /Im Do Q /Fm Do"), + stream( + "/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Alternates [<< /Image 7 0 R /DefaultForPrinting true >>]", + b"SHOWNIMAGE!", + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /OPI << /1.3 << /Type /OPI /Version 1.3 /F (OPISERVERPATH.tif) >> >>", b""), + stream("/Type /XObject /Subtype /Image /Width 17 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"ALTERNATEORIGINAL"), + ]); + assert_eq!(scan(&doc).into_iter().find(|c| c.0 == Hidden::PrivateData), Some((Hidden::PrivateData, 2))); + assert_eq!(remove_hidden(&mut doc, &[Hidden::PrivateData]).unwrap(), [(Hidden::PrivateData, 2)]); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"SHOWNIMAGE!"), "the image the page shows stays"); + assert!(!has(b"ALTERNATEORIGINAL"), "the alternate image survived the save"); + assert!(!has(b"OPISERVERPATH"), "the OPI link survived the save"); +} diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 477f03f9a5..c561b7f8d1 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -161,6 +161,30 @@ fn redacted_type3_glyph_and_exclusive_resources_do_not_survive_full_save() { assert!(t3_xo.contains(b"ImB"), "Type 3 font resources lost surviving image ImB"); } +/// The surviving glyph B draws a Form whose own resources also name A's image, which the Form +/// never draws. Pruning only the font's resources left that entry, so the image of the removed +/// glyph stayed in the file through the Form. +#[test] +fn a_redacted_type3_glyphs_image_does_not_survive_in_a_surviving_glyphs_form() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources << /XObject << /ImA 9 0 R /ImB 11 0 R >> >>", b"/ImB Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"PUBLIC_IMAGE_B"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); + assert!(!full_save_holds(&doc, b"CONFIDENTIAL_IMAGE_A"), "the removed glyph's image survives through the surviving glyph's form"); + assert!(full_save_holds(&doc, b"PUBLIC_IMAGE_B"), "the image the surviving glyph's form draws was lost"); +} + /// When all glyphs of a Type 3 font are redacted, the entire font is removed from Resources (F-011). #[test] fn all_redacted_type3_glyphs_cause_font_removal_from_resources() { @@ -298,3 +322,23 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() { assert_eq!(report.glyphs, 1); assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived"); } + +/// A Form the surviving glyph draws holds `/Resources` as an array that references the removed +/// glyph's image: it can't be pruned, so the redaction refuses rather than keep the image. +#[test] +fn a_type3_glyphs_form_with_non_dictionary_resources_fails_closed() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AB) Tj ET"), + b"<< /Font << /T3 6 0 R >> >>".to_vec(), + b"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 66 /Widths [500 500] /Encoding << /Differences [65 /A /B] >> /CharProcs << /A 7 0 R /B 8 0 R >> /Resources << /XObject << /ImA 9 0 R /Fm 10 0 R >> >> >>".to_vec(), + stream("", b"/ImA Do"), + stream("", b"/Fm Do"), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"CONFIDENTIAL_IMAGE_A"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 1000 1000] /Resources [9 0 R]", b""), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + assert!(apply(&mut doc, None).is_err()); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 57971571af..3bba33a645 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -135,7 +135,13 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res if parsed.skipped > 0 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect glyph stream syntax".into() }); } - for op in parsed.ops { + parsed.ops.iter().for_each(|op| record_used(op, used)); + Ok(()) +} + +/// Record the resource name `op` uses, if any. +fn record_used(op: &pdfcraft_content::Op, used: &mut UsedInGlyphs) { + { match op.op.as_slice() { b"Do" => { if let Some(n) = op.last_name() { @@ -175,6 +181,54 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res _ => {} } } +} + +/// Prune the own `/Resources` of every Form a Type 3 font's (already pruned) resources still +/// list, and of the Forms those list in turn, to the names each Form's content uses. A Form a +/// surviving glyph draws may name, unused, an image only a removed glyph drew; that entry would +/// keep the image in the file. Pruning only unused entries is safe for a shared Form: no drawer +/// of it can use a name its content doesn't. Fails closed when a Form can't be read. +fn prune_font_forms(doc: &mut Document, font_res: &Dict) -> Result<(), CosError> { + let mut pending: Vec<(ObjRef, usize)> = res_dict(doc, font_res, b"XObject").iter().filter_map(|(_, v)| v.as_ref()).map(|r| (r, 0)).collect(); + let mut seen = HashSet::new(); + while let Some((form_ref, depth)) = pending.pop() { + if !seen.insert(form_ref) { + continue; + } + if depth > MAX_DEPTH || seen.len() > MAX_HOLDERS { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not establish safe Type 3 font cleanup: forms nest too deep".into() }); + } + let Object::Stream(form) = &*doc.get(form_ref) else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + // A Form without resources of its own (no key, or `null`) draws through its caller's, + // which were pruned already. Any other value that isn't a dictionary may still reference + // what a removed glyph drew, and can't be pruned: fail closed. + let mut res = match form.dict.get(b"Resources").map(|r| doc.resolve(r)) { + None => continue, + Some(r) if matches!(*r, Object::Null) => continue, + Some(r) => match r.as_dict() { + Some(d) => d.clone(), + None => { + return Err(CosError::Syntax { + offset: 0, + detail: "redaction could not establish safe Type 3 font cleanup: a form's /Resources is not a dictionary".into(), + }); + } + }, + }; + let data = form.decoded()?; + let mut used = UsedInGlyphs::default(); + crate::interp::resource_ops(doc, &res, std::slice::from_ref(&data))?.iter().for_each(|op| record_used(op, &mut used)); + // `rg`, `g` and `k` draw through these entries when the Form keeps them as its defaults, + // and nothing cites them with `cs`/`CS`: pruning them would change the Form's colours. + used.colorspaces.extend([b"DefaultRGB".to_vec(), b"DefaultGray".to_vec(), b"DefaultCMYK".to_vec()]); + if prune_type3_font_resources(doc, &mut res, &used) { + doc.update_dict(form_ref, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.extend(res_dict(doc, &res, b"XObject").iter().filter_map(|(_, v)| v.as_ref()).map(|r| (r, depth + 1))); + } Ok(()) } @@ -631,6 +685,7 @@ fn clean_type3_font( } })?; } + prune_font_forms(doc, &f_res)?; Ok(()) } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 65c94852e6..aacd5d2801 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -31,5 +31,6 @@ bytemuck = { version = "1" } getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] +pdfcraft-testkit = { workspace = true } # Builds the decompression-bomb streams in the raster tests. flate2 = { workspace = true } diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 7c4543b733..6cd6b32cf4 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -15,13 +15,38 @@ const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; /// are not listed: real comments stay far below, many small ones sharing one long string do not. pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20; -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its -/// address, an attachment under its generic name without a description, and an embedded file whose -/// key does not fit is not listed. +/// Web-address text the bookmark and link listings, and name, description and embedded-file key +/// text the attachment listing, keep together. Many bookmarks, links or attachments can share one +/// long string, which each would otherwise copy. Past it, a bookmark or link to a web address lists +/// as a URI action without its address, an attachment under its generic name without a +/// description, and an embedded file whose key does not fit is not listed. pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; +/// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color` +/// and `drawn_color`): one entry is grey, three RGB, four CMYK; among four entries a non-number is +/// 0 but a reference is unreadable, otherwise every entry must be a number. `None` for anything +/// else, an empty array or a value out of range; each component is clamped to 0..=1. +fn drawn_color(c: &[Object]) -> Option<[f32; 3]> { + let v: Vec = if c.len() == 4 { + c.iter() + .map(|o| match o { + Object::Reference(_) => None, + o => Some(o.as_float().unwrap_or(0.0)), + }) + .collect::>()? + } else { + c.iter().map(|o| o.as_float().ok()).collect::>()? + }; + let rgb = match v.as_slice() { + [g] => [*g; 3], + [r, g, b] => [*r, *g, *b], + [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], + _ => return None, + }; + // pdfcraft-annot's `finite`: finite and below 1e7 in magnitude. + rgb.iter().all(|x| x.is_finite() && x.abs() < 1e7).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) +} + /// The bytes a listed annotation keeps. pub(super) fn retained(a: &Annotation) -> usize { let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent]; @@ -93,6 +118,16 @@ impl<'a> Inspector<'a> { let mut comments_full = false; for (page, index, a) in entries { let Some(d) = self.dict(a) else { continue }; + // An annotation's `/Type`, when present, is `/Annot` (ISO 32000-2 §12.5.2, Table 166): + // a font or other dictionary a page lists among its annotations isn't one, and neither is + // one whose `/Type` isn't a name at all. A null or dangling `/Type` counts as absent + // (§7.3.9: a null value is the same as leaving the entry out). + if let Ok(t) = d.get(b"Type").map(|t| self.resolve(t)) + && !matches!(t, Object::Null | Object::Reference(_)) + && t.as_name().ok() != Some(b"Annot".as_slice()) + { + continue; + } let Some(subtype) = self.name(d, b"Subtype") else { continue }; if subtype == "FileAttachment" && let Ok(fs) = d.get(b"FS") @@ -119,13 +154,18 @@ impl<'a> Inspector<'a> { continue; } let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } + let c = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) => Some(c), _ => None, }; + // Three numbers, some given by reference, with a non-number read as 0. + let rgb = c.filter(|c| c.len() == 3).map(|c| { + let f = |i: usize| c.get(i).map_or(0.0, |o| self.resolve(o).as_float().unwrap_or(0.0)); + [f(0), f(1), f(2)] + }); + // Other comments list the stroke colour as pdfcraft-annot draws it and lists it + // (`summaries`): grey, CMYK, a non-number among four entries read as 0. + let mut color = if subtype == "FreeText" { rgb } else { c.and_then(|c| drawn_color(c)).or(rgb) }; // A text box's /C is its background; its text colour is in /DA. if subtype == "FreeText" && let Some(da) = self.text(d, b"DA") @@ -194,35 +234,44 @@ impl<'a> Inspector<'a> { link_replies(&mut info.annotations); } + /// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest` + /// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no + /// rectangle, or a destination or action that leads nowhere. fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; LinkTarget::Page(page, view) } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; - match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { - Some(uri) => LinkTarget::Uri(uri), - None => LinkTarget::Other("URI".into()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } + self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? }; Some(Link { page, index, rect, target }) } + /// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in + /// this document; a remote go-to's names a page or destination of another file (ISO 32000-2 + /// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here. + /// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]). + pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option { + Some(match self.name(a, b"S").as_deref()? { + "GoTo" => { + let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; + LinkTarget::Page(page, view) + } + "URI" => { + let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; + match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { + Some(uri) => LinkTarget::Uri(uri), + None => LinkTarget::Other("URI".into()), + } + } + "SetOCGState" => self.layer_state(a), + "GoToR" => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), + "Launch" => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), + other => LinkTarget::Other(other.to_string()), + }) + } + fn remote_go_to(&self, action: &Dictionary) -> Option { let file = self.action_file(action)?; let destination = match action.get(b"D") { @@ -340,14 +389,14 @@ impl<'a> Inspector<'a> { o: &'a Object, parent: Option<(&str, Option, Option)>, out: &mut Vec, - seen: &mut HashSet, + walk: &mut FieldWalk, depth: u32, ) { if depth > 32 { return; } if let Object::Reference(id) = o - && !seen.insert(*id) + && !walk.seen.insert(*id) { return; } @@ -366,10 +415,16 @@ impl<'a> Inspector<'a> { Ok(Object::Array(k)) => k.as_slice(), _ => &[], }; - let field_kids: Vec<_> = kids.iter().filter(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())).collect(); + let (field_kids, widget_kids): (Vec<&Object>, Vec<&Object>) = kids.iter().partition(|k| self.dict(k).is_some_and(|kd| kd.get(b"T").is_ok())); + let field_kids: Vec<_> = field_kids.into_iter().filter(|k| !self.not_a_child(walk, o, k)).collect(); if !field_kids.is_empty() { + let id = if let Object::Reference(id) = o { Some(*id) } else { None }; + walk.path.extend(id); for k in field_kids { - self.field(k, Some((&full, ft.clone(), ff)), out, seen, depth + 1); + self.field(k, Some((&full, ft.clone(), ff)), out, walk, depth + 1); + } + if id.is_some() { + walk.path.pop(); } return; } @@ -399,14 +454,67 @@ impl<'a> Inspector<'a> { Ok(Object::Dictionary(_)) if kind == FieldKind::Signature => Some("signed".into()), _ => None, }; - // The widget is either this dict (merged field/widget) or its first kid. - let widget = if d.get(b"Rect").is_ok() { Some(d) } else { kids.first().and_then(|k| self.dict(k)) }; - let page = widget.and_then(|w| w.get(b"P").ok()).and_then(|p| self.page_of(p)).or_else(|| self.find_widget_page(o, kids)); + // The widget is either this dict (merged field/widget) or its first kid. With no widget + // among its kids, it is the first page widget naming this field as its `/Parent`: a child + // names its parent (ISO 32000-2 §12.7.4.1, Table 226), as MuPDF and PDFium read it. + let merged = d.get(b"Rect").is_ok(); + let named = if merged || !widget_kids.is_empty() { None } else { self.widget_naming(o) }; + let widget = if merged { + Some(d) + } else { + widget_kids.first().and_then(|k| self.dict(k)).or_else(|| named.and_then(|id| self.doc.get_dictionary(id).ok())) + }; + let page = widget + .and_then(|w| w.get(b"P").ok()) + .and_then(|p| self.page_of(p)) + .or_else(|| self.find_widget_page(o, kids)) + .or_else(|| named.and_then(|id| self.annotation_pages.get_or_init(|| self.annotation_pages()).get(&id).copied())); let has_actions = d.get(b"AA").is_ok() || widget.is_some_and(|w| w.get(b"AA").is_ok() || w.get(b"A").is_ok()); let rect = widget.and_then(|w| w.get(b"Rect").ok()).map(|r| rect4(self.resolve(r))); out.push(Field { name: full, kind, value, page, tooltip: self.text(d, b"TU"), has_actions, rect }); } + /// Whether `k`, listed in the `/Kids` of field `o`, is not a child of `o` after all: `o` itself + /// or a field `o` descends from (a loop), or a root field (one `/Fields` lists) that doesn't + /// name `o` as its `/Parent`. Root fields have no ancestors (ISO 32000-2 §12.7.3, Table 224), + /// and a child names its parent (§12.7.4.1, Table 226). + fn not_a_child(&self, walk: &FieldWalk, o: &Object, k: &Object) -> bool { + let Object::Reference(k) = k else { return false }; + let parent = self.dict(&Object::Reference(*k)).and_then(|d| match d.get(b"Parent") { + Ok(Object::Reference(p)) => Some(*p), + _ => None, + }); + let o = if let Object::Reference(o) = o { Some(*o) } else { None }; + o == Some(*k) || walk.path.contains(k) || (walk.roots.contains(k) && (o.is_none() || parent != o)) + } + + /// The first page widget that names field `o` as its `/Parent` and is not a field of its own + /// (no `/T`), in page-annotation order. + fn widget_naming(&self, o: &Object) -> Option { + let Object::Reference(id) = o else { return None }; + self.parent_widgets.get_or_init(|| self.parent_widgets()).get(id).copied() + } + + /// The first page widget (no `/T`) naming each field as its `/Parent`, in page-annotation order. + fn parent_widgets(&self) -> HashMap { + let mut first = HashMap::new(); + for &pid in &self.page_ids { + let Some(annots) = self.doc.get_dictionary(pid).ok().and_then(|d| d.get(b"Annots").ok()) else { continue }; + let Ok(annots) = self.resolve(annots).as_array() else { continue }; + for a in annots { + let Object::Reference(id) = a else { continue }; + let Ok(w) = self.doc.get_dictionary(*id) else { continue }; + if self.name(w, b"Subtype").as_deref() != Some("Widget") || w.get(b"T").is_ok() { + continue; + } + if let Ok(Object::Reference(parent)) = w.get(b"Parent") { + first.entry(*parent).or_insert(*id); + } + } + } + first + } + /// The first page whose `/Annots` lists the field or one of its kids. fn find_widget_page(&self, field: &Object, kids: &[Object]) -> Option { let pages = self.annotation_pages.get_or_init(|| self.annotation_pages()); @@ -428,7 +536,7 @@ impl<'a> Inspector<'a> { pages } - /// The topmost field above widget `id` (itself without a `/Parent`), or `None` when it or an + /// The topmost field above widget `id` (itself without a `/Parent` that is a field), or `None` when it or an /// ancestor is already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. pub(super) fn unlisted_field(&self, id: ObjectId, seen: &HashSet) -> Option { let mut top = id; @@ -436,8 +544,10 @@ impl<'a> Inspector<'a> { loop { let o = Object::Reference(top); let d = self.dict(&o)?; + // A `/Parent` naming an object the file doesn't hold, or no dictionary, reads as + // `null`, the same as no parent (ISO 32000-2 §7.3.9). let parent = match d.get(b"Parent") { - Ok(Object::Reference(p)) => Some(*p), + Ok(Object::Reference(p)) if self.dict(&Object::Reference(*p)).is_some() => Some(*p), _ => None, }; let Some(p) = parent else { @@ -454,6 +564,15 @@ impl<'a> Inspector<'a> { } } +/// What one reading of the form's fields keeps: the fields read, the root fields `/Fields` lists, +/// and the fields whose `/Kids` are being read, outermost first. +#[derive(Default)] +pub(super) struct FieldWalk { + pub(super) seen: HashSet, + pub(super) roots: HashSet, + path: Vec, +} + /// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a /// `u32` counts as no flags. fn draw_flags(o: &Object) -> u32 { diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 924bfcc51d..c5df792f23 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,4 +1,4 @@ -use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; +use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES}; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; use super::{ @@ -80,6 +80,9 @@ pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result info.pages = p, Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))), } + // Never silent: the pages of a file whose last update has no `/Prev` were found by rebuilding + // its cross-reference data (`pdfcraft-cos` rebuilds it under the same conditions and logs it). + let unlinked = pdf.rebuilt_unlinked_update(); let options = load_options(password); // Geometry no longer needs the renderer parser. Do not overlap its allocations with // structural inspection, including the compatibility fallback for damaged files. @@ -110,9 +113,17 @@ pub fn inspect(bytes: Arc>, password: Option<&str>) -> Result Result<(), String>) { let structure = catch_unwind(AssertUnwindSafe(|| { @@ -144,13 +155,16 @@ pub(super) struct Inspector<'a> { /// has no `/P`. Searching every page's `/Annots` for each such field was O(fields × /// annotations): a second on a form of 8,000 fields. pub(super) annotation_pages: OnceCell>, + /// The first page widget naming each field as its `/Parent`, built when a field has no widget + /// among its `/Kids`. + pub(super) parent_widgets: OnceCell>, } impl<'a> Inspector<'a> { pub(super) fn new(doc: &'a dyn Structure) -> Self { let page_ids = doc.page_ids(); let page_index = page_ids.iter().copied().enumerate().map(|(index, id)| (id, index)).collect(); - let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new() }; + let mut me = Self { doc, page_ids, page_index, named: HashMap::new(), annotation_pages: OnceCell::new(), parent_widgets: OnceCell::new() }; let mut named = HashMap::new(); if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) @@ -161,6 +175,9 @@ impl<'a> Inspector<'a> { me } + /// Reads what `info` holds beyond the page list: encryption, document information, tagging, + /// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments, + /// JavaScript and the opening destination. pub(super) fn fill(&self, info: &mut DocInfo) { // lopdf drops /Encrypt from the trailer once it has decrypted the file. info.encrypted = self.doc.encrypted(); @@ -204,18 +221,19 @@ impl<'a> Inspector<'a> { Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { + (info.outline, info.outline_more) = self.outline_window(first, &mut text_budget); + } self.annotations(info, &mut text_budget); self.transitions(info); - let mut field_seen = HashSet::new(); + let mut walk = FieldWalk::default(); if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)) && let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array()) { + walk.roots = fields.iter().filter_map(|f| if let Object::Reference(id) = f { Some(*id) } else { None }).collect(); for f in fields { - self.field(f, None, &mut info.fields, &mut field_seen, 0); + self.field(f, None, &mut info.fields, &mut walk, 0); } } // Widgets no `/Fields` entry reaches are fields too (Acrobat and the browsers fill them); @@ -231,9 +249,9 @@ impl<'a> Inspector<'a> { continue; } if let Object::Reference(id) = a - && let Some(root) = self.unlisted_field(*id, &field_seen) + && let Some(root) = self.unlisted_field(*id, &walk.seen) { - self.field(&Object::Reference(root), None, &mut info.fields, &mut field_seen, 0); + self.field(&Object::Reference(root), None, &mut info.fields, &mut walk, 0); } } } @@ -430,3 +448,40 @@ mod open_action_javascript_tests { assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); } } + +#[cfg(test)] +mod unlinked_update_tests { + use super::*; + + /// An update without `/Prev` cuts the page tree off the chain; the pages come from the + /// rebuilt table, the page tree's pages in its order, and the file says it was rebuilt. + #[test] + fn an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt() { + let info = inspect(Arc::new(pdfcraft_testkit::update_without_prev(false)), None).expect("the unlinked file opens"); + assert_eq!(info.pages.len(), 3, "{:?}", info.pages); + assert_eq!(info.pages.iter().map(|p| p.width).collect::>(), [200.0, 300.0, 400.0]); + assert_eq!(info.warnings, [UNLINKED_UPDATE]); + // The structure is read from the rebuilt table as well: the second page's link. + assert_eq!(info.links.len(), 1, "{:?}", info.links); + let linked = inspect(Arc::new(pdfcraft_testkit::update_without_prev(true)), None).expect("the linked file opens"); + assert_eq!(linked.pages.len(), 3); + assert!(linked.warnings.is_empty(), "{:?}", linked.warnings); + assert_eq!(linked.links.len(), 1); + } + + /// A hybrid update's `/XRefStm` stream is part of that update, not an earlier section: without + /// `/Prev` the file is rebuilt as the document model rebuilds it, and with it, it isn't. + #[test] + fn a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it() { + let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false)); + let info = inspect(bytes.clone(), None).expect("the unlinked hybrid file opens"); + assert_eq!(info.pages.iter().map(|p| p.width).collect::>(), [200.0, 300.0, 400.0]); + assert_eq!(info.warnings, [UNLINKED_UPDATE]); + assert!(pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update()); + let bytes = Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true)); + let linked = inspect(bytes.clone(), None).expect("the linked hybrid file opens"); + assert_eq!(linked.pages.len(), 3); + assert!(linked.warnings.is_empty(), "{:?}", linked.warnings); + assert!(!pdfcraft_cos::Document::open(bytes).expect("cos opens it").rebuilt_unlinked_update()); + } +} diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..981de248ae 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -287,6 +287,11 @@ pub struct OutlineItem { pub page: Option, /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). pub view: DestView, + /// What the bookmark does instead when its action (`/A`, ISO 32000-2 §12.3.3 Table 151) goes + /// nowhere in this document: a web address, another PDF, a layer change, or an action named + /// but not run ([`LinkTarget::Other`]), read as a link's is. Never [`LinkTarget::Page`]: a + /// go-to is `page` and `view`. + pub action: Option, pub children: Vec, /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 4000a74135..fb19526875 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -1,7 +1,7 @@ #[cfg(test)] use super::core::inspect; use super::core::spend; -use super::model::{DestView, OutlineItem, PageInfo}; +use super::model::{DestView, LinkTarget, OutlineItem, PageInfo}; use super::{ BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, @@ -185,8 +185,9 @@ impl<'a> Inspector<'a> { /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - pub(super) fn outline_window(&self, first: &'a Object) -> (Vec, bool) { + /// each bookmark is read once, so the work and memory stay within the window. Web addresses are + /// spent from `text_budget`, as links' are. + pub(super) fn outline_window(&self, first: &'a Object, text_budget: &mut usize) -> (Vec, bool) { let mut seen = HashSet::new(); // The listed bookmarks in outline order, with their depth. let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); @@ -210,12 +211,20 @@ impl<'a> Inspector<'a> { break; } *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); + // A bookmark goes to its `/Dest`, else does what its action `/A` does, as a link would. + // Only a go-to's `/D` is a page here: a remote go-to's names a page of the other file. + // An action without a type is still read as the go-to its `/D` suggests. + let action = match d.get(b"Dest").ok().and_then(|dest| self.dest(dest, 0)) { + Some((page, view)) => Some(LinkTarget::Page(page, view)), + None => d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| match a.get(b"S") { + Ok(_) => self.action_target(a, text_budget), + Err(_) => a.get(b"D").ok().and_then(|dest| self.dest(dest, 0)).map(|(page, view)| LinkTarget::Page(page, view)), + }), + }; + let ((page, view), action) = match action { + Some(LinkTarget::Page(page, view)) => ((Some(page), view), None), + other => ((None, DestView::Top), other), + }; match d.get(b"First").ok() { Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), Some(_) => more = true, @@ -229,7 +238,7 @@ impl<'a> Inspector<'a> { }; let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); + listed.push((depth, OutlineItem { title, page, view, action, children: Vec::new(), open, color, italic, bold })); } (nest_outline(listed), more) } diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 0895c64f8a..b8d25efc02 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -231,9 +231,15 @@ impl<'a> Inspector<'a> { } } -/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. +/// Fetch an attachment's bytes (decoded). Capped at 1 GiB to defuse decompression bombs. A +/// document opened without the password its embedded files need (`/AuthEvent /EFOpen`, ISO +/// 32000-2 §7.6.6) holds them encrypted: asking for one is an error, never its encrypted bytes. pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -> Result, String> { let run = || -> Result, String> { + // Read through lopdf, which can't read a document opened without its key at all. + if pdfcraft_cos::Document::open_with_password(std::sync::Arc::new(bytes.to_vec()), password).is_ok_and(|d| d.embedded_files_locked()) { + return Err("the attachment is encrypted: open the document with its password to read it".into()); + } let options = load_options(password); let doc = Document::load_mem_with_options(bytes, options).map_err(|e| e.to_string())?; let insp = Inspector::new(&doc); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 907401f8bb..c103276cb9 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -786,6 +786,26 @@ fn fields_listed_only_on_the_pages_are_listed() { assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); } +/// A widget whose `/Parent` names an object the file doesn't hold, or one that isn't a dictionary, +/// is listed as a field of its own: that parent reads as `null`, the same +/// as none (ISO 32000-2 §7.3.9). +#[test] +fn a_widget_whose_parent_is_missing_is_listed() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >> endobj +4 0 obj << /Fields [] >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /V (one) /Parent 40 0 R /Rect [10 100 90 120] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Parent 7 0 R /Rect [10 60 90 80] >> endobj +7 0 obj [1 2 3] endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.value.as_deref(), f.page)).collect(); + assert_eq!(listed, [("alpha", Some("one"), Some(0)), ("beta", None, Some(0))], "{:?}", info.fields); +} + /// A `Structure` that counts the objects read through it. struct CountingReads<'a> { doc: &'a Document, @@ -805,6 +825,30 @@ impl Structure for CountingReads<'_> { } } +/// A font (or any dictionary whose `/Type` is present and isn't `/Annot`) a page lists in `/Annots` +/// isn't an annotation (ISO 32000-2 §12.5.2, Table 166) and isn't listed as a comment; the real ones +/// around it keep their place in the array, and an entry without `/Type` is still read as one. A +/// `/Type` that isn't a name (`/Type 42`) isn't `/Annot` either, while `/Type null` counts as absent. +#[test] +fn dictionaries_of_another_type_in_annots_are_not_annotations() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj +4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (typed) >> endobj +6 0 obj << /Type /Font /Subtype /TrueType /BaseFont /Arial >> endobj +7 0 obj << /Subtype /Square /Rect [40 40 60 60] /Contents (untyped) >> endobj +8 0 obj << /Type 42 /Subtype /Text /Rect [70 70 90 90] /Contents (number type) >> endobj +9 0 obj << /Type null /Subtype /Circle /Rect [100 100 120 120] /Contents (null type) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let mut listed: Vec<_> = info.annotations.iter().map(|a| (a.subtype.as_str(), a.index, a.contents.as_deref())).collect(); + listed.sort_by_key(|a| a.1); + assert_eq!(listed, [("Text", 1, Some("typed")), ("Square", 3, Some("untyped")), ("Circle", 5, Some("null type"))], "{:?}", info.annotations); +} + /// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad /// with a non-number is left out rather than drawn with a zero, an indirect number is followed, /// and a trailing partial quad is dropped. @@ -877,6 +921,40 @@ fn an_annotation_reached_through_a_reference_chain_is_listed_once() { } } +/// Comments list their `/C` as pdfcraft-annot draws and lists it: four entries with a null are +/// CMYK with the null as 0, one entry is grey, a reference among four entries is unreadable, +/// three numbers (some by reference) are RGB, and a text box keeps its three-number `/C`. +#[test] +fn comment_colours_are_listed_as_drawn() { + let pdf = "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >> endobj + 4 0 obj << /Type /Annot /Subtype /Square /Rect [0 0 10 10] /C [1 0 0 null] >> endobj + 5 0 obj << /Type /Annot /Subtype /Square /Rect [0 20 10 30] /C 10 0 R >> endobj + 6 0 obj << /Type /Annot /Subtype /Square /Rect [0 40 10 50] /C [1 0 0 11 0 R] >> endobj + 7 0 obj << /Type /Annot /Subtype /Square /Rect [0 60 10 70] /C [11 0 R 0 0] >> endobj + 8 0 obj << /Type /Annot /Subtype /Circle /Rect [0 80 10 90] /C [0 0 0 1] >> endobj + 9 0 obj << /Type /Annot /Subtype /FreeText /Rect [0 100 10 110] /C [0 1 0] >> endobj + 10 0 obj [0.5] endobj + 11 0 obj 1 endobj + trailer << /Root 1 0 R >> +%%EOF"; + let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut saved = Vec::new(); + doc.save_to(&mut saved).expect("saves"); + let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + for reader in [&doc as &dyn Structure, &lazy] { + let mut info = DocInfo { pages: vec![page.clone()], ..Default::default() }; + Inspector::new(reader).fill(&mut info); + let mut listed: Vec<(f32, Option<[f32; 3]>)> = info.annotations.iter().map(|a| (a.rect[1], a.color)).collect(); + listed.sort_by(|a, b| a.0.total_cmp(&b.0)); + let colors: Vec> = listed.into_iter().map(|(_, c)| c).collect(); + assert_eq!(colors, [Some([0.0, 1.0, 1.0]), Some([0.5, 0.5, 0.5]), None, Some([1.0, 0.0, 0.0]), Some([0.0, 0.0, 0.0]), Some([0.0, 1.0, 0.0])]); + } +} + /// A reply finds its comment where the listing keeps it: on the first page of a page-shared /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. #[test] @@ -1637,6 +1715,57 @@ fn outline_items_carry_their_colour_and_style() { ); } +/// A bookmark's action (`/A`, ISO 32000-2 §12.3.3 Table 151) is read as a link's is. 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 (§12.6.4.3), so `[0 /FitH 200]` there is not this file's first page. A web address, +/// another PDF and actions PdfCraft doesn't run are kept for the bookmark to run or report. An +/// action without a type is still read as the go-to its `/D` suggests. +#[test] +fn bookmark_actions_are_read_as_links_read_them() { + use DestView::*; + let actions = [ + ("Go to", "/A << /S /GoTo /D [3 0 R /FitH 300] >>"), + ("Web", "/A << /S /URI /URI (https://example.org/manual) >>"), + ("Other file", "/A << /S /GoToR /F (other.pdf) /D [0 /FitH 200] /NewWindow true >>"), + ("Other file, named", "/A << /S /GoToR /F (other.pdf) /D (sec) >>"), + ("Launch", "/A << /S /Launch /F (other.pdf) >>"), + ("Menu item", "/A << /S /Named /N /NextPage >>"), + ("Script", "/A << /S /JavaScript /JS (app.alert\\(1\\)) >>"), + ("No type", "/A << /D [3 0 R /Fit] >>"), + ]; + let n = actions.len(); + let items: Vec = actions + .iter() + .enumerate() + .map(|(k, (title, action))| { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} {action} >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + let got: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view, o.action.clone())).collect(); + let remote = |destination, new_window| Some(LinkTarget::RemoteGoTo { launch: false, file: "other.pdf".into(), destination, new_window }); + assert_eq!( + got, + [ + ("Go to", Some(0), FitH { top: Some(300.0) }, None), + ("Web", None, Top, Some(LinkTarget::Uri("https://example.org/manual".into()))), + ("Other file", None, Top, remote(RemoteDestination::Page { page: 0, view: FitH { top: Some(200.0) } }, Some(true))), + ("Other file, named", None, Top, remote(RemoteDestination::Named("sec".into()), None)), + ( + "Launch", + None, + Top, + Some(LinkTarget::RemoteGoTo { launch: true, file: "other.pdf".into(), destination: RemoteDestination::Default, new_window: None }) + ), + ("Menu item", None, Top, Some(LinkTarget::Other("Named".into()))), + ("Script", None, Top, Some(LinkTarget::Other("JavaScript".into()))), + ("No type", Some(0), Fit, None), + ] + ); +} + #[test] fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { const COUNT: usize = OUTLINE_WINDOW + 5; @@ -1932,3 +2061,168 @@ fn inspection_keeps_no_private_page_data() { }; assert_eq!(report(with), report(without), "what inspection reports does not change"); } + +/// A `/Kids` entry that loops back, or names a root field that doesn't name this one as its +/// `/Parent`, is not a child (ISO 32000-2 §12.7.3, Table 224; §12.7.4.1, Table 226): the field +/// keeps its other kids and is listed with them. +#[test] +fn kids_that_loop_back_or_are_roots_are_not_children() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R 9 0 R 12 0 R 15 0 R] >> endobj +4 0 obj << /Fields [5 0 R 7 0 R 8 0 R 10 0 R] >> endobj +5 0 obj << /FT /Btn /Ff 49152 /T (size) /Kids [5 0 R 6 0 R] >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj +7 0 obj << /FT /Btn /Ff 49152 /T (left) /Kids [8 0 R 9 0 R] >> endobj +8 0 obj << /FT /Btn /Ff 49152 /T (right) /Kids [7 0 R 12 0 R] >> endobj +9 0 obj << /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [10 120 20 130] >> endobj +10 0 obj << /T (group) /Kids [13 0 R] >> endobj +12 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj +13 0 obj << /FT /Tx /T (inner) /Parent 10 0 R /Kids [14 0 R] >> endobj +14 0 obj << /FT /Tx /T (deep) /Parent 13 0 R /Kids [13 0 R 15 0 R] >> endobj +15 0 obj << /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [10 60 90 80] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.kind, f.page, f.rect.map(|r| r[1]))).collect(); + assert_eq!( + listed, + [ + ("size", FieldKind::Radio, Some(0), Some(150.0)), + ("left", FieldKind::Radio, Some(0), Some(120.0)), + ("right", FieldKind::Radio, Some(0), Some(90.0)), + ("group.inner.deep", FieldKind::Text, Some(0), Some(60.0)), + ], + "{:?}", + info.fields + ); +} + +/// A field with no widget among its `/Kids` takes the page widget that names it as its +/// `/Parent`: a child names its parent (ISO 32000-2 §12.7.4.1, Table 226). Here `pair` and `other` +/// list each other (roots, so not children), and only a page widget says it belongs to `other`. +#[test] +fn a_field_takes_the_page_widget_naming_it_when_its_kids_list_none() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 6 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 7 0 R 11 0 R 13 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 150 20 160] >> endobj +5 0 obj << /FT /Btn /Ff 49152 /T (radio) /Kids [5 0 R 7 0 R] >> endobj +6 0 obj << /Fields [5 0 R 8 0 R 9 0 R] >> endobj +7 0 obj << /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [10 120 20 130] >> endobj +8 0 obj << /FT /Btn /Ff 49152 /T (pair) /Kids [9 0 R 11 0 R] >> endobj +9 0 obj << /FT /Btn /Ff 49152 /T (other) /Kids [8 0 R] >> endobj +11 0 obj << /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 90 20 100] >> endobj +13 0 obj << /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [10 60 20 70] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let listed: Vec<_> = info.fields.iter().map(|f| (f.name.as_str(), f.page, f.rect.map(|r| r[1]))).collect(); + assert_eq!(listed, [("radio", Some(0), Some(120.0)), ("pair", Some(0), Some(90.0)), ("other", Some(0), Some(60.0))], "{:?}", info.fields); +} + +/// The password values (`/O`, `/U`, `/OE`, `/UE`, `/Perms`) of an AES-256 (R6) protection with a +/// user password, as a save writes them. +fn r6_password_values() -> String { + let plain = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let mut doc = pdfcraft_cos::Document::open(Arc::new(plain)).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "user pw", + owner_password: "owner pw", + permissions: -4, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + let saved = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let saved = pdfcraft_cos::Document::open_with_password(Arc::new(saved), Some("user pw")).unwrap(); + let encrypt = saved.resolve(saved.trailer().get(b"Encrypt").unwrap()); + let encrypt = encrypt.as_dict().unwrap(); + ["O", "U", "OE", "UE", "Perms"] + .iter() + .map(|k| { + let value = saved.resolve(encrypt.get(k.as_bytes()).unwrap()); + let hex: String = value.as_string().unwrap().bytes.iter().map(|b| format!("{b:02X}")).collect(); + format!("/{k} <{hex}>") + }) + .collect::>() + .join(" ") +} + +/// A page with text in the clear and an attachment, `note.txt`, encrypted with `/StdCF` (through +/// `/EFF`, or a `/Crypt` filter of its own when `named`). `/StdCF` is authorised by `auth_event`, +/// under the handler `version`. +fn efopen_pdf_with(auth_event: &str, named: bool, version: &str) -> Vec { + let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") }; + let encrypt = format!( + "<< /Filter /Standard {version} {} /P -4 /CF << /StdCF << /AuthEvent /{auth_event} /CFM /AESV3 /Length 32 >> >> /StmF /Identity /StrF /Identity{eff} >>", + r6_password_values() + ); + let page = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET"; + 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] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>".to_string(), + "<< /Type /Filespec /F (note.txt) /EF << /F 5 0 R >> >>".to_string(), + format!("<< /Type /EmbeddedFile{crypt} /Length 32 >>\nstream\n0123456789abcdef0123456789abcdef\nendstream"), + format!("<< /Length {} >>\nstream\n{page}\nendstream", page.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + encrypt, + ]; + 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{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size {} /Root 1 0 R /Encrypt {} 0 R /ID [<00> <00>] >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()) + .as_bytes(), + ); + out +} + +fn efopen_pdf(auth_event: &str) -> Vec { + efopen_pdf_with(auth_event, false, "/V 5 /R 6") +} + +/// ISO 32000-2 §7.6.6, Table 25: a crypt filter whose `/AuthEvent` is `/EFOpen` needs its password +/// only when an embedded file is opened. Such a document opens without one, its page reads, and +/// its attachment is listed; asking for the attachment's bytes is an error, not its encrypted data. +#[test] +fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() { + let bytes = Arc::new(efopen_pdf("EFOpen")); + assert!(check_pages(bytes.clone(), None, 1).is_ok()); + let info = inspect(bytes.clone(), None).expect("opens without a password"); + assert_eq!(info.pages.len(), 1); + let mut renderer = crate::PageRenderer::new(bytes.clone(), crate::RenderConfig::default()); + let text = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Text, ..Default::default() }); + assert!(text.text.is_some_and(|page| page.plain_text().contains("Visible page")), "{:?}", text.error); + let listed = |name: &str| info.attachments.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name} is listed")).clone(); + let refused = attachment_data(&bytes, None, &listed("note.txt")).expect_err("encrypted bytes aren't the file"); + assert!(refused.contains("password"), "{refused}"); + // Authorised when the document opens, the same filter still needs the password. + let doc_open = Arc::new(efopen_pdf("DocOpen")); + assert!(matches!(inspect(doc_open.clone(), None), Err(OpenError::NeedsPassword))); + assert!(matches!(check_pages(doc_open, None, 1), Err(OpenError::NeedsPassword))); +} + +/// From the review of the EFOpen change: an attachment naming its own EFOpen crypt filter, with no +/// `/EFF`, opens the same way; a handler older than crypt filters (V2) still needs the password. +#[test] +fn efopen_filters_named_by_the_file_open_and_older_handlers_do_not() { + let named = Arc::new(efopen_pdf_with("EFOpen", true, "/V 5 /R 6")); + let info = inspect(named.clone(), None).expect("opens without a password"); + let note = info.attachments.iter().find(|a| a.name == "note.txt").expect("listed").clone(); + assert!(attachment_data(&named, None, ¬e).is_err()); + let legacy = Arc::new(efopen_pdf_with("EFOpen", false, "/V 2 /R 3 /Length 128")); + assert!(matches!(check_pages(legacy, None, 1), Err(OpenError::NeedsPassword))); +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 9331c82ea7..333464779f 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -87,6 +87,10 @@ pub struct RenderConfig { /// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't /// be decoded. pub jbig2_work: Option, + /// Layer visibility overrides for print-preview rasters ([`PRINT_TAG`]), when the document + /// prints its layers differently from how they are shown (the Print event's auto state, ISO + /// 32000-2 §8.11.4.4): the preview shows the page as it prints. `None`: previews use `layers`. + pub print_layers: Option>>, } impl RenderConfig { @@ -266,6 +270,9 @@ pub enum RenderWarning { /// A colour could not be computed (a colour space that cannot be read, or a Separation or /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. ColourFallback, + /// A Type 3 font without the `/Widths` it requires (ISO 32000-2 Table 110) was used; its + /// glyphs were spaced by the widths their glyph procedures give. + Type3WidthsMissing, } /// Counters for work performed by a renderer or render pool. @@ -355,16 +362,20 @@ fn render_page<'a>( config: &RenderConfig, req: RenderRequest, ) -> Result { - // Print previews share the viewer pool, but must use the output font policy. Keep the - // caller's cancellation/warning hooks, layers and annotation settings. A fresh cache is - // essential: hayro keys cached fonts by PDF dictionary, not by the font resolver, so the - // viewer cache could supply an installed face even after changing the resolver (and a - // cached missing font from print preview could blank the viewer on switching back). + // Print previews share the viewer pool, but must use the output font policy and the layers + // as they print (`print_layers`). Keep the caller's cancellation/warning hooks and annotation + // settings. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the + // font resolver, so the viewer cache could supply an installed face even after changing the + // resolver (and a cached missing font from print preview could blank the viewer on switching + // back); nothing drawn with the viewer's layers is reused either. let print_settings; let print_cache; - let (settings, cache) = if config.system_fonts && req.tag & PRINT_TAG != 0 { + let print = req.tag & PRINT_TAG != 0; + let print_layers = config.print_layers.as_ref().filter(|_| print); + let (settings, cache) = if (config.system_fonts && print) || print_layers.is_some() { print_settings = InterpreterSettings { font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver, + ocg_overrides: print_layers.cloned().unwrap_or_else(|| settings.ocg_overrides.clone()), ..settings.clone() }; print_cache = RenderCache::new(); @@ -408,6 +419,7 @@ fn render_page<'a>( y_offset: t.y as f32, bg_color, color_replacement, + ..Default::default() } } None => { @@ -469,6 +481,7 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc RenderWarning::ContentTruncated, InterpreterWarning::ColorFallback => RenderWarning::ColourFallback, + InterpreterWarning::Type3WidthsMissing => RenderWarning::Type3WidthsMissing, InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return, }; let mut warnings = lock(&target); diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index be232d80eb..679e42cd5e 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3454,6 +3454,56 @@ trailer << /Root 1 0 R >> } } +/// PDFBOX-771: a fax image whose `/Rows` says more rows than its `/Height` (1,084,975,104 on a +/// 2,592-column scan) is decoded to its height, as the image is (ISO 32000-2 Table 87): the +/// decoder was asked for `/Rows` rows, which the size check refused, so every page drew blank. +/// Also through an `/Indexed` colour space, the filter's other output path. Samples past the +/// decoded data read as black there, so a second image striped black, white, black, white shows +/// that every row up to the last is decoded. +#[test] +fn group4_fax_with_rows_past_its_height_decodes_to_the_image_height() { + const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + // Each row in horizontal mode: black rows as white 0 + black 8, white rows as white 8 + + // black 0 (ITU-T T.6 §2.2.3.3, codes from Table 2/T.6), then EOFB. + const STRIPED_8X4: [u8; 12] = [0x26, 0xa2, 0x99, 0x86, 0xe4, 0xd4, 0x53, 0x30, 0xdc, 0x00, 0x40, 0x04]; + const BLACK: [u8; 4] = [0, 0, 0, 255]; + const WHITE: [u8; 4] = [255, 255, 255, 255]; + let images: [(&[u8], [[u8; 4]; 4]); 2] = [(&ALL_BLACK_8X4, [BLACK; 4]), (&STRIPED_8X4, [BLACK, WHITE, BLACK, WHITE])]; + for ((data, expected), space) in images.iter().flat_map(|i| ["/DeviceGray", "[/Indexed /DeviceRGB 1 <000000FFFFFF>]"].map(|s| (i, s))) { + for rows in ["/Rows 5", "/Rows 1000000000", "/Rows 4294967295"] { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} >> /Length {} >> stream +", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(data); + pdf.extend_from_slice( + b" +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + ); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error); + for (y, colour) in [17, 22, 27, 32].into_iter().zip(expected) { + assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], colour, "{space} {rows}: image row at y {y}"); + } + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: outside the image stays white"); + } + } +} + #[test] fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() { use hayro_syntax::object::{FromBytes, Object}; @@ -4073,6 +4123,23 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[ } } +/// A `JBIG2Decode` image whose data starts with a JBIG2 file header (ISO 14492 D.4) draws, in +/// either organization the header declares (hayro-jbig2 patch 17): sequential (flags bit 0 is 1, +/// D.1) and random access (bit 0 is 0, D.2: the segment headers, ending with an end of file +/// segment, then their data). A header with reserved flag bits set leaves the image out. +#[test] +fn a_jbig2_image_with_a_file_header_draws() { + const ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A]; + let page = black_jbig2_page(); + let header = |flags: u8| [&ID[..], &[flags], &1u32.to_be_bytes()].concat(); + let (page_header, page_data) = page.split_at(11); + let end_of_file = [0, 0, 0, 1, 51, 0, 1, 0, 0, 0, 0]; + assert!(jbig2_image_draws(&page), "without a header"); + assert!(jbig2_image_draws(&[&header(0x01)[..], &page].concat()), "sequential"); + assert!(jbig2_image_draws(&[&header(0x00)[..], page_header, &end_of_file, page_data].concat()), "random access"); + assert!(!jbig2_image_draws(&[&header(0x11)[..], &page].concat()), "reserved flag bits"); +} + /// The JBIG2 images of a page share one allowance, down to the step, and the next page starts with /// its own. The images differ by an extension segment: one image decoded again isn't decoded twice. #[test] @@ -5503,6 +5570,198 @@ fn a_font_whose_post_version_has_swapped_halves_is_still_used() { assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves"); } +/// A font dictionary whose `/Subtype` is missing or the empty name is read as a Type 1 font, +/// so its `/BaseFont` picks the face (vendored hayro-interpret patch 94). `/Subtype` is required +/// (ISO 32000-2 Table 109), but such a `/Courier-Bold` was drawn in the Helvetica fallback, +/// shifting its columns (PDFBOX-1988). +#[test] +fn a_font_without_a_subtype_is_drawn_by_its_base_font() { + let render = |subtype: &str, base_font: &str| { + let resources = format!("<< /Font << /F1 << /Type /Font {subtype} /BaseFont /{base_font} >> >> >>"); + render_first_page(image_cache_pdf("BT /F1 20 Tf 5 25 Td (iiiiii) Tj ET", &resources, &[])).rgba.to_vec() + }; + let courier = render("/Subtype /Type1", "Courier-Bold"); + assert_ne!(courier, render("/Subtype /Type1", "Helvetica"), "Courier Bold and Helvetica differ"); + assert_eq!(render("/Subtype /", "Courier-Bold"), courier, "an empty /Subtype"); + assert_eq!(render("", "Courier-Bold"), courier, "no /Subtype"); +} + +/// A page with no `/MediaBox`, its own or inherited, is US Letter (612 × 792 pt), as other +/// readers draw it and as the page model and inspector already sized it; it was A4 (vendored +/// hayro-syntax patch 44; ISO 32000-2 Table 31 makes the box required and gives no default; +/// PDFBOX-4001). A `/MediaBox` without area falls back to the same size. +#[test] +fn a_page_without_a_media_box_is_letter() { + let page_pdf = |media: &str| { + format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R {media} /Contents 4 0 R >> endobj +4 0 obj << /Length 25 >> stream +0 0 1 rg 10 10 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() + }; + for media in ["", "/MediaBox [0 0 0 0]"] { + let pdf = page_pdf(media); + let page = render_first_page(pdf.clone()); + assert_eq!((page.width, page.height), (612, 792), "rendered size, {media:?}"); + let info = crate::inspect(Arc::new(pdf), None).expect("inspect"); + assert_eq!((info.pages[0].width, info.pages[0].height), (612.0, 792.0), "inspected size, {media:?}"); + } + // The blue square sits at the bottom left of the Letter page, also when the box has no + // area: the page is placed and clipped by the same Letter region it is sized by. + for media in ["", "/MediaBox [0 0 0 0]"] { + let page = render_first_page(page_pdf(media)); + assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 792 - 30), [0, 0, 255], "{media:?}"); + } +} + +/// A page without a `/MediaBox` of its own inherits its `/Pages` parent's (ISO 32000-2 §7.7.3.4) +/// before any fallback: rendered and inspected at 300 × 400, not US Letter. +#[test] +fn a_page_inherits_its_parents_media_box_before_falling_back_to_letter() { + let pdf = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj +4 0 obj << /Length 25 >> stream +0 0 1 rg 10 10 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let page = render_first_page(pdf.clone()); + assert_eq!((page.width, page.height), (300, 400), "rendered size"); + assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 400 - 30), [0, 0, 255]); + let info = crate::inspect(Arc::new(pdf), None).expect("inspect"); + assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size"); +} + +/// The same through a `/Pages` node without a box: the page takes its grandparent's `/MediaBox`. +#[test] +fn a_page_inherits_its_grandparents_media_box() { + let pdf = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj +4 0 obj << /Length 25 >> stream +0 0 1 rg 10 10 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let page = render_first_page(pdf.clone()); + assert_eq!((page.width, page.height), (300, 400), "rendered size"); + let info = crate::inspect(Arc::new(pdf), None).expect("inspect"); + assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size"); +} + +/// The nearest ancestor's `/MediaBox` wins: an intermediate `/Pages` node's box over the root's, +/// and a box whose origin isn't 0 0 keeps the content where the box puts it. +#[test] +fn a_page_inherits_its_nearest_ancestors_media_box_with_its_origin() { + let pdf = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 612 792] >> endobj +5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /MediaBox [100 100 400 500] >> endobj +3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj +4 0 obj << /Length 27 >> stream +0 0 1 rg 110 110 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let page = render_first_page(pdf.clone()); + assert_eq!((page.width, page.height), (300, 400), "the intermediate box, not the root's"); + // The square sits 10 pt in from the box's lower-left corner, at (100, 100). + assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 400 - 30), [0, 0, 255], "placed by the box's origin"); + let info = crate::inspect(Arc::new(pdf), None).expect("inspect"); + assert_eq!((info.pages[0].width, info.pages[0].height), (300.0, 400.0), "inspected size"); +} + +/// A page's own `/MediaBox` wins over its parent's: rendered and inspected at the page's 200 × 100, the content placed by +/// the page box's origin. +#[test] +fn a_pages_own_media_box_wins_over_its_parents() { + let pdf = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [50 50 250 150] /Contents 4 0 R >> endobj +4 0 obj << /Length 25 >> stream +0 0 1 rg 60 60 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let page = render_first_page(pdf.clone()); + assert_eq!((page.width, page.height), (200, 100), "the page's box, not its parent's"); + // The square sits 10 pt in from the page box's lower-left corner, at (50, 50). + assert_eq!(rgb_at(&page.rgba, page.width as usize, 30, 100 - 30), [0, 0, 255], "placed by the page box's origin"); + let info = crate::inspect(Arc::new(pdf), None).expect("inspect"); + assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 100.0), "inspected size"); +} + +/// The nearest ancestor's `/MediaBox` is the inherited one even when it has no area: an intermediate `/Pages` node's +/// `[0 0 0 0]` gives US Letter, not the grandparent's 300 × 400 (ISO 32000-2 §7.7.3.4). +#[test] +fn a_nearest_media_box_without_area_gives_letter_not_a_further_ancestors() { + let pdf = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [5 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +5 0 obj << /Type /Pages /Parent 2 0 R /Kids [3 0 R] /Count 1 /MediaBox [0 0 0 0] >> endobj +3 0 obj << /Type /Page /Parent 5 0 R /Contents 4 0 R >> endobj +4 0 obj << /Length 25 >> stream +0 0 1 rg 10 10 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let page = render_first_page(pdf.clone()); + assert_eq!((page.width, page.height), (612, 792), "rendered size"); + let info = crate::inspect(Arc::new(pdf), None).expect("inspect"); + assert_eq!((info.pages[0].width, info.pages[0].height), (612.0, 792.0), "inspected size"); +} + +/// An embedded CMap whose codes are all two bytes long (codespace `<0000> `) and whose +/// mappings are written as `bfchar` reads `<2041>` as the one code 0x2041 (ISO 32000-2 +/// §9.7.6.2: the codespace ranges decide how many bytes form a code). It drew as two +/// glyphs: the lone first byte 0x20 matched the two-byte entry `<0020>` (PDFBOX-1282, +/// `Characters_Arial.pdf` page 3, codes 0x20AC to 0xFFFC). Vendored hayro-cmap patch 4. +#[test] +fn a_bfchar_cmap_reads_codes_of_its_codespace_length() { + let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let render = |encoding: &str, text: &str| -> Vec { + let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def +/CMapName /Test-H def /CMapType 1 def +1 begincodespacerange <0000> endcodespacerange +3 beginbfchar <0020> <0005> <0041> <0006> <2041> <0024> endbfchar +endcmap CMapName currentdict /CMap defineresource pop end end"; + let resources = format!( + "<< /Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /JetBrainsMono-Regular /Encoding {encoding} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /JetBrainsMono-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 600 /CIDToGIDMap /Identity /FontDescriptor << /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 4 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 5 0 R >> >>] >> >> >>" + ); + let content = format!("BT /F1 40 Tf 10 20 Td {text} Tj ET"); + let font_dict = format!("<< /Length {} >>", font.len()); + let cmap_dict = format!( + "<< /Type /CMap /CMapName /Test-H /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /Length {} >>", + cmap.len() + ); + let page = render_first_page(image_cache_pdf(&content, &resources, &[(&font_dict, font), (&cmap_dict, cmap)])); + page.rgba.to_vec() + }; + let blank = render("/Identity-H", "<>"); + let reference = render("/Identity-H", "<0024>"); + assert_ne!(reference, blank, "the reference glyph draws"); + assert_eq!(render("6 0 R", "<2041>"), reference, "<2041> is one code, CID 0x24"); + assert_ne!(render("/Identity-H", "<00050006>"), reference, "the two-glyph misreading looks different"); +} + /// A page whose content expands far beyond its compressed stream stops at the expansion budget, /// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. #[test] @@ -5796,6 +6055,92 @@ fn page_contents_and_nested_forms_share_one_budget() { assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } +/// A form XObject that draws itself (PDFBOX-5782, a fuzzed file whose form names a `/Pages` +/// dictionary as `/X0`, found the form again in the page's resources and spent 38 s drawing +/// itself until the page's nested-paint budget ran out) is drawn once: the drawing inside +/// itself is skipped and reported, as other readers draw it. Vendored patch: hayro-interpret +/// `Context::enter_form` (89). +#[test] +fn a_form_that_draws_itself_is_drawn_once() { + // Each form paints a 10 x 10 square and draws `/X0` 20 units to its right. + let draw = "0 0 1 rg 0 0 10 10 re f q 1 0 0 1 20 0 cm /X0 Do Q"; + // Two forms that draw each other: blue, then red, then blue again would follow. + let other = "1 0 0 rg 0 0 10 10 re f q 1 0 0 1 20 0 cm /X0 Do Q"; + let other = format!( + "6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] /Resources << /XObject << /X0 5 0 R >> >> /Length {} >> stream\n{other}\nendstream endobj\n", + other.len() + ); + let cases = [ + ("its own resources name it", "/Resources << /XObject << /X0 5 0 R >> >>", ""), + ("it names a /Pages dictionary and the page's resources name it", "/Resources << /XObject << /X0 2 0 R >> >>", ""), + ("it has no resources and the page's name it", "", ""), + ("two forms draw each other", "/Resources << /XObject << /X0 6 0 R >> >>", other.as_str()), + ]; + for (what, resources, extra) in cases { + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 40] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n4 0 obj << /Length 7 >> stream\n/X0 Do\nendstream endobj\n".to_vec(); + pdf.extend_from_slice( + format!( + "5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] {resources} /Length {} >> stream\n{draw}\nendstream endobj\n", + draw.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(extra.as_bytes()); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{what}: {:?}", page.error); + assert_eq!(page.width, 200, "{what}"); + assert_eq!(rgb_at(&page.rgba, 200, 5, 35), [0, 0, 255], "{what}: the form draws"); + let second = if extra.is_empty() { [255, 255, 255] } else { [255, 0, 0] }; + assert_eq!(rgb_at(&page.rgba, 200, 25, 35), second, "{what}: the form one level in"); + assert_eq!(rgb_at(&page.rgba, 200, 45, 35), [255, 255, 255], "{what}: the form is not drawn inside itself"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "{what}: the skipped drawing is reported"); + } +} + +/// A form that reaches itself through a tiling pattern it fills with, or a Type 3 glyph it +/// shows, is not drawn inside itself either: the pattern cell or the glyph procedure is +/// interpreted in a context of its own, which still knows the form is being drawn. The form +/// paints a blue square, then fills a square 20 units to its right with the pattern, or shows +/// the glyph there, whose content draws the form. Vendored patch: hayro-interpret +/// `Context::enter_form` (89). +#[test] +fn a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once() { + let cell = "q 1 0 0 1 20 0 cm /X0 Do Q"; + let pattern = format!( + "7 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 200 40] /XStep 200 /YStep 40 /Resources << /XObject << /X0 5 0 R >> >> /Length {} >> stream\n{cell}\nendstream endobj\n", + cell.len() + ); + let glyph = "0 0 d0 /X0 Do"; + let glyph = format!( + "7 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 200 40] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 8 0 R >> /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n8 0 obj << /Length {} >> stream\n{glyph}\nendstream endobj\n", + glyph.len() + ); + let cases = [ + ("a tiling pattern", "/Resources << /Pattern << /P0 7 0 R >> >>", "0 0 1 rg 0 0 10 10 re f /Pattern cs /P0 scn 20 0 10 10 re f", pattern), + ("a Type 3 glyph", "/Resources << /Font << /T3 7 0 R >> >>", "0 0 1 rg 0 0 10 10 re f BT /T3 1 Tf 20 0 Td (a) Tj ET", glyph), + ]; + for (what, resources, draw, extra) in cases { + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 40] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj\n4 0 obj << /Length 7 >> stream\n/X0 Do\nendstream endobj\n".to_vec(); + pdf.extend_from_slice( + format!( + "5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 40] {resources} /Length {} >> stream\n{draw}\nendstream endobj\n", + draw.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(extra.as_bytes()); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{what}: {:?}", page.error); + assert_eq!(rgb_at(&page.rgba, 200, 5, 35), [0, 0, 255], "{what}: the form draws"); + assert_eq!(rgb_at(&page.rgba, 200, 25, 35), [255, 255, 255], "{what}: the form is not drawn inside itself"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "{what}: the skipped drawing is reported"); + } +} + /// The page content budget counts what is held at once, not everything decoded: a form /// with 2 MiB of content painted three hundred times (600 MiB in all) draws every time. #[test] @@ -6156,6 +6501,94 @@ trailer << /Root 1 0 R >> assert!(p != [255, 255, 255, 255] && p[3] == 255, "the triangles are drawn: {p:?}"); } +/// A rainbow of tensor-product patches (two quarter rings per band) painted as a pattern +/// through a rectangle that is just the rainbow's box. Every patch is 722 triangles, and +/// across a narrow curved band they are thin and lie at every angle, so their boxes hold +/// many times their area: walking those boxes spent the visit budget (eight per pixel of the +/// rectangle, four per triangle) before the last patches, and the inner bands were left white +/// (through the page-sized rectangle, or with `sh`, the same mesh drew whole). The sampler +/// now walks only the columns of each row a triangle can reach. Vendored hayro-interpret +/// patch 99. +#[test] +fn thin_patches_of_a_mesh_in_a_tight_path_are_all_painted() { + const K: f64 = 0.5523; // a quarter circle's Bézier handles, as a fraction of the radius + const BANDS: [[u8; 3]; 6] = [[255, 0, 0], [255, 128, 0], [255, 255, 0], [0, 200, 0], [0, 0, 255], [128, 0, 255]]; + // The stream order of a tensor patch's points (column i, row j), ISO 32000-2 8.7.4.5.8. + const ORDER: [(usize, usize); 16] = + [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0), (1, 1), (1, 2), (2, 2), (2, 1)]; + // Bands 20/6 pt wide from radius 50 inwards, centred on (100, 100) by the pattern matrix; + // 16-bit coordinates decoded onto -64..64. + let width = 20.0 / BANDS.len() as f64; + let mut data = Vec::new(); + for (band, colour) in BANDS.iter().enumerate() { + let (outer, inner) = (50.0 - band as f64 * width, 50.0 - (band + 1) as f64 * width); + for side in [1.0, -1.0] { + data.push(0u8); + for (i, j) in ORDER { + let rho = inner + (outer - inner) * j as f64 / 3.0; + let (x, y) = [(side * rho, 0.0), (side * rho, K * rho), (side * K * rho, rho), (0.0, rho)][i]; + for v in [x, y] { + data.extend_from_slice(&(((v + 64.0) / 128.0 * 65535.0).round() as u16).to_be_bytes()); + } + } + for _ in 0..4 { + data.extend_from_slice(colour); + } + } + } + let render = |rect: &str| { + let content = format!("/Pattern cs /P0 scn {rect} re f"); + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Pattern << /P0 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 2 /Shading 5 0 R /Matrix [1 0 0 1 100 100] >> endobj +5 0 obj << /ShadingType 7 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [-64 64 -64 64 0 1 0 1 0 1] /Length {} >> stream +", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(&data); + pdf.extend_from_slice( + b" +endstream endobj +trailer << /Root 1 0 R >> +%%EOF +", + ); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (300, 300)); + page + }; + // At 45° on either side, the middle of the outer and of the inner band, in device pixels + // (scale 1.5, y down). + let at = |rho: f64, side: f64| { + let d = rho * std::f64::consts::FRAC_1_SQRT_2; + (((100.0 + side * d) * 1.5) as usize, ((100.0 - d) * 1.5) as usize) + }; + for (what, rect) in [("the rainbow's box", "50 100 100 50"), ("the page", "0 0 200 200")] { + let page = render(rect); + for (band, rho) in [(0, 50.0 - width / 2.0), (BANDS.len() - 1, 30.0 + width / 2.0)] { + for side in [1.0, -1.0] { + let (x, y) = at(rho, side); + let got = rgb_at(&page.rgba, 300, x, y); + let want = BANDS[band]; + assert!( + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 8), + "through {what}, band {band} at ({x}, {y}): got {got:?}, want {want:?}" + ); + } + } + } +} + /// Two triangles of a mesh that share an edge cover it together. The sampler tested each /// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share /// was left white wherever it ran through the samples, and so was a pixel row or column @@ -7252,8 +7685,8 @@ fn raw_crop_unit_floor_preserves_nearly_zero_fallback() { let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None); let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback"); let page = pdf.pages().first().expect("one page"); - // Existing A4 raw fallback remains raw A4, then scales by the page unit. - let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32); + // The raw fallback is US Letter (hayro-syntax patch 44), then scales by the page unit. + let expected = (612.0, 792.0); assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}"); assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32)); } @@ -7515,6 +7948,329 @@ trailer << /Root 1 0 R >> } } +/// A page of `/ImageMask` stencils painted with a tiling pattern: the 4 × 4 stencil inks a ring +/// (its centre 2 × 2 samples are `1`, unpainted under the default `/Decode [0 1]`), filled with +/// a red coloured pattern. +fn patterned_stencil_page(media: (u32, u32), content: &str) -> RenderedPage { + let tile = "1 0 0 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /Msk 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00606000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + media.0, + media.1, + content.len(), + tile.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + page +} + +/// A stencil mask filled with a pattern shows the pattern where the stencil inks and nothing +/// elsewhere: not in its unpainted samples, and not around it, also when it is drawn smaller +/// than its samples and lands on whole device pixels. +#[test] +fn a_patterned_stencil_mask_paints_only_where_it_inks() { + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[(((200 - y) * p.width + x) * 4) as usize..][..3].to_vec(); + let page = patterned_stencil_page((200, 200), "/Pattern cs /P scn q 40 0 0 40 20 20 cm /Msk Do Q q 3 0 0 3 100.4 100.4 cm /Msk Do Q"); + assert_eq!(px(&page, 25, 25), [255, 0, 0], "the ring is red"); + assert_eq!(px(&page, 40, 40), [255, 255, 255], "the stencil's unpainted centre stays white"); + for (x, y) in [(15, 40), (65, 40), (40, 15), (40, 65)] { + assert_eq!(px(&page, x, y), [255, 255, 255], "outside the stencil at ({x}, {y})"); + } + assert!((100..104).any(|x| px(&page, x, 101) != [255, 255, 255]), "the shrunk stencil is drawn"); + for edge in [97, 98, 106, 107] { + for along in 97..=107 { + assert_eq!(px(&page, edge, along), [255, 255, 255], "beside the shrunk stencil at ({edge}, {along})"); + assert_eq!(px(&page, along, edge), [255, 255, 255], "above or below the shrunk stencil at ({along}, {edge})"); + } + } +} + +/// A patterned stencil mask under a soft mask is drawn at the soft mask's opacity once, as a +/// rectangle filled with the pattern beside it is: the stencil's coverage, which cuts the pattern +/// to the stencil's shape, is not masked a second time. +#[test] +fn a_patterned_stencil_mask_under_a_soft_mask_is_masked_once() { + let content = "/S gs /Pattern cs /P scn q 40 0 0 40 20 20 cm /Msk Do Q 120 20 40 40 re f"; + let tile = "1 0 0 rg 0 0 10 10 re f"; + let group = "0.5 g 0 0 200 200 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Msk 5 0 R >> /Pattern << /P 6 0 R >> /ExtGState << /S << /SMask << /S /Luminosity /G 7 0 R >> >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00606000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceGray >> /Length {} >> stream +{group} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len(), + group.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |x: u32, y: u32| page.rgba[(((200 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + let (ring, rect) = (px(25, 25), px(140, 40)); + assert!(rect != [255, 255, 255] && rect != [255, 0, 0], "the rectangle is half masked: {rect:?}"); + assert!(ring.iter().zip(&rect).all(|(a, b)| a.abs_diff(*b) <= 2), "stencil {ring:?}, rectangle {rect:?}"); + assert_eq!(px(40, 40), [255, 255, 255], "the stencil's unpainted centre stays white"); +} + +/// A patterned stencil mask in a Type 3 glyph is shrunk by area averaging, as it is outside +/// one (#4800): the glyph's own image rules are for its images, not for the coverage that cuts +/// the pattern. Here a one-column stroke of an 8-column stencil, drawn 4 pixels wide, is a +/// half-covered red; nearest-neighbour would read columns 1, 3, 5 and 7 and miss it. +#[test] +fn a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes() { + let content = "BT /Pattern cs /P scn /T3 1 Tf 0 0 Td (a) Tj ET"; + let glyph = "400 0 0 0 400 100 d1 q 400 0 0 100 0 0 cm /Im0 Do Q"; + let tile = "1 0 0 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /Font << /T3 6 0 R >> /Pattern << /P 8 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream +DF> +endstream endobj +6 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 400 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /a 7 0 R >> /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [400] /Resources << /XObject << /Im0 5 0 R >> >> >> endobj +7 0 obj << /Length {} >> stream +{glyph} +endstream endobj +8 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len(), + tile.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (4, 1)); + let px = page.rgba.as_chunks::<4>().0.to_vec(); + let [r, g, b, _] = px.get(1).copied().unwrap_or_default(); + assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}"); + assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}"); +} + +/// pdf.js issue 8078 paints 73,047 small stencil masks with one tiling pattern. Each was drawn +/// through a canvas-sized luminance mask (a full-page render and composite per stencil), which +/// took minutes for the page; a stencil now costs what its rectangle covers. Here 5,000 +/// patterned stencils on a 1000 × 2000 page. What the page draws offscreen is counted (hayro +/// patch 30) rather than timed, so the check is the same on every machine: the one pattern cell, +/// and not a page-sized pixmap per stencil. +#[test] +fn many_patterned_stencil_masks_draw_no_page_sized_masks() { + let mut content = String::from("/Pattern cs /P scn "); + for i in 0..5000u32 { + content.push_str(&format!("q 16 0 0 16 {} {} cm /Msk Do Q ", (i % 50) * 20, (i / 50) * 20)); + } + let before = hayro::offscreen_draws(); + let page = patterned_stencil_page((1000, 2000), &content); + let after = hayro::offscreen_draws(); + assert_eq!(after.tiling_cells - before.tiling_cells, 1, "one cell for the pattern's one setting"); + let pixels = after.pixels - before.pixels; + assert!(pixels < 1000 * 2000, "5,000 patterned stencils drew {pixels} pixels offscreen"); + // The last stencil sits at (980, 1980): its ring is red, its centre white. + let px = |x: u32, y: u32| page.rgba[(((2000 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + assert_eq!(px(981, 1981), [255, 0, 0]); + assert_eq!(px(988, 1988), [255, 255, 255]); +} + +/// A coloured tiling pattern's cell is drawn once for all the fills of one setting (`scn`), as +/// counted by hayro patch 30: here 3,000 small squares filled with one pattern. Drawn again for +/// each fill, each cell was kept until the page was composited (pdf.js issue 8078 ran out of +/// memory that way, its 3.5 MB cell drawn for each of 73,047 stencils). The squares all show the +/// pattern in place. The same holds for a step so large that the fills show one cell, which is +/// drawn with a transparent border instead of repeated. +#[test] +fn fills_of_one_pattern_setting_share_its_cell() { + let mut content = String::from("/Pattern cs /P scn "); + for i in 0..3000u32 { + content.push_str(&format!("{} {} 4 4 re f ", (i % 60) * 5, (i / 60) * 5)); + } + // The cell is blue with a red lower-left quarter; at `/Matrix` scale 10 a cell is 100 units, + // so with a step of 10 red shows where both coordinates, modulo 100, are below 50. With a + // step of 100000 the one cell shows and nothing past it. + let cell = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f"; + for (step, past_the_first_cell) in [("10", [0, 0, 255]), ("100000", [255, 255, 255])] { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let before = hayro::offscreen_draws(); + let page = render_first_page(pdf.into_bytes()); + let cells = hayro::offscreen_draws().tiling_cells - before.tiling_cells; + assert_eq!(cells, 1, "step {step}: cells drawn for 3,000 fills of one setting"); + // Pixel rows run top down: PDF (x, y) is pixel (x, 300 - y). + let px = |x: u32, y: u32| page.rgba[(((300 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + // The first square, at (0, 0), and the last, at (295, 245). + assert_eq!(px(1, 1), [255, 0, 0], "step {step}"); + assert_eq!(px(296, 246), past_the_first_cell, "step {step}"); + } +} + +/// Fills interleaving the settings of two coloured patterns with cells of one size, and of one +/// of them at two cell scales, each show their own pattern in place: a cell kept for one setting +/// is not taken for another (hayro patch 30). `/P` is blue with a red lower-left quarter and `/Q` +/// green with a yellow one, both at `/Matrix` scale 10, so a cell is 100 units and its quarter +/// shows where both coordinates, modulo 100, are below 50. The form `/X`, drawn at scale 2, sets +/// `/P` in its own space, where a cell is 200 units, red where both page coordinates, modulo +/// 200, are below 100. Each of the six settings draws its cell once, for all its fills. +#[test] +fn fills_of_interleaved_pattern_settings_each_show_their_own_cell() { + let content = "/Pattern cs /P scn 60 60 4 4 re f 10 10 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \ + /Q scn 110 10 4 4 re f 160 160 4 4 re f /P scn 210 210 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \ + /Q scn 260 260 4 4 re f 110 110 4 4 re f"; + // In page space, squares at (60, 260) and (260, 60), where `/P` at scale 1 is blue. + let form = "/Pattern cs /P scn 30 130 2 2 re f 130 30 2 2 re f"; + let p = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f"; + let q = "0 1 0 rg 0 0 10 10 re f 1 1 0 rg 0 0 5 5 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R /Q 6 0 R >> /XObject << /X 7 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{p} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{q} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 150 150] /Resources << /Pattern << /P 5 0 R >> >> /Length {} >> stream +{form} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + p.len(), + q.len(), + form.len() + ); + let before = hayro::offscreen_draws(); + let page = render_first_page(pdf.into_bytes()); + assert_eq!(hayro::offscreen_draws().tiling_cells - before.tiling_cells, 6, "one cell per setting"); + // The middle of the 4 × 4 square at PDF (x, y); pixel rows run top down. + let px = |x: u32, y: u32| page.rgba[(((300 - 2 - y) * page.width + x + 2) * 4) as usize..][..3].to_vec(); + let (red, blue, green, yellow) = ([255, 0, 0], [0, 0, 255], [0, 255, 0], [255, 255, 0]); + for (x, y, colour, what) in [ + (60, 60, blue, "/P"), + (10, 10, red, "/P"), + (60, 260, red, "/P at scale 2"), + (260, 60, red, "/P at scale 2"), + (110, 10, yellow, "/Q"), + (160, 160, green, "/Q"), + (210, 210, red, "/P set again"), + (260, 260, green, "/Q set again"), + (110, 110, yellow, "/Q set again"), + ] { + assert_eq!(px(x, y), colour, "{what} at ({x}, {y})"); + } +} + +/// An uncoloured tiling pattern (`/PaintType 2`) takes the colour of each setting, so fills that +/// alternate red and green settings of it show both, each in place. Its cell also takes the +/// opacity the setting captured, which a patterned stencil mask's copy of the setting leaves out +/// (its transparency group applies it, hayro-interpret patch 46), so the cell drawn for one fill +/// of a setting is not the other's (hayro patch 30 keeps only coloured cells): a square and a +/// stencil painted with one half-opaque blue setting show the same half blue. +#[test] +fn an_uncoloured_pattern_shows_each_settings_colour() { + let content = "/C cs 1 0 0 /U scn 10 10 20 20 re f 0 1 0 /U scn 40 10 20 20 re f \ + 1 0 0 /U scn 70 10 20 20 re f 0 1 0 /U scn 100 10 20 20 re f \ + /H gs 0 0 1 /U scn 10 50 20 20 re f q 20 0 0 20 40 50 cm /Msk Do Q"; + let tile = "0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 100] /Contents 4 0 R /Resources << /ColorSpace << /C [/Pattern /DeviceRGB] >> /Pattern << /U 6 0 R >> /XObject << /Msk 5 0 R >> /ExtGState << /H << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00000000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len() + ); + let page = render_first_page(pdf.into_bytes()); + // Pixel rows run top down: PDF (x, y) is pixel (x, 100 - y). + let px = |x: u32, y: u32| page.rgba[(((100 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + for (x, colour) in [(20, [255, 0, 0]), (50, [0, 255, 0]), (80, [255, 0, 0]), (110, [0, 255, 0])] { + assert_eq!(px(x, 20), colour, "the square at x {x}"); + } + let (square, stencil) = (px(20, 60), px(50, 60)); + assert!(square[2] == 255 && square[0].abs_diff(128) <= 1 && square[1] == square[0], "half blue: {square:?}"); + assert!(square.iter().zip(&stencil).all(|(a, b)| a.abs_diff(*b) <= 2), "square {square:?}, stencil {stencil:?}"); +} + /// #2971: in a knockout transparency group (`/K true`) each element composites with the /// group's initial backdrop, not with the elements drawn before it in the group, over the /// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue @@ -8187,18 +8943,232 @@ fn decoded_images_serve_every_size_they_are_drawn_at() { let mut device = Draws::default(); interpret_page(page, &mut ctx, &mut device); assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels"); - cache.decoded_image_decodes() + // (image decodes, mask decodes) + let masks = cache.decoded_mask_decodes(); + (cache.decoded_image_decodes() - masks, masks) }; - assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size"); - assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); + assert_eq!(decodes("A"), (1, 0), "a Flate image decodes the same at every size"); + // The image decodes once for each of the five sizes, its JPX soft mask within it: only a mask + // averaged down (hayro-interpret patch 82) has a cache entry of its own. + assert_eq!(decodes("B"), (5, 0), "an image with a JPX soft mask decodes for the size it is drawn at"); +} + +/// A soft mask far larger than where its image is drawn is averaged down to that size and cached +/// there (vendored hayro-interpret patch 82): a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 +/// times on one slide (pdf.js `issue16263.pdf`), was decoded and drawn at full size each time and +/// the page took minutes. Drawn ten times at one size, the mask is now decoded once and handed +/// over at that size rounded up to a power of two; a mask no larger than twice its drawn size +/// keeps its own. +#[test] +fn a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + /// Asks for every raster image at the size it is drawn at, as the renderer does, and keeps + /// the size of each alpha channel it was handed. + #[derive(Default)] + struct Draws(Vec<(u32, u32)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.push(alpha.map_or((0, 0), |a| (a.width, a.height))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + // `side` × `side` mask samples, drawn ten times at 20 × 20 points (device pixels at 72 dpi). + let draws = |side: u32| { + let alpha = flate(&(0..side * side).map(|i| (i % 256) as u8).collect::>()); + let mask = format!( + "<< /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + alpha.len() + ); + let content = (0..10).map(|i| format!("q 20 0 0 20 {} 0 cm /A Do Q", i * 22)).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 230.0, 30.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) + }; + let (alphas, decodes) = draws(1000); + // Drawn 20 pixels square, averaged down to that rounded up to a power of two. + assert_eq!(alphas, vec![(32, 32); 10], "averaged down to the size it is drawn at"); + // One decode for the image and one for its mask (here no larger than the intermediate the + // averaged-down sizes are made from, so that is the mask itself), plus the averaged-down mask, + // all shared by the ten draws. + assert_eq!(decodes, 3, "decoded once"); + let (alphas, _) = draws(40); + assert_eq!(alphas, vec![(40, 40); 10], "no more than twice the drawn size: kept as it is"); +} + +/// The soft masks a small image is handed, drawn at each of `sizes` (width × height points at 72 +/// dpi): its `mask` (`width`, `height`, 8-bit samples) is larger than the 2 × 1 image, so it may +/// be averaged down (vendored hayro-interpret patch 82). Each mask is given as its size and +/// samples. +fn soft_masks_handed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)]) -> (Vec<(u32, u32, Vec)>, usize) { + soft_masks_handed_keyed(width, height, mask, sizes, ("Width", "Height")) +} + +/// `soft_masks_handed`, with the mask's dimensions under `keys` (`/W` and `/H`, say). +fn soft_masks_handed_keyed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)], keys: (&str, &str)) -> (Vec<(u32, u32, Vec)>, usize) { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + #[derive(Default)] + struct Draws(Vec<(u32, u32, Vec)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.extend(alpha.map(|a| (a.width, a.height, a.data))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + let alpha = flate(mask); + let mask_dict = format!( + "<< /Type /XObject /Subtype /Image /{} {width} /{} {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + keys.0, + keys.1, + alpha.len() + ); + let content = sizes.iter().map(|(w, h)| format!("q {w} 0 0 {h} 0 0 cm /A Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask_dict, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 400.0, 400.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) +} + +/// An averaged-down soft mask (vendored hayro-interpret patch 82): +/// - takes each source sample into the target sample its centre falls in: seven columns +/// `0 0 0 255 0 0 0`, drawn three wide and so averaged to four, give `0 0 127 0` (by left edges, +/// `0 127 0 0`); +/// - is never sized past the samples a truncated stream decodes: a mask that says it is 100 rows +/// but holds 10, drawn 20 high, is handed no rows that no sample covers; +/// - is cached per drawn size, rounded up to a power of two: the same image drawn 20 and then 200 +/// points wide is handed its mask at 32 and then at its own 100, not the first draw's 32 again, +/// and drawn 20 and 21 wide it is handed one mask of 32 both times, whether the mask's +/// dimensions are under `/Width` and `/Height` or `/W` and `/H`; +/// - is made, at every size, from one intermediate mask at most 4096 on a side, so a mask wider +/// than that, drawn at four sizes, is decoded in full once and not four times. +#[test] +fn an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size() { + let (handed, _) = soft_masks_handed(7, 1, &[0, 0, 0, 255, 0, 0, 0], &[(3, 1)]); + assert_eq!(handed, vec![(4, 1, vec![0, 0, 127, 0])], "centres, not left edges"); + let (handed, _) = soft_masks_handed(100, 100, &[255; 100 * 10], &[(20, 20)]); + // 32 wide (20 rounded up to a power of two), and 10 rows, the ones decoded, not 32: all opaque. + assert_eq!(handed, vec![(32, 10, vec![255; 32 * 10])], "no uncovered rows"); + let sizes = |draws: &[(u32, u32)]| -> Vec<(u32, u32)> { + soft_masks_handed(100, 100, &[255; 100 * 100], draws).0.iter().map(|(w, h, _)| (*w, *h)).collect() + }; + assert_eq!(sizes(&[(20, 20), (200, 200)]), vec![(32, 32), (100, 100)], "each draw at its own size"); + assert_eq!(sizes(&[(20, 20), (21, 21)]), vec![(32, 32), (32, 32)], "draws a pixel apart share one"); + // The same with the mask's dimensions under the abbreviated `/W` and `/H`, which an image is + // read with too. + let (handed, _) = soft_masks_handed_keyed(100, 100, &[255; 100 * 100], &[(20, 20), (200, 200)], ("W", "H")); + let abbreviated: Vec<(u32, u32)> = handed.iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(abbreviated, vec![(32, 32), (100, 100)], "each draw at its own size, under /W and /H"); + // Columns alternating 0 and 255, so every averaged-down sample is about half: within 8, as a + // sample covering 19 or 20 columns, say, holds 9 or 10 opaque ones. + let wide: Vec = (0..5000 * 2).map(|i| if i % 2 == 0 { 0 } else { 255 }).collect(); + let (handed, decodes) = soft_masks_handed(5000, 2, &wide, &[(20, 1), (40, 1), (80, 1), (160, 1)]); + let sizes: Vec<(u32, u32)> = handed.iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(sizes, vec![(32, 1), (64, 1), (128, 1), (256, 1)]); + assert!(handed.iter().all(|(_, _, data)| data.iter().all(|&a| a.abs_diff(127) <= 8)), "{handed:?}"); + // The image at each of the four sizes, the 4096-wide intermediate once, and the mask at each + // of the four sizes made from it. Without the intermediate there are 8, each mask size a full + // decode. + assert_eq!(decodes, 9, "the full mask decoded once"); } // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; +// A Type 3 glyph shown again decodes its procedure once (vendored hayro-interpret patch 87). +mod type3_proc_cache; + +// A picture that doesn't decode is decoded once however often it is drawn (vendored +// hayro-interpret patch 91). +mod failed_image_decodes; + +// A page past its rasterizer coverage leaves the rest out and says so (vendored vello_cpu patch 1). +mod coverage_budget; + // A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). mod symbolic_truetype_cmap; +// A TrueType font whose `head.indexToLocFormat` is out of range reads `loca` in the format its +// length gives (vendored hayro-interpret patch 83). +mod truetype_loca_format; + +// An Arial (#5889) or Times New Roman (#6449) the PDF doesn't embed draws the glyphs its codes +// name. +mod non_embedded_arial; + +// A Type 3 font without /Widths advances by its glyphs' d0/d1 widths (PDFBOX-2943). +mod type3_proc_widths; + +// A symbolic TrueType font in the standard Macintosh glyph order reads its codes as Mac OS Roman +// (vendored hayro-interpret patch 101). +mod symbolic_truetype_mac_roman_text; + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); @@ -8980,9 +9950,14 @@ mod default_colour_spaces; // (vendored hayro-syntax patch 28, hayro-interpret patch 55). mod devicen_inks; -// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35). +// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35), and +// PDFBOX-3932: rows that carry no filter-type byte are read as samples (patch 40). mod png_predicted_images; +// TIFF-predicted Flate images at 8 and 16 bits are undone byte by byte (vendored hayro-syntax +// patch 42). +mod tiff_predicted_images; + // #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro // patch 27). mod isolated_cmyk_groups; @@ -9080,6 +10055,183 @@ fn a_rebuilt_table_finds_an_object_whose_header_names_another_generation() { assert_eq!((page.width, page.height), (200, 100)); } +/// A one-page file: a 200 x 100 page, its offsets right. +fn one_page_file() -> Vec { + one_page_file_with(false) +} + +/// The same, and with `stale` a 999 x 999 copy of the page after it that the table does not +/// name: a file read through its table shows 200 x 100, one rebuilt from object headers 999 x 999. +fn one_page_file_with(stale: bool) -> Vec { + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for body in + ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >>"] + { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + if stale { + out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 999 999] >>\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n"); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +/// `base` with an incremental update appended: a new 300 x 150 first page whose content stream is +/// `padding` bytes long, and the page tree redefined to hold both pages. With `footer` the update +/// ends in its trailer, `startxref` and `%%EOF`; without, it stops after its cross-reference rows, +/// as a save cut short does. +fn with_update(base: &[u8], padding: usize, footer: bool) -> Vec { + let prev = base + .windows(9) + .rposition(|w| w == b"startxref") + .map(|p| String::from_utf8_lossy(&base[p + 9..]).split_whitespace().next().unwrap_or("").to_string()) + .unwrap(); + let mut out = base.to_vec(); + let mut offsets = vec![(4, out.len())]; + out.extend_from_slice(b"4 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Contents 5 0 R >>\nendobj\n"); + offsets.push((5, out.len())); + out.extend_from_slice(format!("5 0 obj\n<< /Length {padding} >>\nstream\n").as_bytes()); + out.extend(std::iter::repeat_n(b' ', padding)); + out.extend_from_slice(b"\nendstream\nendobj\n"); + offsets.push((2, out.len())); + out.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [4 0 R 3 0 R] /Count 2 >>\nendobj\n"); + let xref = out.len(); + out.extend_from_slice(b"xref\n0 1\n0000000000 65535 f \n"); + for (num, o) in offsets { + out.extend_from_slice(format!("{num} 1\n{o:010} 00000 n \n").as_bytes()); + } + if footer { + out.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + } + out +} + +/// What the renderer shows of `file` (page count and the first page's size) and what the +/// document model reads (the page tree's /Count), for comparing the two. +fn renderer_and_model_pages(file: Vec) -> ((usize, (u32, u32)), i64) { + let bytes = Arc::new(file); + let mut r = PageRenderer::new(bytes.clone(), RenderConfig::default()); + let count = r.page_count(); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + let doc = pdfcraft_cos::Document::open(bytes).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let model = pages.as_dict().and_then(|d| d.int(b"Count")).unwrap(); + ((count, (page.width, page.height)), model) +} + +/// PDFBOX-3934, PDFBOX-3787: the last incremental update was cut off after its cross-reference +/// rows, with no trailer, `startxref` or `%%EOF`, so the last `startxref` in the file is the +/// older revision's, more than the footer window (4 KiB) from the end, and objects follow it. +/// The update's objects are the latest revision (ISO 32000-2 §7.5.6); the document model rebuilds +/// the file from object headers and reads them. The renderer read the older table the last +/// `startxref` names instead and showed one page where the model has two. It now rebuilds too. +#[test] +fn an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them() { + let file = with_update(&one_page_file(), 5000, false); + assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's"); + let ((count, size), model) = renderer_and_model_pages(file); + assert_eq!(model, 2, "the model reads the update"); + assert_eq!(count, 2, "the renderer shows the update's page tree"); + assert_eq!(size, (300, 150), "and its new first page"); +} + +/// The same update with its footer is read through its table as before: renderer and model both +/// show its two pages. +#[test] +fn a_complete_update_reads_as_before() { + let ((count, size), model) = renderer_and_model_pages(with_update(&one_page_file(), 5000, true)); + assert_eq!((count, size, model), (2, (300, 150), 2)); +} + +/// A file with data after `%%EOF` that holds no object header (a web page glued to a served file) +/// is read through its table by the renderer, as before: the file has a stale copy of its page that +/// only a rebuild would show. The model, finding no `startxref` in its footer window, rebuilds from +/// object headers and takes that stale 999 x 999 copy, so here the two still differ, as they did +/// before this change. +#[test] +fn trailing_data_without_objects_reads_as_before() { + let mut junk = one_page_file_with(true); + junk.extend_from_slice(b"

1 0 objection: 12 0 endobj, 3 0obj, 4 0 obj_name, 4294967296 0 obj

"); + junk.extend(std::iter::repeat_n(b' ', 5000)); + junk.extend_from_slice(b"\n"); + assert_eq!(model_first_media_box(&junk), [0.0, 0.0, 999.0, 999.0], "the model's rebuilt page"); + let ((count, size), model) = renderer_and_model_pages(junk); + assert_eq!((count, size, model), (1, (200, 100), 1)); +} + +/// The /MediaBox of the first page in the document model's page tree (its first /Kids entry). +fn model_first_media_box(file: &[u8]) -> Vec { + let doc = pdfcraft_cos::Document::open(Arc::new(file.to_vec())).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let kid = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap().first().unwrap()); + kid.as_dict().unwrap().get(b"MediaBox").unwrap().as_array().unwrap().iter().filter_map(|x| x.as_f64()).collect() +} + +/// A cut-off update on `one_page_file` whose object headers are written `N G obj{after}<<` in place +/// of `N G obj\n<<`. Only the update is rewritten, so the base file's table stays at the offset +/// its `startxref` names and reading it raises no error that would rebuild the file anyway: the +/// update is found only if the header scan accepts what follows `obj`. +fn cut_off_update_with_headers(after: &str) -> Vec { + let base = one_page_file(); + let mut file = with_update(&base, 5000, false); + let update = String::from_utf8(file.split_off(base.len())).unwrap(); + assert_eq!(update.matches(" obj\n<<").count(), 3, "every updated header is rewritten"); + file.extend_from_slice(update.replace(" obj\n<<", &format!(" obj{after}<<")).as_bytes()); + file +} + +/// A cut-off update whose object headers run straight into their dictionaries (`4 0 obj<<`) is +/// still found: a delimiter ends the keyword (ISO 32000-2 §7.2.3, Table 2), and the renderer +/// shows the update's new first page, as the model does. +#[test] +fn a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt() { + let file = cut_off_update_with_headers(""); + assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's"); + let ((count, size), model) = renderer_and_model_pages(file); + assert_eq!((count, size, model), (2, (300, 150), 2)); +} + +/// The same with a comment straight after the keyword (`4 0 obj%comment`): `%` is a delimiter +/// that starts a comment (ISO 32000-2 §7.2.3 Table 2, §7.2.4), so it ends the keyword too. +#[test] +fn a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt() { + let file = cut_off_update_with_headers("%comment\n"); + assert_eq!(model_first_media_box(&file), [0.0, 0.0, 300.0, 150.0], "the model's first page is the update's"); + let ((count, size), model) = renderer_and_model_pages(file); + assert_eq!((count, size, model), (2, (300, 150), 2)); +} + +/// A cut-off update that ends within the footer window leaves the older `startxref` near the end, +/// which the model reads too: both keep the older revision there. +#[test] +fn a_short_cut_off_update_reads_as_before() { + let ((count, size), model) = renderer_and_model_pages(with_update(&one_page_file(), 100, false)); + assert_eq!((count, size, model), (1, (200, 100), 1)); +} + +/// A file cut short in the middle of its last update, whose new page tree names a page that is +/// not there: the rebuilt table finds no page, so the older table is read and the file still +/// opens with the page it had, rather than not at all. +#[test] +fn an_update_cut_off_before_its_new_pages_keeps_the_older_table() { + let file = String::from_utf8(with_update(&one_page_file(), 5000, false)).unwrap().replace("4 0 obj", "9 0 obj").into_bytes(); + let mut r = PageRenderer::new(Arc::new(file), RenderConfig::default()); + assert_eq!(r.page_count(), 1); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (200, 100)); +} + /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting /// but draws no pixels itself. #[test] @@ -9622,3 +10774,170 @@ fn a_stream_with_a_crypt_filter_of_its_own_is_drawn() { } } } + +/// Data after `%%EOF` that holds an object header, past the footer window, is read the way the +/// document model reads it: the model rebuilds the file from object headers and takes the later +/// definition of the page, and the renderer shows that same page, so the two still agree. +#[test] +fn trailing_data_with_an_object_header_reads_as_the_model_reads_it() { + let mut f = one_page_file(); + f.extend_from_slice( + b"
+3 0 obj
+<< /Type /Page /Parent 2 0 R /MediaBox [0 0 999 999] >>
+endobj
+
", + ); + f.extend(std::iter::repeat_n(b' ', 5000)); + let doc = pdfcraft_cos::Document::open(Arc::new(f.clone())).unwrap(); + let page = doc.get(pdfcraft_cos::ObjRef::new(3, 0)); + let model_box = page.as_dict().and_then(|d| d.get(b"MediaBox").cloned()); + assert_eq!(model_box, Some(pdfcraft_cos::Object::Array([0, 0, 999, 999].map(pdfcraft_cos::Object::Int).to_vec())), "the model"); + let ((count, size), model) = renderer_and_model_pages(f); + assert_eq!((count, size, model), (1, (999, 999), 1), "the renderer, as the model"); +} + +/// An encrypted file whose last update was cut off is still refused with a wrong password, and +/// opened with the right one. +#[test] +fn an_encrypted_file_with_a_cut_off_update_still_needs_its_password() { + use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full}; + let mut doc = Document::open(Arc::new(one_page_file())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let base = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let file = Arc::new(with_update(&base, 5000, false)); + let open = |password: &str| { + let mut r = PageRenderer::new(file.clone(), RenderConfig { password: Some(Arc::from(password)), ..RenderConfig::default() }); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + (r.page_count(), page.error.is_none(), (page.width, page.height)) + }; + let (_, drawn, _) = open("wrong"); + assert!(!drawn, "a wrong password opens nothing"); + let (count, drawn, _) = open("user"); + assert!(count >= 1 && drawn, "the right one opens it"); +} + +/// An encrypted file whose cut-off update ends in a trailer dictionary without `/Encrypt`: the rebuild would take that +/// later dictionary and read the encrypted content as plain bytes, so it is not taken. A wrong password is still +/// refused, and the right one opens the table the last `startxref` names, its content decrypted and drawn. +#[test] +fn a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken() { + use pdfcraft_cos::{Algorithm, Document, NewEncryption, SaveOptions, write_full}; + let plain = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R >> endobj +4 0 obj << /Length 25 >> stream +0 0 1 rg 10 10 50 50 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF" + .to_vec(); + let mut doc = Document::open(Arc::new(plain)).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let root = doc.root().unwrap(); + let mut file = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + // The update: one object, past the 4 KiB footer window, and a trailer without `/Encrypt`, cut off before its + // `startxref`. + file.extend_from_slice(b"900 0 obj\n<< /Length 5000 >>\nstream\n"); + file.extend(std::iter::repeat_n(b' ', 5000)); + file.extend_from_slice(format!("\nendstream\nendobj\ntrailer\n<< /Size 901 /Root {} {} R >>\n", root.num, root.generation).as_bytes()); + let file = Arc::new(file); + let open = |password: &str| { + let mut r = PageRenderer::new(file.clone(), RenderConfig { password: Some(Arc::from(password)), ..RenderConfig::default() }); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + page.error.is_none().then(|| rgb_at(&page.rgba, page.width as usize, 30, page.height as usize - 30)) + }; + assert_eq!(open("wrong"), None, "a wrong password opens nothing"); + assert_eq!(open(""), None, "nor does none"); + assert_eq!(open("user"), Some([0, 0, 255]), "the right one draws the decrypted content"); +} + +/// A stream with no `endstream` after it, as a producer that writes `endstrea` after every stream +/// leaves it, 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). Nothing found its end, so the page's content was lost and +/// the page drew blank, while other readers draw it. Vendored hayro-syntax patch 36. +#[test] +fn a_content_stream_without_endstream_is_drawn_to_its_length() { + use super::*; + let content = "1 0 0 rg 0 0 200 200 re f"; + let centre = |end: &str| -> [u8; 4] { + let page = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\n{end}\nendobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + assert_eq!(centre("endstream"), [255, 0, 0, 255]); + for end in ["endstrea", "", "end"] { + assert_eq!(centre(end), [255, 0, 0, 255], "{end:?}"); + } +} + +/// A ToUnicode CMap with a token the scanner can't read before its mappings (a stray `>` after +/// a CMap name that holds one, as in pdf.js `issue11651.pdf`; a CMap name with spaces in it, as +/// in `issue7492.pdf`) still maps its codes (vendored hayro-cmap patch 3, hayro-postscript +/// patch 2): the scan stopped at that token, so the page's text could not be copied or found. +#[test] +fn a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse() { + use hayro::hayro_interpret::hayro_cmap::{BfString, CMap}; + let body = "1 begincodespacerange\n<0000> \nendcodespacerange\n2 beginbfchar\n<002B> <0048>\n<0048> <0065>\nendbfchar\n1 beginbfrange\n<0050> <0052> <0061>\nendbfrange\nendcmap\n"; + let stray = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap\n/CIDSystemInfo <) /Supplement 0>> def\n/CMapName /Adobe-Identity-UC> def\n/CMapType 2 def\n{body}" + ); + let spaced = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap\n/CMapName /-usr-share-fonts-Panton Family-Fontfabric - Panton.otf,000-UTF16 def\n/CMapType 2 def\n{body}" + ); + for (what, data) in [("a stray >", stray), ("a name with spaces", spaced)] { + let cmap = CMap::parse(data.as_bytes(), |_| None).unwrap_or_else(|| panic!("{what}: parsed")); + assert_eq!(cmap.lookup_bf_string(0x2B), Some(BfString::Char('H')), "{what}"); + assert_eq!(cmap.lookup_bf_string(0x48), Some(BfString::Char('e')), "{what}"); + assert_eq!(cmap.lookup_bf_string(0x51), Some(BfString::Char('b')), "{what}: bfrange"); + } + // Unreadable tokens are stepped over a bounded number of times (1,024 per CMap): a long run + // of them ends the scan, and the mappings read before it are kept. + let flood = format!("{body}{}", "-".repeat(1 << 16)); + assert!(CMap::parse(flood.as_bytes(), |_| None).is_some_and(|c| c.lookup_bf_string(0x2B).is_some())); + // A run past the limit before the mappings ends the scan before them, and a CMap that read + // no mappings, like one of unreadable tokens only, reports failure. (A run of `-` with + // nothing between them is one token, as in PostScript, so these are spaced.) + let flood_first = format!("{}\n{body}", "- ".repeat(1025)); + assert!(CMap::parse(flood_first.as_bytes(), |_| None).is_none()); + assert!(CMap::parse("-".repeat(1 << 16).as_bytes(), |_| None).is_none()); + // Unclosed strings after the mappings: the scan ends and keeps them. (That the first one, + // which reads to the end of the data, is not read again for each `(` inside it is + // hayro-postscript's `parse_object_or_skip_does_not_read_a_failed_read_again`.) + let unclosed = format!("{body}{}{}", "(".repeat(4096), "x".repeat(1 << 16)); + assert!(CMap::parse(unclosed.as_bytes(), |_| None).is_some_and(|c| c.lookup_bf_string(0x48).is_some())); +} diff --git a/crates/render/src/raster/tests/coverage_budget.rs b/crates/render/src/raster/tests/coverage_budget.rs new file mode 100644 index 0000000000..00aa2d6b50 --- /dev/null +++ b/crates/render/src/raster/tests/coverage_budget.rs @@ -0,0 +1,183 @@ +//! A page that paints the same content over and over runs out of rasterizer coverage: what is +//! past it is left out and said to be, rather than panicking once a strip's alpha index no longer +//! fits (PDFBOX-5415's map page drew one form 2^22 times and panicked at 7 GB). + +use super::*; +use std::sync::Mutex; + +/// A blue circle, filled. +const FILLED_CIRCLE: &str = "0 0 1 rg 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c f"; + +/// A page whose form `F0` draws `F1` twice, `F1` draws `F2` twice, and so on `levels` deep; the +/// last form fills a circle. The page draws `F0` once, so the circle is filled 2^`levels` times. +fn doubling_forms(levels: usize) -> Vec { + doubling_forms_then(levels, FILLED_CIRCLE, "") +} + +/// [`doubling_forms`] with the last form painting `leaf` instead, and the page painting `after` +/// once the forms are done. +fn doubling_forms_then(levels: usize, leaf: &str, after: &str) -> Vec { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + // Object 4 is the page's content; forms start at 5. + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /F0 5 0 R >> >> >>".into(), + String::new(), + ]; + let content = format!("/F0 Do {after}"); + objs[3] = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + for level in 0..=levels { + let (body, resources) = if level == levels { + (leaf.to_string(), String::new()) + } else { + ("/F Do /F Do".to_string(), format!("/Resources << /XObject << /F {} 0 R >> >>", 6 + level)) + }; + objs.push(format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] {resources} /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + 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{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Renders page 1 with the rasterizer kept to `max_coverage` values, returning its pixels and +/// whether it said content was left out. +fn render_kept_to(pdf: Vec, max_coverage: Option) -> (Vec, bool) { + let parsed = Pdf::new(Arc::new(pdf)).unwrap(); + let truncated = Arc::new(Mutex::new(false)); + let seen = truncated.clone(); + let settings = InterpreterSettings { + warning_sink: Arc::new(move |w| { + if matches!(w, InterpreterWarning::ContentTruncated) { + *seen.lock().unwrap() = true; + } + }), + ..Default::default() + }; + let pixmap = + hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, max_coverage, ..Default::default() }); + let cut = *truncated.lock().unwrap(); + (pixmap.data_as_u8_slice().to_vec(), cut) +} + +#[test] +fn a_page_past_its_coverage_leaves_the_rest_out_and_says_so() { + // 2^10 circles, each storing its edge tiles' coverage. + let pdf = doubling_forms(10); + let (whole, whole_cut) = render_kept_to(pdf.clone(), None); + assert!(!whole_cut, "within the limit nothing is left out"); + let blue = |px: &[u8]| px.as_chunks::<4>().0.iter().filter(|p| p[2] > 200 && p[0] < 50).count(); + assert!(blue(&whole) > 10_000, "the circle is drawn"); + + // Room for a few circles only: the rest are left out, the page still renders, and says so. + let (kept, cut) = render_kept_to(pdf, Some(200_000)); + assert!(cut, "the page says content was left out"); + assert_eq!(kept.len(), whole.len()); + assert!(blue(&kept) > 10_000, "the circles drawn before the limit stay: {}", blue(&kept)); +} + +/// Pixels of `px` that are mostly red. +fn red(px: &[u8]) -> usize { + px.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[2] < 50).count() +} + +/// The page paints `leaf` 2^10 times through forms and then `marker` (red, away from the leaf's +/// drawing) once, both through the same rasterizer path. Kept to 200,000 coverage values, the +/// marker is past the limit: it is left out and the page says so. Unlimited, it is drawn. +fn leaves_out_past_the_limit(leaf: &str, marker: &str) { + let pdf = doubling_forms_then(10, leaf, marker); + let (whole, whole_cut) = render_kept_to(pdf.clone(), None); + assert!(!whole_cut, "within the limit nothing is left out"); + assert!(red(&whole) > 50, "unlimited, the marker is drawn: {}", red(&whole)); + + let (kept, cut) = render_kept_to(pdf, Some(200_000)); + assert!(cut, "the page says content was left out"); + assert_eq!(kept.len(), whole.len()); + assert_eq!(red(&kept), 0, "the marker, past the limit, is left out"); +} + +#[test] +fn strokes_past_the_coverage_are_left_out() { + // A stroked circle and a stroked curve: neither is a rectangle, so both go through + // `stroke_path`, and the page's only coverage is strokes. + leaves_out_past_the_limit( + "0 0 1 RG 4 w 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c S", + "1 0 0 RG 6 w 170 10 m 180 30 190 10 195 30 c S", + ); +} + +#[test] +fn fast_rectangles_past_the_coverage_are_left_out() { + // Axis-aligned rectangles fill through `fill_rect_fast`, and are the page's only coverage. + leaves_out_past_the_limit("0 0 1 rg 40.5 40.5 119 119 re f", "1 0 0 rg 170.5 10.5 20 20 re f"); +} + +#[test] +fn layer_clips_past_the_coverage_are_left_out() { + // hayro pushes no clipped layers (its clips go through `push_clip_path`), so the layer clip + // is driven through the vello_cpu context directly, with clips as the scene's only coverage. + use hayro::vello_cpu::color::palette::css::RED; + use hayro::vello_cpu::kurbo::{Circle, Rect, Shape}; + use hayro::vello_cpu::{Pixmap, RenderContext, Resources}; + + let clip = Circle::new((50.0, 50.0), 40.0).to_path(0.1); + let mut ctx = RenderContext::new(100, 100); + // Room while fewer than 30,000 - 2 x 100 x 100 = 10,000 values are stored. + ctx.set_max_coverage(30_000); + assert!(!ctx.coverage_full(), "an empty scene has room"); + + // Below the limit the clip is kept: a fill inside it shows within the circle only. + ctx.push_clip_layer(&clip); + assert!(!ctx.coverage_full(), "the first clip fits"); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(0.0, 0.0, 100.0, 100.0)); + ctx.pop_layer(); + assert!(!ctx.coverage_full(), "the fill inside it fits too"); + let mut pixmap = Pixmap::new(100, 100); + ctx.flush(); + ctx.render_to_pixmap(&mut Resources::new(), &mut pixmap); + let px = |x: usize, y: usize| pixmap.data_as_u8_slice().get((y * 100 + x) * 4..(y * 100 + x) * 4 + 4).map(<[u8]>::to_vec); + assert_eq!(px(50, 50), Some(vec![255, 0, 0, 255]), "inside the clip"); + assert_eq!(px(2, 2), Some(vec![0, 0, 0, 0]), "outside the clip"); + + ctx.reset(); + for _ in 0..1_000 { + ctx.push_clip_layer(&clip); + ctx.pop_layer(); + } + assert!(ctx.coverage_full(), "the clips past the limit were left out, and that is said"); + // The scene still rasterizes: the layers left unclipped have nothing drawn into them. + ctx.flush(); + ctx.render_to_pixmap(&mut Resources::new(), &mut pixmap); + + ctx.reset(); + assert!(!ctx.coverage_full(), "a reset scene starts with room"); +} + +/// Past the limit the clip itself is refused, not only what is drawn into its layer: with room +/// for exactly one path, a fill takes it and the clip pushed next is what finds the scene full. +#[test] +fn a_layer_clip_past_the_coverage_is_itself_left_out() { + use hayro::vello_cpu::RenderContext; + use hayro::vello_cpu::color::palette::css::RED; + use hayro::vello_cpu::kurbo::{Circle, Rect, Shape}; + + let mut ctx = RenderContext::new(100, 100); + // Room while fewer than 20,001 - 2 x 100 x 100 = 1 value is stored: for one path. + ctx.set_max_coverage(20_001); + ctx.set_paint(RED); + ctx.fill_path(&Rect::new(10.5, 10.5, 30.5, 30.5).to_path(0.1)); + assert!(!ctx.coverage_full(), "the fill fits"); + ctx.push_clip_layer(&Circle::new((50.0, 50.0), 40.0).to_path(0.1)); + assert!(ctx.coverage_full(), "the clip, past the limit, is left out and that is said"); + ctx.pop_layer(); +} diff --git a/crates/render/src/raster/tests/failed_image_decodes.rs b/crates/render/src/raster/tests/failed_image_decodes.rs new file mode 100644 index 0000000000..bd0329ed31 --- /dev/null +++ b/crates/render/src/raster/tests/failed_image_decodes.rs @@ -0,0 +1,239 @@ +//! A picture that doesn't decode is decoded once however often a page draws it (vendored +//! hayro-interpret patch 91). The decoded-image cache kept only decodes that succeeded, so a +//! damaged picture, or a JPX or JBIG2 stream its decoder refuses, was decoded again at every +//! draw: a page drawing it a thousand times decoded it a thousand times. The failure is kept for +//! the same key now, and every draw still reports it as it did. + +use super::image_cache_pdf; +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, TransformExt, interpret_page, +}; +use kurbo::{Affine, BezPath, Rect}; +use std::io::Write; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +/// What a page handed its device: the raster images with their pixels, those of them with a +/// mask, and the stencil masks with theirs. Every image is asked for at the size it is drawn +/// at, as the renderer does. +#[derive(Default)] +struct Draws { + rasters: usize, + masked: usize, + stencils: usize, +} + +impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + match image { + Image::Raster(r) => r.with_rgba( + |_, alpha| { + self.rasters += 1; + self.masked += usize::from(alpha.is_some()); + }, + Some(target), + ), + Image::Stencil(s) => s.with_stencil(|_, _| self.stencils += 1, Some(target)), + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +/// What drawing `/name` five times at one size, and then the picture that decodes (`/G`) once +/// on the right, did: the decodes the cache ran, the image decode failures reported, and what +/// the device was handed. +struct Outcome { + decodes: usize, + failures: usize, + draws: Draws, +} + +fn draw_five_times(name: &str) -> Outcome { + draw_five_times_at(name, [20; 5]) +} + +/// [`draw_five_times`], drawing `/name` the five times at `sizes` (units square). +fn draw_five_times_at(name: &str, sizes: [u32; 5]) -> Outcome { + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::>()); + let image = |filter: &str, extra: &str, len: usize| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /{filter} {extra}/Length {len} >>" + ) + }; + let good = image("FlateDecode", "", pixels.len()); + // A JPEG 2000 picture whose codestream is four zero bytes. + let bad_jpx = image("JPXDecode", "", 4); + // A picture whose soft mask is such a JPEG 2000 stream. + let bad_mask = image("FlateDecode", "/SMask 9 0 R ", pixels.len()); + // A stencil mask whose JBIG2 data is four zero bytes. + let bad_stencil = "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ImageMask true /Filter /JBIG2Decode /Length 4 >>"; + let jpx_mask = + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>"; + let content = [0, 24, 48, 72, 96].iter().zip(sizes).map(|(x, s)| format!("q {s} 0 0 {s} {x} 10 cm /{name} Do Q")).collect::>().join(" ") + + " q 10 0 0 10 118 10 cm /G Do Q"; + let bytes = image_cache_pdf( + &content, + "<< /XObject << /G 5 0 R /J 6 0 R /S 7 0 R /M 8 0 R >> >>", + &[(&good, &pixels), (&bad_jpx, b"\0\0\0\0"), (&bad_mask, &pixels), (bad_stencil, b"\0\0\0\0"), (jpx_mask, b"\0\0\0\0")], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let failures = Arc::new(AtomicUsize::new(0)); + let others = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let (failures, others) = (failures.clone(), others.clone()); + Arc::new(move |warning| match warning { + InterpreterWarning::ImageDecodeFailure => { + failures.fetch_add(1, Ordering::Relaxed); + } + other => others.lock().unwrap().push(format!("{other:?}")), + }) + }, + ..InterpreterSettings::default() + }; + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), settings); + let mut draws = Draws::default(); + interpret_page(page, &mut ctx, &mut draws); + assert!(others.lock().unwrap().is_empty(), "/{name} reported {:?}", others.lock().unwrap()); + Outcome { decodes: cache.decoded_image_decodes(), failures: failures.load(Ordering::Relaxed), draws } +} + +/// A JPEG 2000 picture that doesn't decode, drawn five times at one size, is decoded once, and +/// each of the five draws still reports the failure. +#[test] +fn a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn() { + let out = draw_five_times("J"); + assert_eq!(out.draws.rasters, 1, "only the picture after it drew"); + assert_eq!(out.decodes, 2, "decoded again at every draw"); + assert_eq!(out.failures, 5, "every draw reports the failure, as before"); +} + +/// A JPEG 2000 picture decodes at a reduced resolution for the size it is drawn at, so its +/// failure is kept for that size: drawn at two sizes, it is decoded once at each, also when it +/// comes back to the first size after the second. +#[test] +fn a_picture_that_does_not_decode_is_decoded_once_at_each_size() { + let out = draw_five_times_at("J", [20, 20, 10, 10, 20]); + assert_eq!(out.decodes, 3, "once at each of its two sizes, and the picture after it"); + assert_eq!(out.failures, 5, "every draw reports the failure"); +} + +/// A JBIG2 stencil mask that doesn't decode, drawn five times, is decoded once, and each draw +/// still reports the failure: it isn't the page's JBIG2 allowance that refused it, which a +/// later page would have afresh. +#[test] +fn a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn() { + let out = draw_five_times("M"); + assert_eq!((out.draws.stencils, out.draws.rasters), (0, 1), "only the picture after it drew"); + assert_eq!(out.decodes, 2, "decoded again at every draw"); + assert_eq!(out.failures, 5, "every draw reports the failure, as before"); +} + +/// A picture whose soft mask doesn't decode draws without it at every draw, decoded once, and +/// the mask's failure is reported once, as it was (the picture decodes; only its mask fails). +#[test] +fn a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once() { + let out = draw_five_times("S"); + assert_eq!((out.draws.rasters, out.draws.masked), (6, 0), "drawn five times without its mask, and the picture after it"); + assert_eq!(out.decodes, 2); + assert_eq!(out.failures, 1); +} + +/// A picture that decodes is still decoded once and handed to every draw. +#[test] +fn a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time() { + let out = draw_five_times("G"); + assert_eq!(out.draws.rasters, 6); + assert_eq!(out.decodes, 1); + assert_eq!(out.failures, 0); +} + +/// A JBIG2 picture the page's allowance left out isn't kept as a failure: the next page drawing +/// it starts with an allowance of its own and draws it, from the same cache. +#[test] +fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() { + use super::{RenderWarning, black_jbig2_page, jbig2_segment}; + use crate::raster::{parse, render_page, take_warnings, warning_settings}; + use crate::{RenderConfig, RenderRequest, RequestKind}; + use hayro::RenderCache; + + let image = |number: u8, id: u8| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 62, &[], &[0, 0, 0, 0, id]); + let mut object = format!( + "{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + stream.len() + ) + .into_bytes(); + object.extend_from_slice(&stream); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let page = |number: u8, content: u8| { + format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << /A 7 0 R /B 8 0 R >> >> >> endobj\n").into_bytes() + }; + let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes(); + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec(); + pdf.extend(page(3, 5)); + pdf.extend(page(4, 6)); + // The first page spends its allowance on A and has nothing left for B; the second draws A, + // decoded already, and then B. + pdf.extend(content(5, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(content(6, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(image(7, 1)); + pdf.extend(image(8, 2)); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = parse(&Arc::new(pdf), None).expect("parses"); + /// A page's pixels and the warnings it reported. + type Drawn = (Vec, Vec); + let black = |(rgba, _): &Drawn, x: usize| match &rgba[((20 * 40 + x) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither an image nor the page: {other:?}"), + }; + // Both pages, or the first alone, through one cache, as a render worker keeps it. + let pages = |work: u64, count: usize| { + let config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() }; + let cache = RenderCache::new(); + (0..count) + .map(|page| { + let warnings = Arc::new(Mutex::new(Vec::new())); + let settings = warning_settings(&config.settings(), warnings.clone()); + let req = RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }; + let (_, _, rgba, _) = render_page(&pdf, &cache, &settings, &config, req).expect("renders"); + (rgba[..].to_vec(), take_warnings(&warnings)) + }) + .collect::>() + }; + // The smallest allowance that draws A on the first page. A larger allowance draws at least as + // much, so a binary search over the first page alone finds it in about ten draws. + let draws_a = |work: u64| pages(work, 1).first().is_some_and(|first| black(first, 10)); + let candidates: Vec = (1..1_000).collect(); + let one = candidates.get(candidates.partition_point(|work| !draws_a(*work))).copied().expect("a small image is cheap"); + let Ok([first, second]) = <[Drawn; 2]>::try_from(pages(one, 2)) else { panic!("two pages") }; + assert_eq!((black(&first, 10), black(&first, 30)), (true, false), "the first page had enough for A alone"); + assert!(first.1.contains(&RenderWarning::ContentTruncated), "{:?}", first.1); + assert!(black(&second, 10) && black(&second, 30), "A, and B left out of the first page, draw on the second"); + assert!(second.1.is_empty(), "{:?}", second.1); +} diff --git a/crates/render/src/raster/tests/non_embedded_arial.rs b/crates/render/src/raster/tests/non_embedded_arial.rs new file mode 100644 index 0000000000..42a990638a --- /dev/null +++ b/crates/render/src/raster/tests/non_embedded_arial.rs @@ -0,0 +1,150 @@ +//! An Arial or a Times New Roman the PDF doesn't embed draws the glyphs its codes name (vendored +//! hayro-interpret patches 76 and 88, #5889, #6449). Word and PDFMaker write such a font as a +//! Type 0 `CIDFontType2` with `Identity-H`, often without `/ToUnicode`, so each code is a glyph +//! index in the author's font. Both number their first 258 glyphs in the standard Macintosh order +//! of the TrueType `post` table (`L` is 47, `o` 82, `w` 90). The standard substitute numbers its +//! glyphs another way: "Low Power" drew as "@ck 'Dck Yf", and "Salarié(e) du régime général" as +//! "Salari²*g+f w't²i lo g'ì²p²tcn". +//! +//! Each document is drawn twice, once as such a file has it and once with a `/ToUnicode` that +//! names each code's character, which draws through the substitute's glyph for that character. +//! The two must be the same pixels. + +use super::render_first_page; + +/// "Low É" as Arial's glyph indexes: L, o, w, space, Eacute. +const LOW_E_ACUTE: [(u16, char); 5] = [(47, 'L'), (82, 'o'), (90, 'w'), (3, ' '), (101, 'É')]; + +struct Font<'a> { + /// The `/BaseFont` of both the Type 0 font and its descendant. + base_font: &'a str, + /// The descendant's `/Subtype`. + subtype: &'a str, + /// `true`: the codes are 1, 2, … and a `/CIDToGIDMap` stream gives each its glyph in the author's font. + /// `false`: `/CIDToGIDMap /Identity`, and the codes are the glyph indexes themselves. + mapped: bool, + to_unicode: bool, +} + +/// A page showing [`LOW_E_ACUTE`] at 40 pt in `font`, which has no font program. +fn pdf(font: &Font<'_>) -> Vec { + let codes: Vec = if font.mapped { (1..=LOW_E_ACUTE.len() as u16).collect() } else { LOW_E_ACUTE.iter().map(|(gid, _)| *gid).collect() }; + let hex: String = codes.iter().map(|code| format!("{code:04X}")).collect(); + let content = format!("BT /F1 40 Tf 5 15 Td <{hex}> Tj ET"); + let bfchar: String = codes.iter().zip(LOW_E_ACUTE).map(|(code, (_, c))| format!("<{code:04X}> <{:04X}>\n", u32::from(c))).collect(); + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def +/CMapName /Adobe-Identity-UCS def /CMapType 2 def +1 begincodespacerange <0000> endcodespacerange +{} beginbfchar +{bfchar}endbfchar +endcmap CMapName currentdict /CMap defineresource pop end end", + codes.len() + ); + // CID 0 is .notdef; CIDs 1… give the glyphs in order. + let cid_to_gid: Vec = std::iter::once(0).chain(LOW_E_ACUTE.iter().map(|(gid, _)| *gid)).flat_map(u16::to_be_bytes).collect(); + let Font { base_font, subtype, .. } = font; + let to_unicode = if font.to_unicode { " /ToUnicode 8 0 R" } else { "" }; + let map = if font.mapped { "9 0 R" } else { "/Identity" }; + + let mut out = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /Identity-H /DescendantFonts [6 0 R]{to_unicode} >> endobj +6 0 obj << /Type /Font /Subtype /{subtype} /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> + /FontDescriptor 7 0 R /CIDToGIDMap {map} /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 32 /FontBBox [-665 -325 2000 1040] /ItalicAngle 0 /Ascent 728 /Descent -210 /CapHeight 716 /StemV 80 >> endobj +8 0 obj << /Length {} >> stream +{cmap} +endstream endobj +9 0 obj << /Length {} >> stream +", + content.len(), + cmap.len(), + cid_to_gid.len() + ) + .into_bytes(); + out.extend_from_slice(&cid_to_gid); + out.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + out +} + +/// `font` drawn without and with a `/ToUnicode`; the second must draw ink. +fn without_and_with_unicode(font: Font<'_>) -> (Vec, Vec) { + let without = render_first_page(pdf(&Font { to_unicode: false, ..font })); + let with = render_first_page(pdf(&Font { to_unicode: true, ..font })); + assert!(with.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "{}: the control must draw ink", font.base_font); + (without.rgba.to_vec(), with.rgba.to_vec()) +} + +/// Each Arial face, as Windows exporters name it and as Word names a face it doesn't embed, draws +/// "Low É" from its glyph indexes, with `/CIDToGIDMap /Identity` and with a map stream. The name +/// is read without regard to case, `MT` included. +#[test] +fn an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name() { + for base_font in [ + "ArialMT", + "Arial-BoldMT", + "Arial-ItalicMT", + "Arial-BoldItalicMT", + "Arial", + "Arial,Bold", + "Arial,BoldItalic", + "ABCDEF+Arial-BoldMT", + "arialmt", + "Arial-Boldmt", + ] { + for mapped in [false, true] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype: "CIDFontType2", mapped, to_unicode: false }); + assert!(without == with, "{base_font} (CIDToGIDMap {}): the codes draw \"Low É\"", if mapped { "stream" } else { "/Identity" }); + } + } +} + +/// Each Times New Roman face, as Windows exporters name it and as Word names a face it doesn't +/// embed (`TimesNewRoman,Bold` in #6449), draws "Low É" from its glyph indexes. +#[test] +fn a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name() { + for base_font in [ + "TimesNewRomanPSMT", + "TimesNewRomanPS-BoldMT", + "TimesNewRomanPS-ItalicMT", + "TimesNewRomanPS-BoldItalicMT", + "TimesNewRoman", + "TimesNewRoman,Bold", + "TimesNewRoman,Italic", + "TimesNewRoman,BoldItalic", + "WFXLUF+TimesNewRoman,Bold", + ] { + for mapped in [false, true] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype: "CIDFontType2", mapped, to_unicode: false }); + assert!(without == with, "{base_font} (CIDToGIDMap {}): the codes draw \"Low É\"", if mapped { "stream" } else { "/Identity" }); + } + } +} + +/// Only the faces of Arial and Times New Roman are read in that order: Arial Narrow, Arial Black, +/// Arial Unicode MS, Times and Times New Roman MT Std are other fonts, and a `CIDFontType0` font's +/// CIDs aren't TrueType glyph indexes. +#[test] +fn codes_of_other_fonts_are_not_read_in_the_macintosh_order() { + for (base_font, subtype) in [ + ("ArialNarrow", "CIDFontType2"), + ("ArialNarrow-Bold", "CIDFontType2"), + ("Arial-Black", "CIDFontType2"), + ("ArialUnicodeMS", "CIDFontType2"), + ("ArialMT", "CIDFontType0"), + ("Times-Bold", "CIDFontType2"), + ("TimesNewRomanMTStd", "CIDFontType2"), + ("TimesNewRoman,Bold", "CIDFontType0"), + ] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype, mapped: false, to_unicode: false }); + assert!(without != with, "{base_font} ({subtype}) is not read in the Macintosh order"); + } +} diff --git a/crates/render/src/raster/tests/png_predicted_images.rs b/crates/render/src/raster/tests/png_predicted_images.rs index 3bb95309bc..4ea59947fe 100644 --- a/crates/render/src/raster/tests/png_predicted_images.rs +++ b/crates/render/src/raster/tests/png_predicted_images.rs @@ -1,7 +1,8 @@ //! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a //! time (vendored hayro-syntax patch 35). The bit-level decoder they went through before dropped //! the last byte of each filtered row that ends part-way through a pixel, so every later row -//! started a byte early and the picture came out sheared. +//! started a byte early and the picture came out sheared. An image whose rows carry no filter-type +//! byte at all, although it declares a PNG predictor, is read as its samples (patch 40). use super::rgb_at; use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; @@ -67,3 +68,73 @@ fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() { } } } + +/// PDFBOX-3932: a writer declared `/Predictor 15` for an image and its soft mask without applying +/// it, so their rows carry no PNG filter-type byte (vendored hayro-syntax patch 40). Read with the +/// predictor, each row's first sample was taken for a filter type and the image came out a row +/// short and sheared; PDFium, MuPDF and Poppler draw the samples as they are. +#[test] +fn an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples() { + // Four rows of three 8-bit RGB pixels, every sample above 4 (no byte reads as a filter type + // that would happen to undo cleanly), and an opaque mask of three grey samples a row. + let rgb: Vec = ROWS.iter().flat_map(|px| (0..3).flat_map(move |_| px.map(|s| s * 16 + 8))).collect(); + let mask = [255u8; 12]; + let pdf = image_pdf(&[("/ColorSpace /DeviceRGB /SMask 6 0 R", "/Colors 3", &rgb), ("/ColorSpace /DeviceGray", "/Colors 1", &mask)]); + let page = render(pdf); + for (row, px) in ROWS.iter().enumerate() { + let expected = px.map(|s| s * 16 + 8); + for column in 0..3 { + let (x, y) = (column * 50 + 25, row * 50 + 25); + assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}"); + } + } +} + +/// Data one byte short of rows without tags is neither layout: it is decoded with the predictor +/// as before, and does not panic. +#[test] +fn an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor() { + let rgb = [0u8; 35]; + let page = render(image_pdf(&[("/ColorSpace /DeviceRGB", "/Colors 3", &rgb)])); + assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32)); +} + +/// Image objects 5, 6, … of a 3 × 4 8-bit image each, Flate-compressed with `/Predictor 15` +/// declared and their `data` stored as given; the page draws object 5 over all of itself. +fn image_pdf(images: &[(&str, &str, &[u8])]) -> Vec { + let content = "q 150 0 0 200 0 0 cm /Im0 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + for (i, (keys, colors, data)) in images.iter().enumerate() { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(data).unwrap(); + let data = encoder.finish().unwrap(); + pdf.extend_from_slice( + format!( + "{} 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 {keys} /BitsPerComponent 8 \ + /Filter /FlateDecode /DecodeParms << /Predictor 15 {colors} /BitsPerComponent 8 /Columns 3 >> /Length {} >> stream\n", + 5 + i, + data.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + pdf +} + +fn render(pdf: Vec) -> crate::RenderedPage { + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32)); + page +} diff --git a/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs b/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs new file mode 100644 index 0000000000..87bcd6e4e0 --- /dev/null +++ b/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs @@ -0,0 +1,281 @@ +//! A symbolic TrueType font with no `/Encoding`, no `/ToUnicode` and no glyph names reads a code +//! as Mac OS Roman when its (1, 0) `cmap` subtable selects the glyph the code draws and that glyph +//! sits where the standard Macintosh glyph order puts the code's Mac OS Roman character (vendored +//! hayro-interpret patch 101; ISO 32000-2 §9.6.5.4: single bytes "look up the associated glyph +//! descriptions" in the (1, 0) subtable, and a (3, 0) subtable maps "the single-byte codes in the +//! (1, 0) subtable" prepended with 0xF0). pdf.js's issue2017r.pdf has such a font; its text read as +//! WinAnsi, `ˆ` for `à`. +//! +//! The fonts are built here, in memory: [`GLYPHS`] glyphs, those the tests show filled squares, no +//! `post` table, and the `cmap` subtables each test asks for. No font file is involved. + +use super::*; + +/// Glyphs in each font: past the standard Macintosh order's `quoteright` (183). +const GLYPHS: u16 = 200; + +/// The standard Macintosh order's glyphs for `agrave`, `eacute`, `endash` and `quoteright`. +const AGRAVE: u8 = 106; +const EACUTE: u8 = 112; +const ENDASH: u8 = 178; +const QUOTERIGHT: u8 = 183; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A (1, 0) format 0 subtable mapping each code to its glyph. +fn mac_subtable(map: &[(u8, u8)]) -> Vec { + let mut out = Vec::new(); + for n in [0u16, 262, 0] { + be16(&mut out, n); // format, length, language + } + let mut ids = [0u8; 256]; + for &(code, glyph) in map { + ids[usize::from(code)] = glyph; + } + out.extend(ids); + out +} + +/// A (3, 0) format 4 subtable mapping each code at 0xF000 to its glyph, one segment each. +fn windows_subtable(map: &[(u8, u8)]) -> Vec { + let segments: Vec<(u16, u16)> = map.iter().map(|&(code, glyph)| (0xF000 | u16::from(code), u16::from(glyph))).chain([(0xFFFF, 0)]).collect(); + let count = segments.len() as u16; + let mut out = Vec::new(); + for n in [4u16, 16 + 8 * count, 0, 2 * count, 2, 0, 0] { + be16(&mut out, n); // format, length, language, segCountX2, searchRange, entrySelector, rangeShift + } + for &(code, _) in &segments { + be16(&mut out, code); // endCode + } + be16(&mut out, 0); // reservedPad + for &(code, _) in &segments { + be16(&mut out, code); // startCode + } + for &(code, glyph) in &segments { + be16(&mut out, if code == 0xFFFF { 1 } else { glyph.wrapping_sub(code) }); // idDelta + } + for _ in &segments { + be16(&mut out, 0); // idRangeOffset + } + out +} + +/// A TrueType font of [`GLYPHS`] glyphs, the `inked` ones squares, whose (3, 0) subtable maps +/// `windows` and whose (1, 0) subtable, if any, maps `mac`. A `named` font has a version 2.0 `post` +/// table naming its `inked` glyphs `bullet0`, `bullet1` and so on. +fn font(inked: &[u8], mac: Option<&[(u8, u8)]>, windows: &[(u8, u8)], named: bool) -> Vec { + // One contour, the square 0..1000 on both axes. + let mut square = Vec::new(); + for n in [1u16, 0, 0, 1000, 1000] { + be16(&mut square, n); + } + be16(&mut square, 3); // the contour's last point + be16(&mut square, 0); // no instructions + square.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + square.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 0, 1000, 0] { + square.extend_from_slice(&dy.to_be_bytes()); + } + let mut glyf = Vec::new(); + let mut loca = Vec::new(); + for glyph in 0..GLYPHS { + be16(&mut loca, (glyf.len() / 2) as u16); + if inked.iter().any(|g| u16::from(*g) == glyph) { + glyf.extend_from_slice(&square); + } + } + be16(&mut loca, (glyf.len() / 2) as u16); + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&GLYPHS.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..GLYPHS { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, GLYPHS); + maxp.resize(32, 0); + + let subtables: Vec<((u16, u16), Vec)> = + mac.map(|m| ((1, 0), mac_subtable(m))).into_iter().chain([((3, 0), windows_subtable(windows))]).collect(); + 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); + be32(&mut cmap, offset); + offset += bytes.len() as u32; + } + for (_, bytes) in &subtables { + cmap.extend(bytes); + } + + let mut tables = + vec![(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + if named { + let mut post = vec![0, 2, 0, 0]; + post.resize(32, 0); + be16(&mut post, GLYPHS); + for glyph in 0..GLYPHS { + // Index 258 and on are the table's own names, in order. + let own = inked.iter().position(|g| u16::from(*g) == glyph); + be16(&mut post, own.map_or(0, |i| 258 + i as u16)); + } + for i in 0..inked.len() { + let name = format!("bullet{i}"); + post.push(name.len() as u8); + post.extend(name.bytes()); + } + tables.push((*b"post", post)); + } + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +/// The text extracted from a page that shows `codes` in `font`, marked symbolic and with no +/// `/Encoding` and no `/ToUnicode`. +fn extracted(font: &[u8], codes: &[u8]) -> String { + extracted_with(font, codes, "AAAAAA+Sym", "") +} + +/// [`extracted`] with the font dictionary's `/BaseFont` named `base_font` and the `extra` entries. +fn extracted_with(font: &[u8], codes: &[u8], base_font: &str, extra: &str) -> String { + let hex: String = codes.iter().map(|c| format!("{c:02X}")).collect(); + let content = format!("BT /F1 30 Tf 5 5 Td <{hex}> Tj ET").into_bytes(); + let widths = vec!["1000"; 256].join(" "); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 80 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + format!("<< /Type /Font /Subtype /TrueType /BaseFont /{base_font} /FirstChar 0 /LastChar 255 /Widths [{widths}] /FontDescriptor 6 0 R {extra}>>").into_bytes(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sym /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.text.map(|t| t.plain_text()).unwrap_or_default() +} + +#[test] +fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman() { + // As issue2017r.pdf's font: code c and 0xF000 + c select the glyph the standard Macintosh + // order gives Mac OS Roman c. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "àé", "Mac OS Roman 0x88 is à and 0x8E é"); +} + +#[test] +fn winansi_codes_of_a_font_in_the_macintosh_order_read_as_before() { + // A Windows subset in the same order: code 0x92 draws `quoteright`, WinAnsi 0x92, where Mac OS + // Roman 0x92 is `iacute`. + let map = [(0x92, QUOTERIGHT), (0x96, ENDASH)]; + let font = font(&[ENDASH, QUOTERIGHT], Some(&map), &map, false); + assert_eq!(extracted(&font, &[0x92, 0x96]), "\u{2019}\u{2013}", "WinAnsi, not Mac OS Roman's íñ"); +} + +#[test] +fn a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before() { + let font = font(&[AGRAVE, EACUTE], None, &[(0x88, AGRAVE), (0x8E, EACUTE)], false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "with no (1, 0) subtable the codes read as WinAnsi"); +} + +#[test] +fn a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read() { + // The (3, 0) subtable is used first (#4156); a (1, 0) one that disagrees doesn't say what the + // drawn glyphs are. + let font = font(&[AGRAVE, EACUTE], Some(&[(0x88, EACUTE), (0x8E, AGRAVE)]), &[(0x88, AGRAVE), (0x8E, EACUTE)], false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "the codes read as WinAnsi, as before"); +} + +#[test] +fn a_font_that_names_its_glyphs_is_not_read_by_their_order() { + // A Wingdings subset draws code 0x83 with glyph 101, `Eacute` in the order, and names it + // `threesans`: a font with glyph names needn't keep the order. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, true); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "the codes read as WinAnsi, as before"); +} + +#[test] +fn a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading() { + // Symbol and ZapfDingbats have built-in encodings that aren't Latin (ISO 32000-2 §9.6.6.1), so their codes get + // no implicit Mac OS Roman reading, as the editor's `unicode_map` gives them none. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + for name in ["ZapfDingbats", "AAAAAA+Symbol"] { + let text = extracted_with(&font, &[0x88, 0x8E], name, ""); + assert_eq!(text, "ˆŽ", "{name}: the codes read as WinAnsi, as before"); + } +} + +#[test] +fn differences_naming_notdef_clear_the_mac_roman_reading_for_its_code() { + // `/Differences` without a base encoding: code 0x8E names `.notdef`, and code 0x88 keeps the Mac OS Roman reading. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + let text = extracted_with(&font, &[0x88, 0x8E], "AAAAAA+Sym", "/Encoding << /Differences [142 /.notdef] >> "); + assert_eq!(text, "à", "0x8E reads as nothing"); +} + +#[test] +fn a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off() { + // An unknown `/Encoding` name is read as the built-in encoding, but it names a base encoding all the same, so the + // editor gives it no Mac OS Roman reading and neither does the renderer: the codes read as WinAnsi, as before. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + assert_eq!(extracted_with(&font, &[0x88, 0x8E], "AAAAAA+Sym", "/Encoding /NoSuchEncoding "), "ˆŽ"); +} diff --git a/crates/render/src/raster/tests/tiff_predicted_images.rs b/crates/render/src/raster/tests/tiff_predicted_images.rs new file mode 100644 index 0000000000..30a163daeb --- /dev/null +++ b/crates/render/src/raster/tests/tiff_predicted_images.rs @@ -0,0 +1,94 @@ +//! Flate images with TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10) at 8 and 16 bits per +//! component are undone a byte row at a time (vendored hayro-syntax patch 42), with the samples +//! the bit-level decoder gave. + +use super::rgb_at; +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use flate2::{Compression, write::ZlibEncoder}; +use std::io::Write; +use std::sync::Arc; + +/// The page is 150 × 200 pt, drawn at 72 dpi; a 3 × 4 image fills it. +const WIDTH: usize = 150; +const HEIGHT: usize = 200; + +/// Renders a 3 × 4 image whose samples are `rows` (each row's bytes, before prediction), +/// differenced by TIFF predictor 2 with `pixel_len`-byte pixels of `sample_len`-byte samples. +fn render(rows: &[Vec], colour_space: &str, colors: usize, bpc: usize) -> Vec { + let (sample_len, pixel_len) = (bpc / 8, colors * bpc / 8); + let mut predicted = Vec::new(); + for row in rows { + let mut out = row.clone(); + for i in (pixel_len..row.len()).step_by(sample_len).rev() { + if sample_len == 2 { + let cur = u16::from_be_bytes([row[i], row[i + 1]]); + let left = u16::from_be_bytes([row[i - pixel_len], row[i - pixel_len + 1]]); + out[i..i + 2].copy_from_slice(&cur.wrapping_sub(left).to_be_bytes()); + } else { + out[i] = row[i].wrapping_sub(row[i - pixel_len]); + } + } + predicted.extend(out); + } + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&predicted).unwrap(); + let data = encoder.finish().unwrap(); + + let content = "q 150 0 0 200 0 0 cm /Im0 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 /ColorSpace /{colour_space} /BitsPerComponent {bpc} \ + /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns 3 >> /Length {} >> stream\n", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32)); + page.rgba.to_vec() +} + +#[test] +fn an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours() { + // Each pixel differs from the one to its left, and some differences wrap past 255. + let pixels: [[[u8; 3]; 3]; 4] = [ + [[255, 0, 0], [0, 255, 0], [0, 0, 255]], + [[10, 200, 30], [250, 5, 128], [0, 0, 0]], + [[255, 255, 255], [1, 2, 3], [128, 64, 32]], + [[0, 128, 255], [255, 128, 0], [64, 64, 64]], + ]; + let rows: Vec> = pixels.iter().map(|row| row.iter().flatten().copied().collect()).collect(); + let rgba = render(&rows, "DeviceRGB", 3, 8); + for (row, colours) in pixels.iter().enumerate() { + for (column, rgb) in colours.iter().enumerate() { + let (x, y) = (column * 50 + 25, row * 50 + 25); + assert_eq!(rgb_at(&rgba, WIDTH, x, y), *rgb, "image row {row}, column {column}"); + } + } +} + +#[test] +fn a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels() { + // The high byte decides the level drawn; the differences carry across the byte boundary. + let levels: [[u16; 3]; 4] = [[0xffff, 0x00ff, 0x8000], [0x0100, 0xff00, 0x4080], [0, 0x7fff, 0xc000], [0x2000, 0x2000, 0xe0ff]]; + let rows: Vec> = levels.iter().map(|row| row.iter().flat_map(|v| v.to_be_bytes()).collect()).collect(); + let rgba = render(&rows, "DeviceGray", 1, 16); + for (row, values) in levels.iter().enumerate() { + for (column, value) in values.iter().enumerate() { + let (x, y) = (column * 50 + 25, row * 50 + 25); + let [r, g, b] = rgb_at(&rgba, WIDTH, x, y); + let expected = i32::from(value >> 8); + for got in [r, g, b] { + assert!((i32::from(got) - expected).abs() <= 1, "image row {row}, column {column}: {got} for {value:#06x}"); + } + } + } +} diff --git a/crates/render/src/raster/tests/truetype_loca_format.rs b/crates/render/src/raster/tests/truetype_loca_format.rs new file mode 100644 index 0000000000..aa668317ff --- /dev/null +++ b/crates/render/src/raster/tests/truetype_loca_format.rs @@ -0,0 +1,147 @@ +//! A TrueType font whose `head.indexToLocFormat` holds neither 0 nor 1 draws through the `loca` +//! format its table is in (vendored hayro-interpret patch 83). The OpenType `head` table allows +//! only 0 (short offsets) and 1 (long); the glyph reader took anything but 1 as short, so a font +//! whose field reads 256 (pdf.js `issue2537r.pdf`) had its long offsets read as short ones, and +//! "LINE UP" drew as boxes. ISO 32000-2 §9.9 has an embedded TrueType program drawn as the +//! font program it is. +//! +//! The font is built here, in memory: two glyphs (`.notdef`, empty, and a filled square) and a +//! (1, 0) `cmap` subtable mapping `A` to the square. No font file is involved. + +use super::render_first_page; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// The font, its `loca` offsets long when `long` and followed by `padding` zero bytes, its +/// `head.indexToLocFormat` set to `format`. +fn square_font(long: bool, padding: usize, format: u16) -> Vec { + // Glyph 1: one contour, the square 0..1000 on both axes (glyph 0 is empty). + let mut glyf = Vec::new(); + for n in [1u16, 0, 0, 1000, 1000] { + be16(&mut glyf, n); + } + be16(&mut glyf, 3); // the contour's last point + be16(&mut glyf, 0); // no instructions + glyf.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + glyf.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 0, 1000, 0] { + glyf.extend_from_slice(&dy.to_be_bytes()); + } + let mut loca = Vec::new(); + for offset in [0u32, 0, glyf.len() as u32] { + if long { be32(&mut loca, offset) } else { be16(&mut loca, (offset / 2) as u16) } + } + loca.resize(loca.len() + padding, 0); + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + head[50..52].copy_from_slice(&format.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&2u16.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..2 { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, 2); + maxp.resize(32, 0); + + // (1, 0), format 0: `A` → glyph 1 (the square). + let mut mac = Vec::new(); + be16(&mut mac, 0); + be16(&mut mac, 262); + be16(&mut mac, 0); + let mut ids = [0u8; 256]; + ids[usize::from(b'A')] = 1; + mac.extend(ids); + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, 1); + be16(&mut cmap, 1); + be16(&mut cmap, 0); + be32(&mut cmap, 12); + cmap.extend(mac); + + let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +/// A 40 × 40 pt page that shows `A` at 30 pt from (5, 5) in the font. +fn page_with_font(font: &[u8]) -> Vec { + let content = b"BT /F1 30 Tf 5 5 Td (A) Tj ET".to_vec(); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sq /FirstChar 65 /LastChar 65 /Widths [1000] /FontDescriptor 6 0 R >>".to_vec(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sq /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +#[test] +fn a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs() { + // The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it. + let square_drawn = |long: bool, padding: usize, format: u16| { + let page = render_first_page(page_with_font(&square_font(long, padding, format))); + let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255); + assert_eq!(at(2, 2), 255, "nothing is drawn outside the square"); + at(20, 20) < 128 + }; + assert!(square_drawn(true, 0, 1), "long offsets, marked long"); + assert!(square_drawn(false, 0, 0), "short offsets, marked short"); + assert!(square_drawn(true, 0, 0x0100), "long offsets marked 256: the table's length says long"); + assert!(square_drawn(false, 0, 0x0100), "short offsets marked 256: the table's length says short"); + // Three short offsets are 6 bytes and three long ones 12: 8 bytes is short ones and padding. + assert!(square_drawn(false, 2, 0x0100), "short offsets and padding marked 256: still short"); +} diff --git a/crates/render/src/raster/tests/type3_proc_cache.rs b/crates/render/src/raster/tests/type3_proc_cache.rs new file mode 100644 index 0000000000..350877e6f8 --- /dev/null +++ b/crates/render/src/raster/tests/type3_proc_cache.rs @@ -0,0 +1,152 @@ +//! A Type 3 glyph shown many times decodes its glyph procedure once (vendored hayro-interpret +//! patch 87). hayro inflated the procedure again for every copy of the glyph, so a font used as +//! a fill pattern, one glyph drawn hundreds of thousands of times, took seconds a page. + +use super::image_cache_pdf; +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + interpret_page, +}; +use kurbo::{Affine, BezPath, Rect, Shape}; +use std::io::Write; +use std::sync::Arc; + +/// Records the width in device pixels of each path drawn, and draws each Type 3 glyph through its +/// procedure, as the renderer does. +#[derive(Default)] +struct Paths(Vec); + +impl<'a> Device<'a> for Paths { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) { + self.0.push((transform * path.clone()).bounding_box().width()); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, _: &GlyphDrawMode) { + if let Glyph::Type3(glyph) = glyph { + glyph.interpret(self, transform, glyph_transform, paint); + } + } + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +fn flate(data: &[u8]) -> Vec { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() +} + +/// A Type 3 font on an 8 × 8 grid whose glyphs, named `names`, take the codes from 33 on and +/// have their procedures in the objects from `first_object` on. +fn font(names: &[String], first_object: usize) -> String { + let procs: String = names.iter().enumerate().map(|(i, n)| format!("/{n} {} 0 R ", first_object + i)).collect(); + let names_list: String = names.iter().map(|n| format!("/{n} ")).collect(); + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 8 8] /FontMatrix [0.125 0 0 0.125 0 0] /CharProcs << {procs}>> \ + /Encoding << /Type /Encoding /Differences [33 {names_list}] >> /FirstChar 33 /LastChar {} /Widths [{}] >>", + 32 + names.len(), + "8 ".repeat(names.len()) + ) +} + +/// Renders the page of `bytes` through a new cache; the widths of the paths drawn, and how many +/// glyph procedures were decoded. +fn render(bytes: Vec) -> (Vec, usize) { + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let initial = page.initial_transform(true).to_kurbo(); + let cache = InterpreterCache::new(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Paths::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.type3_proc_decodes()) +} + +/// A Type 3 font whose one glyph, `!`, is a Flate-compressed procedure filling its 8 × 8 box, +/// shown 200 times on one page and rendered twice through one cache: every copy draws, and the +/// procedure is decoded once, on the first copy. +#[test] +fn a_type3_glyph_shown_again_decodes_its_procedure_once() { + let proc_bytes = flate(b"8 0 0 0 8 8 d1 0 0 8 8 re f"); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let content = format!("BT /F1 0.5 Tf 1 0 0 1 2 30 Tm ({}) Tj ET", "!".repeat(200)); + let resources = format!("<< /Font << /F1 {} >> >>", font(&["dot".into()], 5)); + let parsed = Pdf::new(Arc::new(image_cache_pdf(&content, &resources, &[(&proc_dict, &proc_bytes)]))).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + for render in ["first", "second"] { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Paths::default(); + interpret_page(page, &mut ctx, &mut device); + assert_eq!(device.0.len(), 200, "the {render} render draws every copy of the glyph"); + assert_eq!(cache.type3_proc_decodes(), 1, "after the {render} render: the procedure is decoded once"); + } +} + +/// A procedure that decodes to more than 64 KiB isn't kept: shown three times, it is decoded three +/// times, and draws each time. +#[test] +fn a_procedure_over_64_kib_is_decoded_for_each_copy() { + let mut program = b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(); + program.resize((64 << 10) + 1, b' '); + let proc_bytes = flate(&program); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let resources = format!("<< /Font << /F1 {} >> >>", font(&["big".into()], 5)); + let bytes = image_cache_pdf("BT /F1 0.5 Tf 1 0 0 1 2 30 Tm (!!!) Tj ET", &resources, &[(&proc_dict, &proc_bytes)]); + let (widths, decodes) = render(bytes); + assert_eq!(widths.len(), 3); + assert_eq!(decodes, 3, "a procedure over the size kept is decoded for each copy"); +} + +/// The fonts of one cache keep at most 32 MiB of procedures together: 600 glyphs of 60,000 bytes +/// each, over three fonts (36 MB), each shown twice, all draw; the procedures kept are decoded +/// once and the rest twice, so there are more decodes than glyphs and fewer than copies. +#[test] +fn the_procedures_one_cache_keeps_stop_at_32_mib() { + let mut program = b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(); + program.resize(60_000, b' '); + let proc_bytes = flate(&program); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let (fonts, per_font) = (3, 200); + let mut resources = String::from("<< /Font << "); + let mut content = String::new(); + for f in 0..fonts { + let names: Vec = (0..per_font).map(|g| format!("g{f}x{g}")).collect(); + resources.push_str(&format!("/F{f} {} ", font(&names, 5 + f * per_font))); + let codes: String = (0..per_font).map(|g| format!("{:02x}", 33 + g)).collect(); + content.push_str(&format!("BT /F{f} 0.05 Tf 1 0 0 1 2 {} Tm <{codes}{codes}> Tj ET ", 10 + 20 * f)); + } + resources.push_str(">> >>"); + let objects: Vec<(&str, &[u8])> = (0..fonts * per_font).map(|_| (proc_dict.as_str(), proc_bytes.as_slice())).collect(); + let bytes = image_cache_pdf(&content, &resources, &objects); + let (widths, decodes) = render(bytes); + assert_eq!(widths.len(), 2 * fonts * per_font, "every copy draws"); + assert!(decodes > fonts * per_font && decodes < 2 * fonts * per_font, "{decodes} decodes: some procedures kept, not all"); +} + +/// Two fonts each with a glyph named `dot`, one filling 8 × 8 and the other 4 × 4, draw their own +/// procedures: what one font keeps isn't looked up by another with the same glyph name. +#[test] +fn fonts_with_the_same_glyph_name_keep_their_own_procedures() { + let (big, small) = (b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(), b"8 0 0 0 8 8 d1 0 0 4 4 re f".to_vec()); + let dict = |data: &[u8]| format!("<< /Length {} >>", data.len()); + let (big_dict, small_dict) = (dict(&big), dict(&small)); + let resources = format!("<< /Font << /A {} /B {} >> >>", font(&["dot".into()], 5), font(&["dot".into()], 6)); + let bytes = image_cache_pdf( + "BT /A 8 Tf 1 0 0 1 2 30 Tm (!!) Tj /B 8 Tf (!!) Tj ET", + &resources, + &[(big_dict.as_str(), big.as_slice()), (small_dict.as_str(), small.as_slice())], + ); + let (widths, _) = render(bytes); + assert_eq!(widths.len(), 4); + let (a, b) = (widths[0], widths[3]); + assert!((a - 2.0 * b).abs() < 0.01, "font A's glyph is twice as wide as font B's: {widths:?}"); + assert_eq!((widths[1], widths[2]), (a, b), "each font draws its own glyph again: {widths:?}"); +} diff --git a/crates/render/src/raster/tests/type3_proc_widths.rs b/crates/render/src/raster/tests/type3_proc_widths.rs new file mode 100644 index 0000000000..bf11775af9 --- /dev/null +++ b/crates/render/src/raster/tests/type3_proc_widths.rs @@ -0,0 +1,130 @@ +//! A Type 3 font without `/Widths` advances each glyph by the width its glyph procedure sets +//! with `d0` or `d1` (vendored hayro-interpret patch 90). ISO 32000-2 Table 110 requires +//! `/Widths` in a Type 3 font, and §9.6.4 has the procedure state the same width: "wx … shall +//! be consistent with the corresponding width in the font's Widths array". A font whose +//! `/Widths` key was misspelt (PDFBOX-2943) gave every glyph no advance, so its text was drawn +//! as one blot and read as characters stacked on one spot. PDFium takes the procedure's width +//! here. A font that has `/Widths` is unchanged: Table 110 gives a code outside `/FirstChar` to +//! `/LastChar` width 0, as Poppler and MuPDF draw it. +//! +//! The glyphs are squares 400 units wide (`a`, set by `d1`, advance 500) and 600 units wide +//! (`b`, set by `d0`, advance 700), shown at 100 pt from x = 20. + +use super::render_first_page; +use crate::RenderWarning; +use crate::text::extract_page; +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use std::sync::Arc; + +/// A page showing `aabb` in a Type 3 font whose dictionary ends with `widths`. +fn type3_page(widths: &str) -> Vec { + type3_page_with(widths, "aabb", "700 0 d0 0 0 600 600 re f") +} + +/// A page showing `text` in a Type 3 font whose dictionary ends with `widths` and whose `b` +/// glyph is `glyph_b`. +fn type3_page_with(widths: &str, text: &str, glyph_b: &str) -> Vec { + let content = format!("BT /F1 100 Tf 20 100 Td ({text}) Tj ET"); + let glyph_a = "500 0 0 0 400 400 d1 0 0 400 400 re f"; + let objects = [ + "<< /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 600 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << /a 6 0 R /b 7 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> {widths} >>" + ), + format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()), + format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n{object}\nendobj\n", i + 1).into_bytes()); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +/// The columns (start, end) of the rendered page that hold ink, as runs. +fn ink_runs(pdf: Vec) -> Vec<(u32, u32)> { + let page = render_first_page(pdf); + let inked = |x: u32| (0..page.height).any(|y| page.rgba.get(((y * page.width + x) * 4) as usize).is_some_and(|v| *v < 128)); + let mut runs = Vec::new(); + let mut start = None; + for x in 0..=page.width { + match (x < page.width && inked(x), start) { + (true, None) => start = Some(x), + (false, Some(s)) => { + runs.push((s, x)); + start = None; + } + _ => {} + } + } + runs +} + +/// The left edge of each extracted glyph, rounded to a point. +fn glyph_lefts(pdf: Vec) -> Vec { + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).expect("text"); + text.glyphs.iter().map(|g| g.rect[0].round() as i32).collect() +} + +#[test] +fn a_type3_font_without_widths_advances_by_its_glyph_procedures() { + // Each glyph in its own place: a at 20 and 70 (d1 width 500), b at 120 and 190 (d0 700). + for widths in ["", "/FirstChar 97 /LastChar 98 /WXdths [300 300]"] { + assert_eq!(ink_runs(type3_page(widths)), [(20, 60), (70, 110), (120, 180), (190, 250)], "{widths:?}"); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 70, 120, 190], "{widths:?}"); + } +} + +#[test] +fn a_code_outside_first_char_to_last_char_still_has_width_zero() { + // a takes /Widths (300); b, past /LastChar, has width 0 (Table 110), whatever d0 says. + let widths = "/FirstChar 97 /LastChar 97 /Widths [300]"; + assert_eq!(ink_runs(type3_page(widths)), [(20, 140)]); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 50, 80]); +} + +#[test] +fn widths_the_font_gives_are_kept() { + // /Widths says 300 for both, whatever the procedures say: the glyphs overlap, as written. + let widths = "/FirstChar 97 /LastChar 98 /Widths [300 300]"; + assert_eq!(ink_runs(type3_page(widths)), [(20, 170)]); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 50, 80, 110]); + // An explicit 0 is an entry too: every glyph stays at x = 20 (text extraction keeps one + // of each glyph drawn again on the same spot). + let zero = "/FirstChar 97 /LastChar 98 /Widths [0 0]"; + assert_eq!(glyph_lefts(type3_page(zero)), [20, 20]); +} + +#[test] +fn a_code_without_a_usable_procedure_width_still_advances_nothing() { + // b's d0 has a name for its width and c has no procedure: neither advances, and neither + // panics. + assert_eq!(glyph_lefts(type3_page_with("", "bcb", "/x 0 d0 0 0 600 600 re f")), [20, 20]); +} + +#[test] +fn a_type3_font_without_widths_is_reported() { + // The page draws, and says once that a font's /Widths was missing; a font with /Widths + // (even one that leaves `b` out) says nothing. + for widths in ["", "/FirstChar 97 /LastChar 98 /WXdths [300 300]"] { + let page = render_first_page(type3_page(widths)); + assert_eq!(page.warnings, [RenderWarning::Type3WidthsMissing], "{widths:?}"); + } + for widths in ["/FirstChar 97 /LastChar 98 /Widths [300 300]", "/FirstChar 97 /LastChar 97 /Widths [300]"] { + assert!(render_first_page(type3_page(widths)).warnings.is_empty(), "{widths:?}"); + } +} diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index effbc87d92..441f48998c 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -144,7 +144,9 @@ pub(crate) struct LazyStructure { impl LazyStructure { pub(crate) fn new(bytes: Arc>, password: Option<&str>) -> std::result::Result { let source = Document::open_with_stream_limit(bytes, password, 256 << 20).map_err(|e| e.to_string())?; - if !source.repair_log().is_empty() { + // A file rebuilt because its last update has no `/Prev` is read here as rebuilt: the + // renderer rebuilds it too, and the fallback reader would follow the cut-off chain. + if !source.repair_log().is_empty() && !source.rebuilt_unlinked_update() { return Err("the structure requires compatibility repair".into()); } let trailer = convert_dict(source.trailer(), 0).map_err(|e| e.to_string())?; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index aa66a4b683..bc132466b1 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -131,7 +131,9 @@ impl PageText { } /// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks, - /// which count as a single space). + /// which count as a single space, or as nothing where the line runs on between two Chinese or + /// Japanese characters or ends in a soft hyphen: there a query with a space across the break + /// doesn't match, and one without does). pub fn find(&self, needle: &str) -> Vec> { self.find_opts(needle, false, false) } diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index 6c14325400..a04e380788 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -458,9 +458,17 @@ impl<'a> Device<'a> for TextDevice { eprintln!("{kind} {text:?} transform={:?} glyph_transform={:?}", transform.as_coeffs(), glyph_transform.as_coeffs()); } let advance = match glyph { - Glyph::Outline(g) => { - g.advance_width().filter(|a| *a > 0.0).map(f64::from).unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)) - } + // A vertical font's advance runs down the column; across it, the glyph is as wide as + // its horizontal width w0, centred on the column by default (ISO 32000-2 §9.7.4.3). Taking + // the outline's width instead gave a column of Latin letters boxes of uneven widths, + // off its centre, and a narrow letter cut the line's gap allowance below the spacing + // of widely tracked text, so the column fell apart into lines. + Glyph::Outline(g) => g + .vertical_glyph_width() + .or_else(|| g.advance_width()) + .filter(|a| a.is_finite() && *a > 0.0) + .map(f64::from) + .unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)), Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0), }; // The baseline direction in view space (y down). diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index c68660dbe9..71670cc271 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -1,4 +1,8 @@ //! Literal search in normalized page text, retaining the original glyph owners. +//! +//! Words and lines are separated by one space, except where a line of Chinese or Japanese runs on +//! to the next: a break between two such characters is no word break, so it reads as nothing and +//! "検知機構" is found when the line ends after "検知". use super::{PageText, fold_case}; use std::ops::Range; @@ -7,21 +11,51 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b return Vec::new(); } let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; - let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); + let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect(); + let needle: Vec = query.split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { return Vec::new(); } let mut chars: Vec<(char, usize)> = Vec::new(); + // Whether a skipped ZERO WIDTH SPACE came right before each character: it draws nothing, but + // it is a word boundary (UAX #29 breaks on both sides of it), which Thai and Khmer write between words + // with no visible gap, so whole-word search still sees two words there. + let mut zero_width_space: Vec = Vec::new(); + let mut pending_zero_width_space = false; + // The last character pushed other than a variation selector, kept as characters are pushed so + // a long run of selectors costs no rescan. + let mut last_base: Option = None; for (i, glyph) in page.glyphs.iter().enumerate() { + let text = fold(&glyph.text); let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b); - let gap = page.space_before.get(i).copied().unwrap_or(false) || line_break; + // A soft hyphen marks where a word may be divided at a line end (ISO 32000-2 §14.8.2.3), so + // a line that ends in one runs on to the next with no word break between them. + // Other invisible characters after the soft hyphen don't hide it. + let soft_break = line_break && last_kept(page, i) == Some('\u{AD}'); + // So does a line of Chinese or Japanese that runs on to the next. The characters on both + // sides of the break are the visible ones: invisible characters are skipped below, and a + // variation selector after the last one only picks its glyph. + let joined = line_break && last_base.is_some_and(unspaced) && first_visible(page, i).is_some_and(unspaced) && wraps(page, i); + let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !(joined || soft_break)); if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') { chars.push((' ', i)); + last_base = Some(' '); + zero_width_space.push(false); + pending_zero_width_space = false; } - for c in fold(&glyph.text).chars() { + for c in text.chars() { + if is_invisible(c) { + pending_zero_width_space |= c == '\u{200B}'; + continue; + } let c = if c.is_whitespace() { ' ' } else { c }; if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { chars.push((c, i)); + if !is_variation_selector(c) { + last_base = Some(c); + } + zero_width_space.push(pending_zero_width_space && c != ' '); + pending_zero_width_space = false; } } } @@ -48,7 +82,9 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b } let end = i.saturating_add(1); let start = end.saturating_sub(needle.len()); - let bounded = !whole_words || (!word(start.checked_sub(1)) && !word(Some(end))); + // A skipped zero width space before the first character or after the last is a boundary. + let after_break = |i: usize| zero_width_space.get(i).copied().unwrap_or(false); + let bounded = !whole_words || ((after_break(start) || !word(start.checked_sub(1))) && (after_break(end) || !word(Some(end)))); if bounded { if let Some((_, first)) = chars.get(start) && let Some(last) = owner.checked_add(1) @@ -68,6 +104,100 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b hits } +/// A character that draws nothing inside a word and only tells layout where a line may break or +/// letters may join: SOFT HYPHEN, ZERO WIDTH SPACE, ZERO WIDTH NON-JOINER and JOINER, WORD JOINER +/// and ZERO WIDTH NO-BREAK SPACE. All six are Unicode default-ignorable format characters, so +/// find skips them in the page text and in the query alike: "Mitarbeiterinnen" matches a word +/// stored with soft hyphens. A visible word gap is its own space in the page text, so skipping a +/// zero width space next to one still keeps the two words apart. +fn is_invisible(c: char) -> bool { + matches!(c, '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}') +} + +/// A variation selector (VS1-VS16 and VS17-VS256): it picks a variant glyph of the character +/// before it and is no character of its own. +fn is_variation_selector(c: char) -> bool { + matches!(c as u32, 0xFE00..=0xFE0F | 0xE0100..=0xE01EF) +} + +/// The last character before glyph `i` on the line that glyph `i - 1` ends, other than the +/// invisible characters find skips; a soft hyphen counts, since it marks a divided word. Looks +/// back no further than that line's start, so each line is read once. +fn last_kept(page: &PageText, i: usize) -> Option { + let end = i.checked_sub(1)?; + let line = page.line_of.get(end)?; + (0..=end) + .rev() + .take_while(|&k| page.line_of.get(k) == Some(line)) + .filter_map(|k| page.glyphs.get(k)) + .flat_map(|g| g.text.chars().rev()) + .find(|&c| c == '\u{AD}' || !is_invisible(c)) +} + +/// Whether `c` is set solid, with no spaces between words, in text that a line break may divide +/// anywhere: East Asian Width Wide, Fullwidth or Halfwidth (Unicode Standard Annex #11) Han, +/// kana, bopomofo, CJK symbols and punctuation and fullwidth forms other than Latin letters and +/// digits. Hangul is left out: Korean separates its words with spaces, so a line end there is a +/// word break. A line break between two such characters is the CSS segment-break rule's case for removing the break, not turning +/// it into a space (CSS Text Module, segment break transformation). +fn unspaced(c: char) -> bool { + matches!(c as u32, + 0x2E80..=0x2FFF // CJK radicals, Kangxi radicals, ideographic description characters + | 0x3001..=0x303E // CJK symbols and punctuation (U+3000 is a space; U+303F is narrow) + | 0x3041..=0x30FF // Hiragana, Katakana + | 0x3105..=0x312F // Bopomofo + | 0x3190..=0x31FF // Kanbun, Bopomofo extended, CJK strokes, Katakana phonetic extensions + | 0x3200..=0x4DBF // Enclosed CJK letters, CJK compatibility, Han extension A + | 0x4E00..=0x9FFF // Han + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFE10..=0xFE19 | 0xFE30..=0xFE6F // vertical, compatibility and small forms + // Fullwidth forms, less the fullwidth digits and Latin letters (U+FF10-U+FF19, U+FF21-U+FF3A, + // U+FF41-U+FF5A): those are Latin text set wide, whose words still end at a line end. + | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF60 | 0xFFE0..=0xFFE6 + | 0xFF61..=0xFF9F // halfwidth CJK punctuation and Katakana (not halfwidth Hangul) + | 0x16FE0..=0x16FFF | 0x1AFF0..=0x1B2FF // ideographic symbols, kana supplements and extensions + | 0x20000..=0x3FFFD // Han extensions B and later, compatibility supplement + ) && !matches!(c as u32, 0x3200..=0x321E | 0x3260..=0x327E) // parenthesised and circled Hangul +} + +/// Whether glyph `i` starts the line that the text of glyph `i - 1` runs on to: both run the same +/// way, it sits past that glyph across the lines, by more than half the glyph's line height and +/// at most three, and starts no further along the line than half a line height past it, as a +/// wrapped line does (a line of one character starts level with the one before). Text set side by +/// side on one line (table cells, labels), a cell set lower and further along the row, a column +/// that starts back at the top, a block further down the page and a flow in another direction +/// keep their word break. +fn wraps(page: &PageText, i: usize) -> bool { + // Measured on the glyphs that own the visible characters either side of the break: an + // invisible glyph or a variation selector of its own (drawn with no height) says nothing about + // the lines. Each search stays within one line. + let Some(end) = i.checked_sub(1) else { return false }; + let visible = |k: &usize| page.glyphs.get(*k).is_some_and(|g| g.text.chars().any(|c| !is_invisible(c) && !is_variation_selector(c))); + let same_line = |k: &usize, of: usize| page.line_of.get(*k).is_some() && page.line_of.get(*k) == page.line_of.get(of); + let before = (0..=end).rev().take_while(|k| same_line(k, end)).find(visible).unwrap_or(end); + let after = (i..page.glyphs.len()).take_while(|k| same_line(k, i)).find(visible).unwrap_or(i); + let Some((before, after)) = page.glyphs.get(before).zip(page.glyphs.get(after)) else { return false }; + // Unit directions: the same flow has a dot product of 1. + let same_flow = before.direction[0] * after.direction[0] + before.direction[1] * after.direction[1] > 0.99; + let centre = |q: &[[f32; 2]; 4]| [q.iter().map(|p| p[0]).sum::() / 4.0, q.iter().map(|p| p[1]).sum::() / 4.0]; + let (a, b) = (centre(&before.quad), centre(&after.quad)); + // Towards the next line: the writing direction turned a quarter clockwise in y-down view space. + let [dx, dy] = before.direction; + let across = (b[0] - a[0]) * -dy + (b[1] - a[1]) * dx; + let along = (b[0] - a[0]) * dx + (b[1] - a[1]) * dy; + let [near, far] = [before.quad[0], before.quad[3]]; + let height = (near[0] - far[0]).hypot(near[1] - far[1]); + // NaN compares false: no join. + same_flow && across > height * 0.5 && across <= height * 3.0 && along < height * 0.5 +} + +/// The first visible character on the line that glyph `i` starts, skipping the invisible characters +/// that find drops, so a line that opens with a zero width character still joins on its first letter. +fn first_visible(page: &PageText, i: usize) -> Option { + let line = page.line_of.get(i)?; + page.glyphs.iter().zip(&page.line_of).skip(i).take_while(|(_, l)| *l == line).flat_map(|(g, _)| g.text.chars()).find(|&c| !is_invisible(c)) +} + /// Extend a prefix, falling back through strictly shorter proper prefixes on a mismatch. fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> usize { while matched > 0 && needle.get(matched) != Some(&c) { diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index f0bd9d864b..3b06ec7f14 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -195,9 +195,10 @@ fn redraws_of_a_long_replacement_share_a_text_budget() { fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() { // Everyday content both ways: a string with a byte above 0x7F, and a later word holding // a capital "EI". The end-of-data look-ahead used to read the string's bytes as image - // data, so the image swallowed the rest of the page. + // data, so the image swallowed the rest of the page. The image is filtered, so its + // length isn't known up front and the look-ahead decides. let pdf = marked_pages(&[ - "BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \0 EI BT /F1 20 Tf 1 0 0 1 20 200 Tm (Gr\u{fc}\u{df}e) Tj 1 0 0 1 20 150 Tm (EINGANG) Tj ET", + "BI /W 1 /H 1 /BPC 8 /CS /DeviceGray /F /AHx ID 00> EI BT /F1 20 Tf 1 0 0 1 20 200 Tm (Gr\u{fc}\u{df}e) Tj 1 0 0 1 20 150 Tm (EINGANG) Tj ET", ]); assert!(marked_text(&pdf, 0).plain_text().contains("EINGANG"), "the text after the inline image was not extracted"); @@ -663,10 +664,12 @@ fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { /// #3169, #3170: glyph names outside the Adobe Glyph List still read as text. Ligature /// (`f_i`) and suffixed (`hyphen.case`) names go by the glyph list specification's full -/// mapping. A name that only numbers the glyph (`c251` small caps, `C39`, `C174`, `C39.sc`) -/// reads as nothing, not as a guess from the code (review on #3239: with `/C1` at 233, -/// StandardEncoding's 233 would read `Ø` for a glyph drawn as `é`), and so do a ZapfDingbats -/// name (`a12`) and a name with no meaning left (`foo`). +/// mapping. A name that only numbers the glyph reads as nothing, not as a guess from the code +/// (review on #3239: with `/C1` at 233, StandardEncoding's 233 would read `Ø` for a glyph drawn +/// as `é`). `C39` at 39 and `C174` at 174 name their own codes, but this font's other numbered +/// names (`c251` at 97, `C39.sc` at 61, `C1` at 233, `a12` at 88) don't, so they read as +/// nothing too (see `numbered_glyph_names_read_as_their_own_code`). A name with no meaning +/// left (`foo`) reads as nothing. #[test] fn glyph_names_outside_the_glyph_list_still_read_as_text() { use lopdf::{Object, dictionary}; @@ -695,11 +698,145 @@ fn glyph_names_outside_the_glyph_list_still_read_as_text() { assert_eq!(lines, ["fi", "fl", "-", "fi"], "{text:?}"); } +/// A glyph name that only numbers its glyph, with no `/ToUnicode` (ISO 32000-2 §9.10.2 has no +/// route for it), reads as its code's Latin-1 character when the number is that very code, in +/// decimal (`C70` at 70, `C0071` at 71, `MT72` at 72, `C39` at 39, `C174` at 174) or +/// hexadecimal (`G49` at 0x49, `GE9` at 0xE9), as pdftotext, PDFium and MuPDF read +/// it: Windows printer drivers and typesetters name subset glyphs so. Only in a font where every +/// numbered name names its own code: one more name numbering another code (`/C1` at 233, review +/// on #3239) turns the whole font back to nothing, and so does a font whose names number +/// something else, where a name can match its code by coincidence (`/MT40` at 0x40). A subset +/// that counts its glyphs in order of first use from code 1 (`/Differences [1 /C1 /C2 … /C80]`) +/// names its own codes too, but `/C65` there is the 65th glyph used, not `A`: a numbered name at +/// a control code (`C1` at 1, `C9` at 9) gives the counter away and the font reads nothing. +/// Code 32 is the first code a numbered name may name: `C32` at 32 reads as a space. +#[test] +fn numbered_glyph_names_read_as_their_own_code() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let encoding = |differences: Vec| Object::Dictionary(dictionary! { "Type" => "Encoding", "Differences" => differences }); + // One glyph per line, as in `glyph_names_outside_the_glyph_list_still_read_as_text`. + let one_per_line = |codes: &[u8]| { + let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec(); + for code in codes { + content.extend_from_slice(format!(" <{code:02x}> Tj 0 -25 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + content + }; + let own = encoding(vec![ + Object::Integer(70), + name("C70"), + name("C0071"), + name("MT72"), + name("G49"), + Object::Integer(233), + name("GE9"), + Object::Integer(39), + name("C39"), + Object::Integer(174), + name("C174"), + ]); + let one_other = encoding(vec![Object::Integer(70), name("C70"), Object::Integer(233), name("C1")]); + let numbered_otherwise = encoding(vec![Object::Integer(1), name("C70"), name("C105"), Object::Integer(64), name("MT40")]); + let counter = encoding(std::iter::once(Object::Integer(1)).chain((1..=80).map(|n| name(&format!("C{n}")))).collect()); + let own_and_a_control = encoding(vec![Object::Integer(9), name("C9"), Object::Integer(70), name("C70"), name("C0071")]); + // Code 32 is the first code a numbered name may name: `/C32` at 32 is a space, not a counter. + let own_from_32 = encoding(vec![Object::Integer(32), name("C32"), Object::Integer(70), name("C70"), name("C0071")]); + let pages = [ + one_per_line(b"FGHI\xe9'\xae"), + one_per_line(b"F\xe9"), + one_per_line(b"\x01\x02@"), + one_per_line(b"\x01\x20\x41\x42\x50"), + one_per_line(b"\x09FG"), + b"BT /F1 20 Tf 20 290 Td <462047> Tj ET".to_vec(), + ]; + let pdf = encoded_pages(&[ + (own, &pages[0][..]), + (one_other, &pages[1][..]), + (numbered_otherwise, &pages[2][..]), + (counter, &pages[3][..]), + (own_and_a_control, &pages[4][..]), + (own_from_32, &pages[5][..]), + ]); + let lines = |page| { + let text = extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().plain_text(); + text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect::>() + }; + assert_eq!(lines(0), ["F", "G", "H", "I", "\u{e9}", "'", "\u{ae}"]); + assert!(lines(1).is_empty(), "/C1 at 233 beside /C70 at 70: {:?}", lines(1)); + assert!(lines(2).is_empty(), "/MT40 at 0x40 among names numbering other codes: {:?}", lines(2)); + assert!(lines(3).is_empty(), "a first-use counter /C1 … /C80 from code 1: {:?}", lines(3)); + assert!(lines(4).is_empty(), "/C9 at 9 beside /C70 at 70: {:?}", lines(4)); + let page5 = extract_page(&pdf, 5, &InterpreterSettings::default()).unwrap().plain_text(); + assert_eq!(page5.trim_matches('\n'), "F G", "/C32 at 32 beside /C70 at 70 reads as a space"); +} + +/// `numbered_glyph_names_read_as_their_own_code` in a Type 3 font, which already reads codes +/// 32-126 by the code: above 126, `C174` at 174, `C176` at 176 and `GE9` at 0xE9 read as the +/// code's Latin-1 character, unless one numbered name in the font numbers another code +/// (`/C1` at 233) or sits at a control code, as a first-use counter's `/C1` at 1 does. +#[test] +fn numbered_type3_glyph_names_read_as_their_own_code() { + use lopdf::{Document, Object, Stream, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + // Each font's /Differences as (code, glyph name) pairs, and the codes its page shows. + type Names<'a> = &'a [(i64, &'a str)]; + let fonts: [(Names, &[u8]); 3] = [ + (&[(70, "C70"), (174, "C174"), (176, "C176"), (233, "GE9")], b"F\xae\xb0\xe9"), + (&[(174, "C174"), (176, "C176"), (233, "C1")], b"\xae\xb0\xe9"), + (&[(1, "C1"), (2, "C2"), (174, "C174"), (176, "C176")], b"\xae\xb0"), + ]; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut kids = Vec::new(); + for (names, codes) in fonts { + let glyph = doc.add_object(Stream::new(dictionary! {}, b"600 0 0 0 500 700 d1 0 0 500 700 re f".to_vec())); + let mut differences = Vec::new(); + let mut procs = lopdf::Dictionary::new(); + for &(code, glyph_name) in names { + differences.extend([Object::Integer(code), name(glyph_name)]); + procs.set(glyph_name, glyph); + } + let font = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "Type3", + "FontBBox" => vec![0.into(), 0.into(), 1000.into(), 1000.into()], + "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()], + "CharProcs" => procs, + "Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences }, + "FirstChar" => 0, "LastChar" => 255, "Widths" => vec![Object::Integer(600); 256], + }); + // One glyph per line, as in `glyph_names_outside_the_glyph_list_still_read_as_text`. + let mut content = b"BT /T3 20 Tf 20 290 Td".to_vec(); + for code in codes { + content.extend_from_slice(format!(" <{code:02x}> Tj 0 -25 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let content = doc.add_object(Stream::new(dictionary! {}, content)); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages_id, "MediaBox" => vec![0.into(), 0.into(), 300.into(), 300.into()], "Resources" => dictionary! {"Font" => dictionary! {"T3" => font}}, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages_id, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let lines = |page| { + let text = extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().plain_text(); + text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect::>() + }; + assert_eq!(lines(0), ["F", "\u{ae}", "\u{b0}", "\u{e9}"]); + assert!(lines(1).is_empty(), "/C1 at 233 beside /C174 at 174: {:?}", lines(1)); + assert!(lines(2).is_empty(), "a counter's /C1 at 1 beside /C174 at 174: {:?}", lines(2)); +} + /// Review on #3239: the glyph list specification's `uni` and `u` forms take uppercase hex /// digits only, and map a name with lowercase ones to nothing ("uni20ac" in its examples). /// Names upstream's own lookup can't read (several groups, a suffix) show it: lowercase reads -/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` is still read by -/// upstream's lenient `unicode_from_name`, outside this mapping. +/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` reads as nothing too +/// (`agl_unicode_from_name_rule_reads_text`). #[test] fn glyph_names_with_lowercase_hex_read_as_nothing() { use lopdf::{Object, dictionary}; @@ -719,6 +856,34 @@ fn glyph_names_with_lowercase_hex_read_as_nothing() { assert_eq!(lines, ["\u{20ac}\u{308}", "\u{1040c}", "f", "f\u{20ac}"], "{text:?}"); } +/// The glyph list specification's `uni` form is `uni` and groups of exactly four uppercase hex +/// digits, its `u` form `u` and four to six, outside the surrogate range: `uni65`, `u65`, +/// `uni20ac`, `u1f600`, `uni+065` and `uniD800` name no character, and their glyphs read as +/// nothing rather than as `e`, `€` or an emoji. The well-formed names read as before. +#[test] +fn agl_unicode_from_name_rule_reads_text() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let names = ["uni65", "u65", "uni20ac", "u1f600", "uni+065", "uniD800", "u10FFFF0", "uni0065", "u0066", "u1F600", "uni20AC"]; + let differences = Object::Dictionary(dictionary! { + "Type" => "Encoding", + "Differences" => std::iter::once(Object::Integer(65)).chain(names.iter().map(|n| name(n))).collect::>(), + }); + // One glyph per line, so each name's text stays its own (and a name read as nothing + // leaves no line). + let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec(); + for code in (b'A'..).take(names.len()) { + content.extend_from_slice(b" ("); + content.push(code); + content.extend_from_slice(b") Tj 0 -25 Td"); + } + content.extend_from_slice(b" ET"); + let pdf = encoded_pages(&[(differences, &content[..])]); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text(); + let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect(); + assert_eq!(lines, ["e", "f", "\u{1f600}", "\u{20ac}"], "{text:?}"); +} + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a @@ -770,6 +935,113 @@ fn vertical_ligatures_stay_in_their_column() { assert_eq!(text.line_rects(0..3).len(), 1, "one column"); } +/// A column of Latin capitals in a vertical font, tracked out with negative character spacing +/// (pdf.js issue 20930: `-40 Tc` puts 40 pt between 24 pt letters). Across the column each glyph +/// is as wide as its `/W` width w0 and centred on the column (ISO 32000-2 §9.7.4.3), so the +/// letters stay one line and Find matches the word. Boxes as wide as each outline made a narrow +/// letter's line allowance smaller than the tracking, and the column broke into lines. +#[test] +fn tracked_vertical_latin_stays_one_line() { + tracked_vertical_latin(-40.0, 100.0); +} + +#[test] +fn horizontally_scaled_vertical_latin_box_is_scaled_across_the_column() { + // Horizontal scaling Th = Tz / 100 scales the glyph's width w0 across the column (ISO 32000-2 + // §9.4.4, the text rendering matrix), and not the advance down it. The tracking is 20 pt here: + // the line's gap allowance is three times the narrower box across it, 3 × 8 pt at 50 Tz. + tracked_vertical_latin(-20.0, 50.0); +} + +/// `ABCDE` down one Identity-V column at 24 pt with `tc Tc` and `tz Tz`. +fn tracked_vertical_latin(tc: f32, tz: f32) { + let text = vertical_latin( + &[(36, 667), (37, 667), (38, 722), (39, 722), (40, 667)], + None, + &format!("BT /F1 24 Tf {tc} Tc {tz} Tz 1 0 0 1 100 340 Tm <00240025002600270028> Tj ET"), + ); + assert_eq!(text.plain_text(), "ABCDE"); + assert_eq!(text.find("ABCDE"), vec![0..5]); + // Each box spans w0 (here 24 pt × 0.667 or 0.722, times Th) centred on the column at x = 100. + assert_eq!(text.glyphs.len(), 5); + for (g, w0) in text.glyphs.iter().zip([667.0, 667.0, 722.0, 722.0, 667.0]) { + let [x0, _, x1, _] = g.rect; + assert!((x1 - x0 - 24.0 * w0 / 1000.0 * tz / 100.0).abs() < 0.01, "{:?} is w0 wide: {:?}", g.text, g.rect); + assert!(((x0 + x1) / 2.0 - 100.0).abs() < 0.01, "{:?} is centred on the column: {:?}", g.text, g.rect); + } +} + +/// The text of a page showing `content` in a vertical CIDFontType2 `/F1` with `/DW 1000`, the +/// `widths` (CID, w0) in `/W`, and CIDs 36 to 41 reading `A` to `F`. `cmap` is the body of an +/// embedded vertical CMap mapping codes to CIDs (`begincidchar` … `endcidchar`); `None` uses +/// Identity-V. +fn vertical_latin(widths: &[(i64, i64)], cmap: Option<&str>, content: &str) -> crate::text::PageText { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let to_unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0024> <0041> <0025> <0042> <0026> <0043> <0027> <0044> <0028> <0045> <0029> <0046> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let unicode = doc.add_object(Stream::new(dictionary! {}, to_unicode.to_vec())); + let widths: Vec = widths.iter().flat_map(|&(cid, w)| [Object::Integer(cid), Object::Array(vec![Object::Integer(w)])]).collect(); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestVerticalLatin", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 1000, "W" => widths, "DW2" => vec![Object::Integer(880), Object::Integer(-1000)], "CIDToGIDMap" => "Identity" + }); + let encoding = match cmap { + Some(body) => { + let stream = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def /CMapName /TestCodes-V def /CMapType 1 def /WMode 1 def 1 begincodespacerange <0000> endcodespacerange {body} endcmap CMapName currentdict /CMap defineresource pop end end" + ); + Object::Reference(doc.add_object(Stream::new( + dictionary! { "Type" => "CMap", "CMapName" => "TestCodes-V", "WMode" => 1, "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 } }, + stream.into_bytes(), + ))) + } + None => Object::Name(b"Identity-V".to_vec()), + }; + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVerticalLatin", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let content = doc.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(500)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap() +} + +/// A CID that `/W` doesn't list is `/DW` wide across the column (ISO 32000-2 §9.7.4.3), and the +/// widths are looked up by CID, not by character code: an embedded vertical CMap maps the codes +/// `<0024>`… (which `/W` lists 500 wide as CIDs) to CIDs 50…. The tracked column stays one line +/// that Find matches. +#[test] +fn vertical_latin_widths_come_from_dw_and_are_looked_up_by_cid() { + let cmap = "5 begincidchar <0024> 50 <0025> 51 <0026> 52 <0027> 53 <0028> 54 endcidchar"; + // CID 52 (`C`) has no /W entry: it is /DW = 1000 wide. + let widths = [(36, 500), (37, 500), (38, 500), (39, 500), (40, 500), (50, 667), (51, 667), (53, 722), (54, 667)]; + let text = vertical_latin(&widths, Some(cmap), "BT /F1 24 Tf -40 Tc 1 0 0 1 100 340 Tm <00240025002600270028> Tj ET"); + assert_eq!(text.plain_text(), "ABCDE"); + assert_eq!(text.find("ABCDE"), vec![0..5]); + let got: Vec = text.glyphs.iter().map(|g| g.rect[2] - g.rect[0]).collect(); + assert_eq!(got.len(), 5); + for (w, w0) in got.iter().zip([667.0, 667.0, 1000.0, 722.0, 667.0]) { + assert!((w - 24.0 * w0 / 1000.0).abs() < 0.01, "{got:?}"); + } +} + +/// Two vertical columns of `/DW`-wide letters set 13.5 pt apart at 20 pt overlap across the +/// column (each box is 20 pt wide), and still read as two lines, in order, with Find matching +/// each column and not a word spanning both. +#[test] +fn close_vertical_columns_of_dw_wide_letters_stay_apart() { + let text = vertical_latin(&[], None, "BT /F1 20 Tf 1 0 0 1 113.5 340 Tm <002400250026> Tj 1 0 0 1 100 340 Tm <002700280029> Tj ET"); + assert_eq!(text.plain_text().lines().map(str::trim).collect::>(), ["ABC", "DEF"]); + assert_eq!(text.find("ABC").len(), 1); + assert_eq!(text.find("DEF").len(), 1); + assert!(text.find("CD").is_empty() && text.find("ABCDEF").is_empty(), "{:?}", text.plain_text()); +} + #[test] fn upright_mapping_round_trips() { let r = [10.0, 20.0, 30.0, 25.0]; @@ -2141,32 +2413,285 @@ fn text_outside_the_crop_box_is_clipped() { } } - // A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region, - // even when a glyph straddles the clamped intersection point. - for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] { + // A CropBox that is zero-area or disjoint from the MediaBox is read as a missing one + // (vendored hayro-syntax patch 38): the visible region is the MediaBox, as on a page without + // a CropBox, not a region of no area. + let text_with = |crop: Option<[i64; 4]>| { let mut doc = Document::with_version("1.7"); let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec())); let pages = doc.new_object_id(); - let page = doc.add_object(dictionary! { + let mut page = dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], - "CropBox" => crop.map(Object::Integer).to_vec(), "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, "Contents" => content, - }); + }; + if let Some(crop) = crop { + page.set("CropBox", crop.map(Object::Integer).to_vec()); + } + let page = doc.add_object(page); doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); doc.trailer.set("Root", catalog); let mut bytes = Vec::new(); doc.save_to(&mut bytes).unwrap(); let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); - let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); - assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text()); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() + }; + let media = text_with(None); + assert!(media.contains("LeftOut") && media.contains("AboveOut") && !media.contains("OutSpan"), "{media:?}"); + for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] { + assert_eq!(text_with(Some(crop)), media, "{crop:?}"); } } +/// A one-page document showing `codes` in a Type 0 font with `encoding` over an embedded +/// `descendant` (`CIDFontType0` takes the bundled CFF program, `CIDFontType2` a TrueType one) +/// of the Adobe-Japan1 collection, with `to_unicode` as its `/ToUnicode` CMap when given. +fn japan1_page(encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String { + collection_page(("Adobe", "Japan1"), encoding, descendant, to_unicode, codes) +} + +/// [`japan1_page`] for the character collection `(registry, ordering)`. +fn collection_page(collection: (&str, &str), encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let (registry, ordering) = collection; + let mut doc = Document::with_version("1.7"); + let (key, program): (&str, &[u8]) = if descendant == "CIDFontType0" { + ("FontFile3", include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb")) + } else { + ("FontFile2", include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")) + }; + let mut file = dictionary! {}; + if descendant == "CIDFontType0" { + file.set("Subtype", "CIDFontType0C"); + } + let program = doc.add_object(Stream::new(file, program.to_vec())); + let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "TestJapan1", "Flags" => 4, key => program }); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => descendant, "BaseFont" => "TestJapan1", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal(registry), "Ordering" => Object::string_literal(ordering), "Supplement" => 6 }, + "FontDescriptor" => descriptor, "DW" => 1000, "CIDToGIDMap" => "Identity" + }); + let mut font = dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestJapan1", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)] }; + if let Some(cmap) = to_unicode { + font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec()))); + } + let font = doc.add_object(font); + let content = doc.add_object(Stream::new(dictionary! {}, format!("BT /F1 20 Tf 1 0 0 1 20 250 Tm <{codes}> Tj ET").into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() +} + +/// An embedded font of an Adobe character collection reads as text through the collection's +/// UCS2 CMap, code to CID to Unicode (ISO 32000-2 §9.10.2), where its `/ToUnicode` is missing or +/// has nothing for a code: with Identity-H or a predefined CMap such as `/H`, CFF or TrueType. +/// Only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts +/// are embedded without a `/ToUnicode` had no text to select, find or copy. `/ToUnicode` still +/// comes first where it maps a code. +#[test] +fn an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap() { + // Adobe-Japan1 CIDs 34, 1125 and 843: A, 亜 and あ. + assert_eq!(japan1_page("Identity-H", "CIDFontType0", None, "00220465034B"), "A亜あ", "Identity-H, CFF, no /ToUnicode"); + assert_eq!(japan1_page("Identity-H", "CIDFontType2", None, "00220465034B"), "A亜あ", "Identity-H, TrueType, no /ToUnicode"); + // `/H` maps JIS X 0208 codes to CIDs: 3021 亜, 2422 あ, 2341 A. + assert_eq!(japan1_page("H", "CIDFontType2", None, "302124222341"), "亜あA", "predefined /H"); + let empty = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Empty def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(japan1_page("Identity-H", "CIDFontType2", Some(empty), "00220465034B"), "A亜あ", "a /ToUnicode with no mappings"); + let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /One def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 1 beginbfchar <0022> <005A> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(japan1_page("Identity-H", "CIDFontType0", Some(one), "00220465034B"), "Z亜あ", "/ToUnicode first, the UCS2 CMap for the rest"); +} + +/// The same route for the other Adobe collections, each through its own UCS2 CMap +/// (Adobe-GB1-UCS2, Adobe-CNS1-UCS2, Adobe-Korea1-UCS2), and for vertical writing (Identity-V) +/// and a predefined CMap over a CFF program. The pairs are read from the bundled CMaps. +#[test] +fn every_adobe_collection_reads_through_its_own_ucs2_cmap() { + for descendant in ["CIDFontType0", "CIDFontType2"] { + // Adobe-GB1 CID 4000 is 姓, Adobe-CNS1 CID 4000 is 團, Adobe-Korea1 CID 2000 is 밧; CID 34 + // is A in each. + for (ordering, codes, text) in [("GB1", "00220FA0", "A姓"), ("CNS1", "00220FA0", "A團"), ("Korea1", "002207D0", "A밧")] { + assert_eq!(collection_page(("Adobe", ordering), "Identity-H", descendant, None, codes), text, "Adobe-{ordering}, {descendant}"); + } + assert_eq!(japan1_page("Identity-V", descendant, None, "00220465034B"), "A亜あ", "Identity-V, {descendant}"); + } + assert_eq!(japan1_page("H", "CIDFontType0", None, "302124222341"), "亜あA", "predefined /H, CFF"); +} + +/// A Type 0 font outside the Adobe collections has no UCS2 CMap: without a `/ToUnicode` its codes +/// give no text through this route (the CIDs mean nothing outside their collection). +#[test] +fn a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap() { + for descendant in ["CIDFontType0", "CIDFontType2"] { + assert_eq!(collection_page(("Example", "Custom"), "Identity-H", descendant, None, "00220465034B"), "", "{descendant}"); + } +} + +/// A Type 0 font whose `/ToUnicode` is written as a CID CMap (`begincidchar`, destinations as +/// numbers, as Word writes it) reads each number as the code's Unicode value (vendored +/// hayro-interpret patch 85), as glyph lookup already did and as pdftotext and PDFium extract it. +/// Read as a bfchar map only, such text extracted nothing (pdf.js `issue9367`). +#[test] +fn a_tounicode_written_as_cid_ranges_gives_text() { + let two = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 begincidchar <0001> 84 <0002> 101 <0004> 116 endcidchar 1 begincidrange <0003> <0003> 120 endcidrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(two), "Text", "two-byte entries"); + // Entries written with four-byte codes are found too (review on #6359). + let four = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <00000000> endcodespacerange 2 begincidchar <00000001> 84 <00000002> 101 endcidchar 1 begincidrange <00000003> <00000004> 120 endcidrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(four), "Texy", "four-byte entries"); +} + +/// When a `/ToUnicode` gives one code both a bf entry and a CID entry, the bf entry is the +/// code's text (ISO 32000-2 §9.10.3 defines `/ToUnicode` by `beginbfchar` / `beginbfrange`); the +/// CID number is read only for codes no bf entry covers (vendored hayro-interpret patch 85). +#[test] +fn a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code() { + // Codes 1 and 3 have both kinds of entry: bf says `T` / `x`, CID says `X` / `Y`. Codes 2 and 4 + // have CID entries only. + let two = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 begincidchar <0001> 88 <0002> 101 <0004> 116 endcidchar 1 begincidrange <0003> <0003> 89 endcidrange 1 beginbfchar <0001> <0054> endbfchar 1 beginbfrange <0003> <0003> <0078> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(two), "Text", "two-byte entries"); + // The same with one-byte entries. For two-byte entries the CID lookup, tried at one byte + // first, already falls back to the bf entries at that length; one-byte entries are where + // reading CID entries before bf entries would show, as `XeYt`. + let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 3 begincidchar <01> 88 <02> 101 <04> 116 endcidchar 1 begincidrange <03> <03> 89 endcidrange 1 beginbfchar <01> <0054> endbfchar 1 beginbfrange <03> <03> <0078> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(one), "Text", "one-byte entries"); +} + +/// The page [`a_tounicode_written_as_cid_ranges_gives_text`] and +/// [`a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code`] read: four codes in a Type 0 font +/// (Inter, Identity-H) whose `/ToUnicode` is `cmap`. +fn cid_range_to_unicode_text(cmap: &[u8]) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let program = doc.add_object(Stream::new(dictionary! {}, include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec())); + let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "Inter-Regular", "Flags" => 4, "FontFile2" => program }); + let cid_font = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "CIDFontType2", + "BaseFont" => "Inter-Regular", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "FontDescriptor" => descriptor, + "CIDToGIDMap" => "Identity", + }); + let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())); + let font = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type0", + "BaseFont" => "Inter-Regular", + "Encoding" => "Identity-H", + "DescendantFonts" => vec![Object::Reference(cid_font)], + "ToUnicode" => to_unicode, + }); + let content = doc.add_object(Stream::new(dictionary! {}, b"BT /F1 20 Tf 20 200 Td <0001000200030004> Tj ET".to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text().trim().to_string() +} + +/// A page box that encloses no area is read as a missing one (vendored hayro-syntax patch 38, +/// ISO 32000-2 §14.11.2): a `/MediaBox [0 0 0 0]` as the default page, a `/CropBox [0 0 0 0]`, +/// or one with nothing in common with the media box, as the media box. The page's visible region +/// had no area, so its text was all outside it and none was extracted (pdf.js +/// `boundingBox_invalid`). Each case is checked on both paths: the text is extracted, and the page +/// renders at the size of the box that stands in, with the text's ink on it. A page's own box +/// without area gives way to an inherited one with area; an inherited box without area falls +/// through to the default as an own one does. +#[test] +fn text_on_a_page_whose_box_has_no_area_is_extracted() { + use lopdf::{Document, Object, Stream, dictionary}; + type Boxes = &'static [(&'static str, [i64; 4])]; + /// (case, inherited boxes, the page's own boxes, where the word goes, expected size). + type Case = (&'static str, Boxes, Boxes, (i64, i64), (u32, u32)); + /// A one-page document whose `/Pages` node carries `inherited`, and whose page carries `own` + /// and draws "Boxed" at `(x, y)` in Helvetica 30 pt. + fn boxed(inherited: Boxes, own: Boxes, (x, y): (i64, i64)) -> Vec { + let rect = |r: &[i64; 4]| r.iter().map(|v| Object::Integer(*v)).collect::>(); + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let pages = doc.new_object_id(); + let ops = format!("BT /F1 30 Tf {x} {y} Td (Boxed) Tj ET"); + let content = doc.add_object(Stream::new(dictionary! {}, ops.into_bytes())); + let mut page = dictionary! { "Type" => "Page", "Parent" => pages, "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }; + for (key, r) in own { + page.set(*key, rect(r)); + } + let page = doc.add_object(page); + let mut node = dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}; + for (key, r) in inherited { + node.set(*key, rect(r)); + } + doc.objects.insert(pages, node.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + bytes + } + // US Letter (612 × 792 pt, hayro-syntax patch 44) covers 612 × 792 device pixels at scale 1. + const LETTER_PX: (u32, u32) = (612, 792); + let cases: [Case; 7] = [ + ("own MediaBox without area", &[], &[("MediaBox", [0, 0, 0, 0])], (25, 500), LETTER_PX), + ("own CropBox without area", &[], &[("MediaBox", [0, 0, 800, 600]), ("CropBox", [0, 0, 0, 0])], (25, 500), (800, 600)), + ( + "CropBox touching the media box at a corner", + &[], + &[("MediaBox", [0, 0, 600, 800]), ("CropBox", [600, 800, 1000, 1000])], + (25, 500), + (600, 800), + ), + // Text past US Letter but inside the inherited box is kept. + ( + "own MediaBox without area, inherited one with area", + &[("MediaBox", [0, 0, 1200, 1200])], + &[("MediaBox", [0, 0, 0, 0])], + (700, 900), + (1200, 1200), + ), + ( + "own CropBox without area, inherited one with area", + &[("CropBox", [0, 0, 300, 300])], + &[("MediaBox", [0, 0, 600, 800]), ("CropBox", [0, 0, 0, 0])], + (25, 100), + (300, 300), + ), + ("inherited MediaBox without area", &[("MediaBox", [0, 0, 0, 0])], &[], (25, 500), LETTER_PX), + ("inherited CropBox outside the media box", &[("CropBox", [600, 600, 900, 900])], &[("MediaBox", [0, 0, 500, 500])], (25, 400), (500, 500)), + ]; + let mut failures = Vec::new(); + for (case, inherited, own, at, size) in cases { + let bytes = boxed(inherited, own, at); + let pdf = Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap(); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).map(|t| t.plain_text().trim().to_string()); + if text.as_deref() != Some("Boxed") { + failures.push(format!("{case}: text {text:?}")); + } + let mut renderer = crate::PageRenderer::new(std::sync::Arc::new(bytes), crate::RenderConfig::default()); + let out = renderer.render(crate::RenderRequest { page: 0, kind: crate::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let ink = out.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128 && p[1] < 128 && p[2] < 128).count(); + if out.error.is_some() || (out.width, out.height) != size || ink < 50 { + failures.push(format!( + "{case}: rendered {}×{} with {ink} dark pixels ({:?}), expected {}×{} with ink", + out.width, out.height, out.error, size.0, size.1 + )); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + /// The glyph under a point is the first whose box holds it, edges included; between glyphs, none. #[test] fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { diff --git a/crates/render/tests/it/cid_cff_unusable_font_dict.rs b/crates/render/tests/it/cid_cff_unusable_font_dict.rs new file mode 100644 index 0000000000..9b40b5277c --- /dev/null +++ b/crates/render/tests/it/cid_cff_unusable_font_dict.rs @@ -0,0 +1,167 @@ +//! A CID-keyed CFF font (`FontFile3` of subtype `CIDFontType0C`, ISO 32000-2 §9.9 Table 124) +//! selects a font dict for each glyph through its FDSelect, and a glyph's charstring needs the +//! Private DICT of that font dict (Adobe TN #5176). A subsetter can empty the font dicts no kept +//! glyph uses, leaving them without a Private DICT. Those font dicts make only their own glyphs +//! missing; the glyphs of the other font dicts still draw (hayro-interpret patch (93)). +//! +//! The font is built here: `.notdef` and two 500-unit squares, CID 1 in the first font dict and +//! CID 2 in the second, which is empty. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A CFF INDEX with one-byte offsets. +fn index(items: &[&[u8]]) -> Vec { + let mut out = u16::try_from(items.len()).unwrap().to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(1); + let mut offset = 1u8; + out.push(offset); + for item in items { + offset += u8::try_from(item.len()).unwrap(); + out.push(offset); + } + for item in items { + out.extend_from_slice(item); + } + out +} + +/// A DICT integer operand in the 5-byte form, so every DICT has a fixed size. +fn int(value: usize) -> Vec { + let mut out = vec![29]; + out.extend_from_slice(&u32::try_from(value).unwrap().to_be_bytes()); + out +} + +fn top_dict(charset: usize, fd_select: usize, char_strings: usize, fd_array: usize) -> Vec { + let mut dict = Vec::new(); + // ROS comes first in a CID font's Top DICT: (Adobe) (Identity) 0. + for operand in [391, 392, 0] { + dict.extend(int(operand)); + } + dict.extend([12, 30]); + dict.extend(int(charset)); + dict.push(15); + dict.extend(int(char_strings)); + dict.push(17); + dict.extend(int(fd_array)); + dict.extend([12, 36]); + dict.extend(int(fd_select)); + dict.extend([12, 37]); + dict +} + +fn font_dict(private: Option<(usize, usize)>) -> Vec { + let mut dict = Vec::new(); + if let Some((size, offset)) = private { + dict.extend(int(size)); + dict.extend(int(offset)); + dict.push(18); + } + dict +} + +/// The CID-keyed CFF program. The second font dict is always empty; the first has a Private +/// DICT only when `first_has_private`. +fn cid_cff(first_has_private: bool) -> Vec { + let header = vec![1, 0, 4, 1]; + let names = index(&[b"T"]); + let strings = index(&[b"Adobe", b"Identity"]); + let global_subrs = index(&[]); + // Format 0: the CIDs of glyphs 1 and 2. + let charset = vec![0, 0, 1, 0, 2]; + // Format 0: the font dict of each glyph. + let fd_select = vec![0, 0, 0, 1]; + // 0 0 rmoveto 500 0 rlineto 0 500 rlineto -500 0 rlineto endchar. + let square: &[u8] = &[139, 139, 21, 28, 1, 244, 139, 5, 139, 28, 1, 244, 5, 28, 254, 12, 139, 5, 14]; + let char_strings = index(&[&[14], square, square]); + // defaultWidthX 0. + let mut private = int(0); + private.push(20); + + let top_len = index(&[&top_dict(0, 0, 0, 0)]).len(); + let charset_at = header.len() + names.len() + top_len + strings.len() + global_subrs.len(); + let fd_select_at = charset_at + charset.len(); + let char_strings_at = fd_select_at + fd_select.len(); + let fd_array_at = char_strings_at + char_strings.len(); + let first_len = font_dict(first_has_private.then_some((0, 0))).len(); + let private_at = fd_array_at + index(&[&vec![0; first_len], &[]]).len(); + let first = font_dict(first_has_private.then_some((private.len(), private_at))); + let fd_array = index(&[&first, &[]]); + let top = index(&[&top_dict(charset_at, fd_select_at, char_strings_at, fd_array_at)]); + + let mut out = header; + for part in [names, top, strings, global_subrs, charset, fd_select, char_strings, fd_array, private] { + out.extend(part); + } + out +} + +/// A page showing the hex string `codes` at 60 pt in an Identity-H Type0 font over `program` +/// (no `/FontFile3` when `program` is empty). +fn page(program: &[u8], codes: &str) -> Vec { + let font_file = if program.is_empty() { "" } else { " /FontFile3 8 0 R" }; + let body = format!("BT /F1 60 Tf 20 30 Td <{codes}> Tj ET"); + let mut stream = format!("<< /Subtype /CIDFontType0C /Length {} >>\nstream\n", program.len()).into_bytes(); + stream.extend_from_slice(program); + stream.extend_from_slice(b"\nendstream"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEF+Synthetic /Encoding /Identity-H /DescendantFonts [6 0 R] >>".to_vec(), + b"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /ABCDEF+Synthetic /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 1000 >>".to_vec(), + format!("<< /Type /FontDescriptor /FontName /ABCDEF+Synthetic /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80{font_file} >>").into_bytes(), + stream, + ]; + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +#[test] +fn glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one() { + let first = pixels(page(&cid_cff(true), "0001")); + // The square is 30 pt on a side at 60 pt: 900 pixels at scale 1. + assert!(inked(&first) >= 800, "the glyph of the usable font dict drew {} pixels", inked(&first)); + let both = pixels(page(&cid_cff(true), "00010002")); + assert!(both == first, "the glyph of the empty font dict drew something, or the first did not draw"); +} + +/// With no usable font dict the program is not read, as before, and the page still renders. +#[test] +fn a_program_with_no_usable_font_dict_draws_no_square() { + let rgba = pixels(page(&cid_cff(false), "00010002")); + let square = pixels(page(&cid_cff(true), "0001")); + assert!(rgba != square, "a font dict without a Private DICT drew its glyph"); + // The page renders exactly as it does with no embedded program at all. + assert!(rgba == pixels(page(&[], "00010002")), "the program was kept although no font dict is usable"); +} diff --git a/crates/render/tests/it/fill_stroke_knockout.rs b/crates/render/tests/it/fill_stroke_knockout.rs new file mode 100644 index 0000000000..7c65dd81d4 --- /dev/null +++ b/crates/render/tests/it/fill_stroke_knockout.rs @@ -0,0 +1,166 @@ +//! Contributor-original synthetic pages for combined fill-and-stroke operators (`B`, `B*`, `b`, +//! `b*`) and text rendering modes 2 and 6 (`FillStroke`, `FillAndStrokeAndClip`) under +//! transparency, blend modes, and soft masks (ISO 32000-2 §11.7.4.4). + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +fn pdf_with_content(content: &str) -> Vec { + let mask_stream = b"0.5 g 0 0 200 200 re f"; + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \ + /Group << /S /Transparency /CS /DeviceRGB >> \ + /Contents 4 0 R \ + /Resources << \ + /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> \ + /ExtGState << \ + /GS50 << /Type /ExtGState /ca 0.5 /CA 0.5 >> \ + /GSStroke50 << /Type /ExtGState /ca 1.0 /CA 0.5 >> \ + /GSMul << /Type /ExtGState /BM /Multiply /ca 1.0 /CA 1.0 >> \ + /GSMask << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 5 0 R >> >> \ + >> \ + >> >>" + .to_vec(), + stream(b"", content.as_bytes()), + stream( + b"/Type /XObject /Subtype /Form /BBox [0 0 200 200] \ + /Group << /S /Transparency /CS /DeviceGray /I true >> ", + mask_stream, + ), + ]; + + 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", i + 1).as_bytes()); + out.extend_from_slice(object); + out.extend_from_slice(b"\nendobj\n"); + } + 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 << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict_entries: &[u8], body: &[u8]) -> Vec { + let mut s = b"<< ".to_vec(); + s.extend_from_slice(dict_entries); + s.extend_from_slice(format!("/Length {} >>\nstream\n", body.len()).as_bytes()); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +struct Page(Vec); + +impl Page { + fn render(content: &str) -> Self { + let mut renderer = PageRenderer::new(Arc::new(pdf_with_content(content)), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{content}: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 200), "{content}"); + Self(page.rgba.to_vec()) + } + + fn at(&self, x: usize, y: usize) -> [u8; 3] { + let i = ((199 - y) * 200 + x) * 4; + match self.0.get(i..i + 3) { + Some([r, g, b]) => [*r, *g, *b], + _ => panic!("pixel ({x}, {y}) is out of bounds"), + } + } + + fn assert_close(&self, x: usize, y: usize, want: [u8; 3], label: &str) { + let got = self.at(x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{label} at ({x}, {y}): got {got:?}, expected {want:?}"); + } +} + +#[test] +fn fill_stroke_path_knocks_out_fill_under_stroke() { + // 50% red fill (`ca = 0.5`) and 50% blue 20 pt stroke (`CA = 0.5`) on a white page. + // The stroke spans x = 30..50 along the left edge of the 40..160 square: + // - outer half of stroke at (35, 100) is over white -> [128, 128, 255] + // - inner half of stroke at (45, 100) knocks out the fill -> [128, 128, 255] + // - interior fill at (100, 100) is 50% red over white -> [255, 128, 128] + for op in ["40 40 120 120 re B", "40 40 120 120 re B*", "40 40 m 160 40 l 160 160 l 40 160 l b", "40 40 m 160 40 l 160 160 l 40 160 l b*"] { + let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w {op} Q")); + page.assert_close(100, 100, [255, 128, 128], &format!("{op} fill center")); + page.assert_close(35, 100, [128, 128, 255], &format!("{op} outer stroke")); + page.assert_close(45, 100, [128, 128, 255], &format!("{op} inner stroke")); + } + + // Even when fill alpha is 1.0 (`ca = 1.0`) and only stroke alpha is 0.5 (`CA = 0.5`), + // the semi-transparent stroke knocks out the opaque fill beneath it and composites + // directly with the white backdrop. + let page = Page::render("q /GSStroke50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q"); + page.assert_close(100, 100, [255, 0, 0], "opaque fill center"); + page.assert_close(35, 100, [128, 128, 255], "CA=0.5 outer stroke"); + page.assert_close(45, 100, [128, 128, 255], "CA=0.5 inner stroke knocks out opaque fill"); + + // Control: separate `f` followed by `S` are two distinct graphics objects, so the stroke + // composites over the already-painted fill: 0.5 * (0, 0, 255) + 0.5 * (255, 128, 128) = (128, 64, 191). + let separate = Page::render("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re f 40 40 120 120 re S Q"); + separate.assert_close(35, 100, [128, 128, 255], "separate outer stroke"); + separate.assert_close(45, 100, [128, 64, 191], "separate inner stroke over fill"); +} + +#[test] +fn fill_stroke_path_applies_blend_mode_and_soft_mask_once() { + // Under `/BM /Multiply` on a (0.8, 0.8, 0.5) backdrop, `B` knocks out the yellow fill + // (1.0, 0.8, 0.0) under the cyan stroke (0.0, 0.8, 1.0) before multiplying with the backdrop: + // - fill center: (1.0 * 0.8, 0.8 * 0.8, 0.0 * 0.5) = [204, 163, 0] + // - both halves of the stroke: (0.0 * 0.8, 0.8 * 0.8, 1.0 * 0.5) = [0, 163, 128] + let mul = Page::render( + "0.8 0.8 0.5 rg 0 0 200 200 re f \ + q /GSMul gs 1 0.8 0 rg 0 0.8 1 RG 20 w 40 40 120 120 re B Q", + ); + mul.assert_close(100, 100, [204, 163, 0], "Multiply fill center"); + mul.assert_close(35, 100, [0, 163, 128], "Multiply outer stroke"); + mul.assert_close(45, 100, [0, 163, 128], "Multiply inner stroke"); + + // Under a 50% luminosity `/SMask`, `B` masks the combined knockout group once: + // - fill center: 50% red over white = [255, 128, 128] + // - both halves of the stroke: 50% blue over white = [128, 128, 255] + let masked = Page::render("q /GSMask gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q"); + masked.assert_close(100, 100, [255, 128, 128], "SMask fill center"); + masked.assert_close(35, 100, [128, 128, 255], "SMask outer stroke"); + masked.assert_close(45, 100, [128, 128, 255], "SMask inner stroke"); +} + +#[test] +fn text_rendering_modes_2_and_6_knock_out_fill_under_stroke() { + for mode in [2, 6] { + let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td {mode} Tr (I) Tj ET Q")); + // Both the 50% red fill ([255, 128, 128]) and the 50% blue stroke ([128, 128, 255]) over + // white have G >= 128. With knockout, every pixel (fill, inner stroke, outer stroke, and + // anti-aliased edges) stays at G >= 126; without knockout, the inner half of the stroke + // composites over the fill to [128, 64, 191] (G = 64). + let mut stroke_pixels = 0usize; + let mut fill_pixels = 0usize; + for &[r, g, b, _] in page.0.as_chunks::<4>().0 { + assert!(g >= 126, "mode {mode} Tr pixel [{r}, {g}, {b}] composited stroke over fill"); + if b >= 253 && r <= 130 { + stroke_pixels += 1; + } else if r >= 253 && b <= 130 { + fill_pixels += 1; + } + } + assert!(stroke_pixels > 200, "mode {mode} Tr should paint stroke pixels"); + assert!(fill_pixels > 100, "mode {mode} Tr should paint interior fill pixels"); + } + + // Mode 6 (`FillAndStrokeAndClip`) also clips subsequent drawing after `ET` to the glyph outline. + let clipped = Page::render( + "q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td 6 Tr (I) Tj ET \ + 1.0 ca 0 1 0 rg 0 0 200 200 re f Q", + ); + // Outside the glyph (10, 10), the green rectangle is clipped away. + clipped.assert_close(10, 10, [255, 255, 255], "outside 6 Tr glyph clip"); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7e8307d908..1a50693fb2 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -1,6 +1,8 @@ //! The crate's integration tests, linked into one test binary. +mod cid_cff_unusable_font_dict; mod cid_fallback_query; +mod fill_stroke_knockout; mod inline_image_ei; mod jbig2_invariants; mod knockout_backdrop; @@ -13,6 +15,9 @@ mod search; mod separation_all; mod stray_q_in_a_form; mod stroke_degenerate_cubic; +mod stroke_zero_end_tangent; mod substitute_widths; +mod symbol_mt_builtin_encoding; mod tiling_cell_placement; mod type3_text_clip; +mod widgets_above_annotations; diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 44fbe49350..5b2a1e4c46 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -12,28 +12,53 @@ fn page(parts: &[&str]) -> PageText { // The original straightforward search is an independent oracle for valid page metadata. fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; - let needle: Vec = fold(query).split_whitespace().collect::>().join(" ").chars().collect(); + // Soft hyphens and zero width characters draw nothing and are skipped on both sides. + let visible = + |s: String| s.chars().filter(|c| !matches!(c, '\u{AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')).collect::(); + let needle: Vec = visible(fold(query)).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() || limit == 0 { return Vec::new(); } - let mut chars = Vec::new(); + // Each character, its glyph, and whether a skipped zero width space came right before it. + let mut chars: Vec<(char, usize, bool)> = Vec::new(); + let mut after_zwsp = false; for (i, glyph) in page.glyphs.iter().enumerate() { - if i > 0 && (page.space_before[i] || page.line_of[i] != page.line_of[i - 1]) && chars.last().is_some_and(|&(c, _)| c != ' ') { - chars.push((' ', i)); + // A line that ends in a soft hyphen, past any empty or other invisible glyphs and + // characters after it on that line, runs on to the next. + let ends_in_soft_hyphen = |end: usize| { + (0..=end) + .rev() + .take_while(|&k| page.line_of[k] == page.line_of[end]) + .flat_map(|k| page.glyphs[k].text.chars().rev()) + .find(|&c| c == '\u{AD}' || !visible(c.to_string()).is_empty()) + == Some('\u{AD}') + }; + let line_break = i > 0 && page.line_of[i] != page.line_of[i - 1] && !ends_in_soft_hyphen(i - 1); + if i > 0 && (page.space_before[i] || line_break) && chars.last().is_some_and(|&(c, ..)| c != ' ') { + chars.push((' ', i, false)); + after_zwsp = false; } for c in fold(&glyph.text).chars() { + after_zwsp |= c == '\u{200B}'; + if visible(c.to_string()).is_empty() { + continue; + } let c = if c.is_whitespace() { ' ' } else { c }; - if c != ' ' || chars.last().is_none_or(|&(last, _)| last != ' ') { - chars.push((c, i)); + if c != ' ' || chars.last().is_none_or(|&(last, ..)| last != ' ') { + chars.push((c, i, after_zwsp && c != ' ')); + after_zwsp = false; } } } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, _)| c.is_alphanumeric()); + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric()); + // A word ends at a skipped zero width space as it does at a space. + let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp); let mut hits = Vec::new(); let mut start = 0; while start + needle.len() <= chars.len() { let end = start + needle.len(); - if (!whole || (!word(start.checked_sub(1)) && !word(Some(end)))) && chars[start..end].iter().map(|&(c, _)| c).eq(needle.iter().copied()) { + let bounded = (edge(start) || !word(start.checked_sub(1))) && (edge(end) || !word(Some(end))); + if (!whole || bounded) && chars[start..end].iter().map(|&(c, ..)| c).eq(needle.iter().copied()) { hits.push(chars[start].1..chars[end - 1].1 + 1); if hits.len() == limit { break; @@ -69,7 +94,7 @@ fn whole_word_rejection_keeps_overlapping_candidates() { #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { - let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية"]; + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); @@ -110,3 +135,486 @@ fn long_repeated_prefix_search_preserves_the_last_match_and_limit() { assert_eq!(text.find_opts_limited(&query, false, false, 1), vec![126977..131073]); assert!(text.find(&format!("{}c", "a".repeat(4095))).is_empty()); } + +/// pdf.js issue 13226 stores "Mitarbeiterinnen" with soft hyphens at its hyphenation points, and +/// issue 9186 puts zero width spaces next to the gaps between words. Find skips both characters +/// (and the other invisible joiners) in the page and in the query, and a match still starts and +/// ends on visible letters. +#[test] +fn find_skips_soft_hyphens_and_zero_width_characters() { + let word = page(&["M", "i", "t", "\u{AD}", "a", "r", "b", "e", "i", "\u{AD}", "t", "e", "r", "i", "n", "n", "e", "n"]); + assert_eq!(word.find("Mitarbeiterinnen"), vec![0..18]); + assert_eq!(word.find_opts("Mitarbeiterinnen", true, true), vec![0..18]); + assert_eq!(word.find("arbei"), vec![4..9]); + // Inside one word, a piece of it is not a whole word. + assert!(word.find_opts("terinnen", false, true).is_empty()); + // A query that carries the soft hyphens still matches. + assert_eq!(word.find("Mit\u{AD}arbei\u{AD}terinnen"), vec![0..18]); + + let mut gaps = page(&["is", "\u{200B}", "\u{200B}", "made", "\u{2060}", "as"]); + gaps.space_before = vec![false, false, true, false, false, true]; + assert_eq!(gaps.find("is made"), vec![0..4]); + assert_eq!(gaps.find_opts("made as", false, true), vec![3..6]); + for joiner in ['\u{200C}', '\u{200D}', '\u{2060}', '\u{FEFF}'] { + assert_eq!(page(&["ab", &joiner.to_string(), "cd"]).find("abcd"), vec![0..3], "{joiner:?}"); + // The same character typed in the query is skipped too. + assert_eq!(page(&["ab", "cd"]).find_opts(&format!("ab{joiner}cd"), true, false), vec![0..2], "{joiner:?}"); + } + assert!(page(&["\u{200B}"]).find("\u{200B}").is_empty()); +} + +/// A soft hyphen at a line end is where the word was divided (ISO 32000-2 §14.8.2.3): the break +/// after it is no word break, so "Mitar\u{AD}" ending one line and "beiterinnen" starting the next +/// are found as one word, highlighted on both lines. A line break after any other glyph still +/// separates words. +#[test] +fn find_joins_a_word_divided_at_a_line_end_soft_hyphen() { + let parts: Vec = "Mitar\u{AD}beiterinnen".chars().map(String::from).collect(); + let mut text = page(&parts.iter().map(String::as_str).collect::>()); + for (i, glyph) in text.glyphs.iter_mut().enumerate() { + let x = i as f32 * 10.0; + glyph.rect = [x, 0.0, x + 10.0, 10.0]; + } + // "Mitar" and the soft hyphen on the first line, "beiterinnen" on the second. + for line in text.line_of.iter_mut().skip(6) { + *line = 1; + } + let hits = text.find("Mitarbeiterinnen"); + assert_eq!(hits, vec![0..17]); + assert_eq!(text.line_rects(hits[0].clone()).len(), 2); + assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![0..17]); + assert!(text.find("Mitar beiterinnen").is_empty()); + + // The soft hyphen as the last character of a longer glyph works the same way. + let mut text = page(&["Mitar\u{AD}", "beiterinnen"]); + text.line_of = vec![0, 1]; + assert_eq!(text.find("Mitarbeiterinnen"), vec![0..2]); + + // Without the soft hyphen the line break is a word break. + let mut text = page(&["Mitar", "beiterinnen"]); + text.line_of = vec![0, 1]; + assert!(text.find("Mitarbeiterinnen").is_empty()); + assert_eq!(text.find("Mitar beiterinnen"), vec![0..2]); +} + +/// Horizontal glyphs of `size` points, one per character, each line `lead` points below the +/// last and starting at `x`: (x, first line's top, text) per line. +fn laid_out(lines: &[(f32, f32, &str)], size: f32) -> PageText { + let mut glyphs = Vec::new(); + for &(x0, top, text) in lines { + for (k, c) in text.chars().enumerate() { + let x = x0 + size * k as f32; + let (y0, y1) = (top, top + size); + glyphs.push(TextGlyph { + text: c.into(), + rect: [x, y0, x + size, y1], + quad: [[x, y1], [x + size, y1], [x + size, y0], [x, y0]], + direction: [1.0, 0.0], + }); + } + } + pdfcraft_render::text::layout(glyphs) +} + +#[test] +fn a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines() { + // "検知機構" wraps after "検知" (pdf.js #15340); "ソレノイド" after "ソレ". + let text = laid_out(&[(10.0, 10.0, "装置の検知"), (10.0, 25.0, "機構です"), (10.0, 40.0, "ソレ"), (10.0, 55.0, "ノイド")], 10.0); + assert_eq!(text.line_of.iter().copied().max(), Some(3)); + let hits = text.find("検知機構"); + assert_eq!(hits, vec![3..7]); + // One highlight per line, the end of the first line and the start of the second. + assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 10.0, 60.0, 20.0], [10.0, 25.0, 30.0, 35.0]]); + assert_eq!(text.find("ソレノイド"), vec![9..14]); + // The query typed with a space still finds nothing across the joined break. + assert!(text.find("検知 機構").is_empty()); + // Copied text keeps the line break. + assert_eq!(text.text_of(3..7), "検知\n機構"); +} + +#[test] +fn a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text() { + // Latin and Korean separate words with spaces: the line end is one. + let latin = laid_out(&[(10.0, 10.0, "abc"), (10.0, 25.0, "def")], 10.0); + assert_eq!(latin.find("abc def"), vec![0..6]); + assert!(latin.find("abcdef").is_empty()); + let mixed = laid_out(&[(10.0, 10.0, "PDF"), (10.0, 25.0, "検知")], 10.0); + assert_eq!(mixed.find("PDF 検知"), vec![0..5]); + assert!(mixed.find("PDF検知").is_empty()); + let hangul = laid_out(&[(10.0, 10.0, "한국"), (10.0, 25.0, "어")], 10.0); + assert_eq!(hangul.find("한국 어"), vec![0..3]); + assert!(hangul.find("한국어").is_empty()); + // Two cells on one row read as two lines, but the second does not continue the first. + let row = laid_out(&[(10.0, 10.0, "東京"), (200.0, 10.0, "大阪")], 10.0); + assert_eq!(row.line_of, vec![0, 0, 1, 1]); + assert!(row.find("東京大阪").is_empty()); + assert_eq!(row.find("東京 大阪"), vec![0..4]); + // Nor does a cell set lower and further along the row: a wrapped line starts back behind the + // end of the line before it. + let staggered = laid_out(&[(10.0, 10.0, "東京"), (200.0, 17.0, "大阪")], 10.0); + assert_eq!(staggered.line_of, vec![0, 0, 1, 1]); + assert!(staggered.find("東京大阪").is_empty()); + assert_eq!(staggered.find("東京 大阪"), vec![0..4]); + // Fullwidth Latin letters and digits are Latin text set wide: their line end is a word break. + let wide = laid_out(&[(10.0, 10.0, "AB"), (10.0, 25.0, "CD"), (10.0, 40.0, "12"), (10.0, 55.0, "34")], 10.0); + assert!(wide.find_opts("ABCD", true, false).is_empty()); + assert_eq!(wide.find_opts("AB CD", true, false), vec![0..4]); + assert!(wide.find("1234").is_empty()); + assert_eq!(wide.find("12 34"), vec![4..8]); +} + +/// Vertical glyphs (WMode 1) of `size` points, one per character, written downwards: each +/// column starts at `top` with its left edge at `x`: (x, top, text) per column, in reading order. +/// Each column is one line, as the text device groups a vertical flow. +fn columns(cols: &[(f32, f32, &str)], size: f32) -> PageText { + let mut text = PageText::default(); + for (line, &(x, top, chars)) in (0u32..).zip(cols) { + for (k, c) in chars.chars().enumerate() { + let y = top + size * k as f32; + // Written downwards: the em box's edge towards the next line is its left one. + text.glyphs.push(TextGlyph { + text: c.into(), + rect: [x, y, x + size, y + size], + quad: [[x, y], [x, y + size], [x + size, y + size], [x + size, y]], + direction: [0.0, 1.0], + }); + text.line_of.push(line); + text.space_before.push(false); + } + } + text +} + +#[test] +fn a_word_divided_between_vertical_columns_is_found_in_both() { + // Japanese set vertically: columns run top to bottom and follow each other to the left. + // "検知機構" breaks after "検知" at the foot of the right column. + let text = columns(&[(40.0, 10.0, "装置の検知"), (25.0, 10.0, "機構です")], 10.0); + let hits = text.find("検知機構"); + assert_eq!(hits, vec![3..7]); + // One highlight per column: the foot of the right one and the head of the left one. + assert_eq!(text.line_rects(hits[0].clone()), vec![[40.0, 40.0, 50.0, 60.0], [25.0, 10.0, 35.0, 30.0]]); + assert_eq!( + text.text_of(3..7), + "検知 +機構" + ); + + // A column that stands to the right of the one before does not continue it. + let back = columns(&[(40.0, 10.0, "装置の検知"), (55.0, 10.0, "機構です")], 10.0); + assert!(back.find("検知機構").is_empty()); + assert_eq!(back.find("検知 機構"), vec![3..7]); +} + +/// A zero width space draws nothing and is skipped, but UAX #29 breaks words on both sides of it: +/// Thai and Khmer write it between words with no visible gap, so each word is still a whole word. +/// The joiners and the soft hyphen join, so they are no boundary. +#[test] +fn a_zero_width_space_is_a_word_boundary_for_whole_words() { + let thai = page(&["สวัสดี", "\u{200B}", "ครับ"]); + assert_eq!(thai.find_opts("ครับ", false, true), vec![2..3]); + assert_eq!(thai.find_opts("สวัสดี", false, true), vec![0..1]); + // The words read together, as typed with no space, still match. + assert_eq!(thai.find_opts("สวัสดีครับ", false, true), vec![0..3]); + // Inside one glyph's text too. + assert_eq!(page(&["ab\u{200B}cd"]).find_opts("cd", false, true), vec![0..1]); + for joiner in ["\u{AD}", "\u{200C}", "\u{200D}", "\u{2060}", "\u{FEFF}"] { + let text = page(&["ab", joiner, "cd"]); + assert!(text.find_opts("cd", false, true).is_empty(), "{joiner:?}"); + assert!(text.find_opts("ab", false, true).is_empty(), "{joiner:?}"); + assert_eq!(text.find_opts("abcd", false, true), vec![0..3], "{joiner:?}"); + } +} + +/// Every range `unspaced` lists joins a line it ends to the next one, on either side of the break; +/// Hangul, parenthesised and circled Hangul, fullwidth Latin and digits, Yi and ASCII keep a space. +#[test] +fn each_unspaced_range_joins_and_spaced_scripts_do_not() { + let joined = [ + '\u{2E80}', + '\u{2F00}', // CJK radicals, Kangxi radicals + '\u{3001}', + '\u{3006}', // CJK symbols and punctuation + '\u{3042}', + '\u{30A2}', // Hiragana, Katakana + '\u{3105}', // Bopomofo + '\u{3190}', + '\u{31A0}', + '\u{31C0}', + '\u{31F0}', // Kanbun, Bopomofo extended, strokes, Katakana extensions + '\u{3220}', + '\u{3300}', + '\u{3400}', // enclosed ideograph, CJK compatibility, Han extension A + '\u{4E2D}', // Han + '\u{F900}', // CJK compatibility ideographs + '\u{FE10}', + '\u{FE30}', + '\u{FE50}', // vertical, compatibility and small forms + '\u{FF01}', + '\u{FF1A}', + '\u{FF3B}', + '\u{FF5B}', + '\u{FFE0}', // fullwidth punctuation and signs + '\u{FF61}', + '\u{FF71}', // halfwidth CJK punctuation, halfwidth Katakana + '\u{16FE0}', + '\u{1AFF0}', + '\u{1B000}', // ideographic symbols, kana extended and supplement + '\u{20000}', + '\u{2F800}', + '\u{30000}', // Han extension B, compatibility supplement, extension G + ]; + for c in joined { + let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0); + assert_eq!(text.find_opts(&format!("中{c}{c}中"), true, false), vec![0..4], "U+{:04X}", c as u32); + } + let spaced = ['\u{AC00}', '\u{3200}', '\u{3260}', '\u{FF10}', '\u{FF21}', '\u{FF41}', '\u{FFA1}', '\u{A000}', 'a']; + for c in spaced { + let text = laid_out(&[(10.0, 10.0, &format!("中{c}")), (10.0, 25.0, &format!("{c}中"))], 10.0); + assert!(text.find_opts(&format!("中{c}{c}中"), true, false).is_empty(), "U+{:04X}", c as u32); + assert_eq!(text.find_opts(&format!("中{c} {c}中"), true, false), vec![0..4], "U+{:04X}", c as u32); + } +} + +/// The two line-end rules meet: a soft hyphen after a Chinese or Japanese character, a line of +/// Chinese that runs on, a German word divided by a soft hyphen, and a line that opens with a zero +/// width space, all on one page. Each break reads as nothing, and plain Latin line ends stay spaces. +#[test] +fn a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine() { + let text = laid_out( + &[ + (10.0, 10.0, "装置の検\u{AD}"), + (10.0, 25.0, "知機構の"), + (10.0, 40.0, "ソレ"), + (10.0, 55.0, "\u{200B}ノイド"), + (10.0, 70.0, "Mitar\u{AD}"), + (10.0, 85.0, "beiterinnen"), + (10.0, 100.0, "abc"), + (10.0, 115.0, "def"), + ], + 10.0, + ); + // Both rules fire at the first break: one hit, highlighted on both lines. + let hits = text.find("検知機構"); + assert_eq!(hits, vec![3..8]); + assert_eq!(text.line_rects(hits[0].clone()).len(), 2); + assert!(text.find("検 知機構").is_empty()); + // A line of Chinese or Japanese with no soft hyphen, and one that opens with a zero width space. + assert_eq!(text.find("機構のソレノイド"), vec![6..15]); + // A soft hyphen between Latin letters. + assert_eq!(text.find_opts("Mitarbeiterinnen", true, true), vec![15..32]); + // Latin with neither: still a word break. + assert_eq!(text.find("abc def"), vec![32..38]); + assert!(text.find("abcdef").is_empty()); +} + +/// Glyphs of `size` points as [`laid_out`] sets them, each line `lead` below the last, with the +/// writing direction turned to `direction` about the first glyph's corner (a unit vector in +/// y-down view space; `[1, 0]` is [`laid_out`]). +fn turned(lines: &[&str], size: f32, lead: f32, direction: [f32; 2]) -> PageText { + let [dx, dy] = direction; + // Towards the next line: the writing direction turned a quarter clockwise in view space. + let [nx, ny] = [-dy, dx]; + let at = |along: f32, across: f32| [100.0 + along * dx + across * nx, 100.0 + along * dy + across * ny]; + let mut glyphs = Vec::new(); + for (l, line) in lines.iter().enumerate() { + for (k, c) in line.chars().enumerate() { + let (a, b) = (size * k as f32, lead * l as f32); + // Baseline edge first, in the writing direction; then the far edge back. + let quad = [at(a, b + size), at(a + size, b + size), at(a + size, b), at(a, b)]; + let xs = quad.map(|p| p[0]); + let ys = quad.map(|p| p[1]); + let rect = [ + xs.iter().copied().fold(f32::MAX, f32::min), + ys.iter().copied().fold(f32::MAX, f32::min), + xs.iter().copied().fold(f32::MIN, f32::max), + ys.iter().copied().fold(f32::MIN, f32::max), + ]; + glyphs.push(TextGlyph { text: c.into(), rect, quad, direction }); + } + } + let mut text = PageText::default(); + for (l, line) in (0u32..).zip(lines) { + for _ in line.chars() { + text.line_of.push(l); + text.space_before.push(false); + } + } + text.glyphs = glyphs; + text +} + +/// Text turned a quarter either way, or upside down, runs on between lines as horizontal text +/// does: the line after is the next one across the turned lines, starting back at the turned +/// line's head. +#[test] +fn a_chinese_or_japanese_word_divided_in_turned_text_is_found_on_both_lines() { + for direction in [[0.0, -1.0], [0.0, 1.0], [-1.0, 0.0]] { + let text = turned(&["装置の検知", "機構です"], 10.0, 15.0, direction); + assert_eq!(text.find("検知機構"), vec![3..7], "{direction:?}"); + assert!(text.find("検知 機構").is_empty(), "{direction:?}"); + } +} + +/// The line end after a soft hyphen is found past other invisible characters that follow it, in +/// the same glyph or in glyphs of their own. +#[test] +fn a_soft_hyphen_line_end_joins_past_invisible_characters() { + let hyphen = laid_out(&[(10.0, 10.0, "Mitar\u{AD}\u{200B}"), (10.0, 25.0, "beiterinnen")], 10.0); + assert_eq!(hyphen.find("Mitarbeiterinnen"), vec![0..18]); + let mut text = page(&["Mitar\u{AD}", "\u{2060}", "beiterinnen"]); + text.line_of = vec![0, 0, 1]; + assert_eq!(text.find("Mitarbeiterinnen"), vec![0..3]); +} + +/// A variation selector after the last Chinese or Japanese character of a line only picks its +/// glyph: the line still runs on to the next. +#[test] +fn a_variation_selector_at_a_line_end_keeps_the_line_running_on() { + let selector = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0); + assert_eq!(selector.find("葛\u{E0100}飾区"), vec![0..4]); + assert!(selector.find("葛\u{E0100} 飾区").is_empty()); +} + +/// A wrapped line is near the one before: a block far down the page keeps its word break. +#[test] +fn a_line_end_does_not_join_a_block_far_below() { + let far = laid_out(&[(10.0, 10.0, "検知"), (10.0, 200.0, "機構")], 10.0); + assert!(far.find("検知機構").is_empty()); + assert_eq!(far.find("検知 機構"), vec![0..4]); +} + +/// A wrapped line runs the same way as the one before: a line in another writing direction keeps +/// its word break, wherever it sits. +#[test] +fn a_line_end_does_not_join_a_line_in_another_direction() { + let mut across = laid_out(&[(10.0, 10.0, "検知"), (10.0, 25.0, "機構")], 10.0); + for g in across.glyphs.iter_mut().skip(2) { + g.direction = [0.0, 1.0]; + } + assert!(across.find("検知機構").is_empty()); + assert_eq!(across.find("検知 機構"), vec![0..4]); +} + +/// A line of one character starts level with the one before, not behind it, and still runs on. +#[test] +fn lines_of_one_character_run_on() { + let single = laid_out(&[(10.0, 10.0, "検"), (10.0, 25.0, "知"), (10.0, 40.0, "機")], 10.0); + assert_eq!(single.find("検知機"), vec![0..3]); +} + +/// A minimal PDF of `objects` (numbered from 1), each with its xref entry. +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The same vertical Japanese as [`a_word_divided_between_vertical_columns_is_found_in_both`], +/// drawn by a page in an Identity-V font: extracted text runs on from the foot of the right +/// column to the head of the left one. +#[test] +fn a_word_divided_between_vertical_columns_of_a_page_is_found_in_both() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let chars: Vec = "装置の検知機構です".chars().collect(); + let pairs: String = (1u32..).zip(&chars).map(|(code, c)| format!("<{code:04X}> <{:04X}> ", *c as u32)).collect(); + let to_unicode = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange {} beginbfchar {pairs}endbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + chars.len() + ); + let content = "BT /F1 10 Tf 1 0 0 1 300 700 Tm <0001000200030004 0005> Tj 1 0 0 1 285 700 Tm <0006000700080009> Tj ET"; + let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 800] /Resources << /Font << /F1 4 0 R >> >> /Contents 7 0 R >>".into(), + "<< /Type /Font /Subtype /Type0 /BaseFont /TestMincho /Encoding /Identity-V /DescendantFonts [5 0 R] /ToUnicode 6 0 R >>".into(), + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /TestMincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /DW 1000 /CIDToGIDMap /Identity >>".into(), + stream("", &to_unicode), + stream("", content), + ]); + let mut renderer = PageRenderer::new(std::sync::Arc::new(bytes), RenderConfig::default()); + let result = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(result.error.is_none(), "{:?}", result.error); + let text = result.text.expect("text"); + assert_eq!(text.find("検知機構"), vec![3..7], "{:?}", text.plain_text()); + assert_eq!(text.line_rects(3..7).len(), 2, "one highlight per column"); + assert!(text.find("検知 機構").is_empty()); +} + +/// A line of Chinese or Japanese that ends in a zero width space drawn with no height still runs +/// on: the break is measured on the glyphs that draw the characters either side of it. +#[test] +fn a_line_end_after_an_invisible_glyph_with_no_height_still_joins() { + let mut text = laid_out(&[(10.0, 10.0, "検知\u{200B}"), (10.0, 25.0, "機構")], 10.0); + if let Some(g) = text.glyphs.get_mut(2) { + g.rect = [30.0, 15.0, 30.0, 15.0]; + g.quad = [[30.0, 15.0]; 4]; + } + assert_eq!(text.find("検知機構"), vec![0..5]); +} + +/// A long run of variation selectors, each a glyph of its own, is searched in time linear in the +/// page: the line-end check doesn't rescan the selectors before every glyph. +#[test] +fn a_long_run_of_variation_selectors_is_searched_quickly() { + let selectors: String = std::iter::once('葛').chain(std::iter::repeat_n('\u{FE00}', 200_000)).collect(); + let text = laid_out(&[(10.0, 10.0, &selectors)], 0.001); + let started = std::time::Instant::now(); + assert!(text.find("飾").is_empty()); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); +} + +/// A variation selector drawn as a glyph of its own with no height at a line end stays in the +/// searchable text, and the line still runs on: the break is measured on the letter it follows. +#[test] +fn a_line_end_after_a_zero_height_variation_selector_glyph_still_joins() { + let mut text = laid_out(&[(10.0, 10.0, "葛\u{E0100}"), (10.0, 25.0, "飾区")], 10.0); + if let Some(g) = text.glyphs.get_mut(1) { + g.rect = [20.0, 15.0, 20.0, 15.0]; + g.quad = [[20.0, 15.0]; 4]; + } + assert_eq!(text.find("葛\u{E0100}飾区"), vec![0..4]); +} + +/// The visible glyphs either side of a break are found however many invisible glyphs stand +/// between them and the break. +#[test] +fn a_line_end_after_many_invisible_glyphs_still_joins() { + let line: String = std::iter::once('検').chain(std::iter::once('知')).chain(std::iter::repeat_n('\u{200B}', 40)).collect(); + let mut text = laid_out(&[(10.0, 10.0, &line), (10.0, 25.0, "機構")], 10.0); + for g in text.glyphs.iter_mut().skip(2).take(40) { + let [x, y] = [g.rect[0], g.rect[1]]; + g.rect = [x, y, x, y]; + g.quad = [[x, y]; 4]; + } + assert_eq!(text.find("検知機構"), vec![0..44]); +} + +/// The reference search and `find` agree on a soft hyphen followed, before the line end, by an +/// empty glyph and an invisible one: both join the word. +#[test] +fn reference_and_find_join_past_empty_and_invisible_glyphs_after_a_soft_hyphen() { + let mut text = page(&["Mitar\u{AD}", "", "\u{200B}", "beiterinnen"]); + text.line_of = vec![0, 0, 0, 1]; + assert_eq!(text.find("Mitarbeiterinnen"), vec![0..4]); + assert_eq!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX), vec![0..4]); + + // A visible glyph after the soft hyphen ends the hyphenated word there: the line end stays a space. + let mut text = page(&["Mitar\u{AD}", "x", "", "\u{200B}", "beiterinnen"]); + text.line_of = vec![0, 0, 0, 0, 1]; + assert!(text.find("Mitarbeiterinnen").is_empty()); + assert!(reference(&text, "Mitarbeiterinnen", false, false, usize::MAX).is_empty()); + assert_eq!(text.find("Mitarx beiterinnen"), vec![0..5]); + assert_eq!(reference(&text, "Mitarx beiterinnen", false, false, usize::MAX), vec![0..5]); +} diff --git a/crates/render/tests/it/stroke_zero_end_tangent.rs b/crates/render/tests/it/stroke_zero_end_tangent.rs new file mode 100644 index 0000000000..50a22f5c7b --- /dev/null +++ b/crates/render/tests/it/stroke_zero_end_tangent.rs @@ -0,0 +1,61 @@ +//! A contributor-original synthetic page: a wide stroke of a path built with `y` (the second +//! control point on the end point, ISO 32000-2 §8.5.2.1 Table 58) was not drawn at all at some widths, +//! because the stroker put NaN into its outline and the rasterizer dropped the whole path. A file +//! in the PDFBox issue tracker lost the 32 pt outline of its "XX" this way. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A one-page 200 × 200 pt PDF drawing `content`, with a cross-reference table. +fn pdf(content: &str) -> Vec { + let objects = [ + "<< /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 200 200] /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×. +fn red_at(content: &str, x: usize, y: usize) -> u8 { + let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + *out.rgba.get(at).expect("the pixel lies on the page") +} + +#[test] +fn a_wide_stroke_of_a_path_with_y_curves_is_drawn() { + // The path's exact coordinates are the trigger; `cm` moves the point where its two curves + // meet, (18.9, 236.3), to about (98.9, 99.7) from the top left, inside every stroke width + // below whatever the caps. The `v` path is the same two curves run backwards, a guard that + // draws with or without the fix. + let y = "21.4166 234.5928 m 19.9009 236.0303 18.9478 236.3428 y 19.2603 238.4209 20.8697 238.4209 y S"; + let v = "20.8697 238.4209 m 19.2603 238.4209 18.9478 236.3428 v 19.9009 236.0303 21.4166 234.5928 v S"; + let mut dropped = Vec::new(); + for path in [y, v] { + for width in [4, 8, 16, 24, 32, 48, 63, 64] { + for (join, cap) in [(0, 0), (1, 1), (2, 2)] { + let content = format!("0 1 0 RG {width} w {join} j {cap} J 1 0 0 1 80 -136 cm {path}"); + let red = red_at(&content, 98, 99); + if red > 64 { + dropped.push(content); + } + } + } + } + assert!(dropped.is_empty(), "strokes not drawn: {dropped:#?}"); +} diff --git a/crates/render/tests/it/symbol_mt_builtin_encoding.rs b/crates/render/tests/it/symbol_mt_builtin_encoding.rs new file mode 100644 index 0000000000..fd9d0d28ca --- /dev/null +++ b/crates/render/tests/it/symbol_mt_builtin_encoding.rs @@ -0,0 +1,113 @@ +//! A non-embedded font named `SymbolMT` (the name Symbol has as a TrueType font) or a style +//! variant such as `Symbol,Bold` is the standard Symbol font, and a non-embedded Symbol draws +//! through its built-in encoding (ISO 32000-2 §9.6.2.2), even when the font dictionary +//! declares `/WinAnsiEncoding` and the nonsymbolic flag: code 0x6D (`m`) is `mu`, as for +//! `/Symbol` itself (hayro-interpret patch (98)). + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A page showing "abmp" at 40 pt in a non-embedded font `font` (the inside of its dictionary). +fn page(font: &str) -> Vec { + page_with(font, None) +} + +/// [`page`] with `program` as object 6, a `/FontFile2` stream the font descriptor can point at. +fn page_with(font: &str, program: Option<&[u8]>) -> Vec { + let body = "BT /F1 40 Tf 10 30 Td (abmp) Tj ET"; + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + format!("<< /Type /Font {font} >>").into_bytes(), + ]; + if let Some(program) = program { + objects.push([format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program.to_vec(), b"\nendstream".to_vec()].concat()); + } + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +/// A TrueType `name` with WinAnsiEncoding, nonsymbolic flags and no font program, as written +/// by spreadsheet exports. +fn win_ansi_true_type(name: &str) -> String { + format!( + "/Subtype /TrueType /BaseFont /{name} /Encoding /WinAnsiEncoding /FontDescriptor << /Type /FontDescriptor /FontName /{name} /Flags 32 /FontBBox [0 -216 1113 693] /ItalicAngle 0 /Ascent 1005 /Descent -216 /CapHeight 693 /StemV 60 >>" + ) +} + +#[test] +fn symbol_mt_draws_symbols_through_the_built_in_encoding() { + let symbol = pixels(page("/Subtype /Type1 /BaseFont /Symbol")); + let latin = pixels(page("/Subtype /Type1 /BaseFont /Helvetica")); + assert!(symbol != latin, "the Symbol stand-in drew the Latin letters"); + for name in ["SymbolMT", "ABCDEF+SymbolMT", "SymbolMT,Bold", "Symbol,Italic", "Symbol-Bold"] { + assert!(pixels(page(&win_ansi_true_type(name))) == symbol, "{name} did not draw as Symbol"); + } + let type1 = "/Subtype /Type1 /BaseFont /SymbolMT /Encoding /WinAnsiEncoding"; + assert!(pixels(page(type1)) == symbol, "a Type1 SymbolMT did not draw as Symbol"); +} + +/// The number of dark pixels on a page. +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +/// Only the Symbol name itself, alone or with a style, counts: a font whose name merely starts +/// with it, or names another font after the separator, keeps its declared encoding and its +/// Latin stand-in, drawing the same letters as Helvetica. +#[test] +fn a_name_that_only_starts_with_symbol_is_not_symbol() { + let latin = pixels(page("/Subtype /Type1 /BaseFont /Helvetica")); + assert!(inked(&latin) > 100, "the Latin reference drew no text"); + for name in ["Symbolic", "SymbolPS", "Symbol-Helvetica", "SymbolMT,Helvetica"] { + assert!(pixels(page(&win_ansi_true_type(name))) == latin, "{name} did not draw the Latin letters"); + } +} + +/// A `/Differences` array still applies to a SymbolMT: code 0x6D named `alpha` draws alpha, as +/// for `/Symbol` with the same differences, not the built-in encoding's `mu`. +#[test] +fn symbol_mt_applies_its_differences() { + let symbol = pixels(page("/Subtype /Type1 /BaseFont /Symbol")); + let alpha = pixels(page("/Subtype /Type1 /BaseFont /Symbol /Encoding << /Differences [109 /alpha] >>")); + assert!(alpha != symbol, "the differences changed nothing"); + let font = win_ansi_true_type("SymbolMT") + .replace("/Encoding /WinAnsiEncoding", "/Encoding << /BaseEncoding /WinAnsiEncoding /Differences [109 /alpha] >>"); + assert!(pixels(page(&font)) == alpha, "SymbolMT did not draw the alpha its differences name"); +} + +/// An embedded font named SymbolMT draws its own program, not the Symbol stand-in. +#[test] +fn an_embedded_symbol_mt_draws_its_own_program() { + let program = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let embedded = |name: &str| { + let font = win_ansi_true_type(name).replace(" >>", " /FontFile2 6 0 R >>"); + pixels(page_with(&font, Some(program))) + }; + let own = embedded("ABCDEF+JetBrainsMono"); + assert!(inked(&own) > 100, "the embedded program drew no text"); + assert!(embedded("ABCDEF+SymbolMT") == own, "an embedded SymbolMT did not draw its own glyphs"); +} diff --git a/crates/render/tests/it/widgets_above_annotations.rs b/crates/render/tests/it/widgets_above_annotations.rs new file mode 100644 index 0000000000..7800fa05cf --- /dev/null +++ b/crates/render/tests/it/widgets_above_annotations.rs @@ -0,0 +1,74 @@ +//! A contributor-original synthetic page: a check box widget listed before a filled square +//! comment that covers it. Form fields' widgets are drawn after the page's other annotations +//! (hayro-interpret patch (86)), so the check box shows; the comment covered it before. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A 200 × 200 pt page whose `/Annots` lists `order` of: `W`, a check box drawing a green box, +/// `S`, a square comment drawing a black one over it, and `R`, a larger red square comment. +fn pdf(order: &str) -> Vec { + let annot = |c: char| match c { + 'W' => "6 0 R", + 'S' => "7 0 R", + _ => "8 0 R", + }; + let annots: Vec<&str> = order.chars().map(annot).collect(); + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >>", annots.join(" ")), + form("0 1 0"), + form("0 0 0"), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (box) /F 4 /Rect [50 50 150 150] /AS /Off /AP << /N << /Off 4 0 R /Yes 4 0 R >> >> >>" + .to_string(), + "<< /Type /Annot /Subtype /Square /F 4 /C [0 0 0] /Rect [40 40 160 160] /AP << /N 5 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Square /F 4 /C [1 0 0] /Rect [30 30 170 170] /AP << /N 9 0 R >> >>".to_string(), + form("1 0 0"), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The colour at the page's centre. +fn centre(order: &str) -> [u8; 3] { + at(order, 100, 100) +} + +/// The colour at (`x`, `y`) px from the top left. +fn at(order: &str, x: usize, y: usize) -> [u8; 3] { + let mut renderer = PageRenderer::new(Arc::new(pdf(order)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + let px = out.rgba.get(at..at + 3).expect("the centre lies on the page"); + [px[0], px[1], px[2]] +} + +#[test] +fn a_widget_is_drawn_above_the_comments_on_its_page() { + assert_eq!(centre("WS"), [0, 255, 0], "a check box listed before a comment over it"); + assert_eq!(centre("SW"), [0, 255, 0], "a check box listed after it"); + // Comments keep their `/Annots` order among themselves. + assert_eq!(centre("RS"), [0, 0, 0], "the black square listed last is on top"); + assert_eq!(centre("SR"), [255, 0, 0], "the red square listed last is on top"); + // And with a widget among them: (45, 45) is inside both squares, outside the check box. + assert_eq!(at("WRS", 45, 45), [0, 0, 0], "a widget first: the black square listed last is on top"); + assert_eq!(at("WSR", 45, 45), [255, 0, 0], "a widget first: the red square listed last is on top"); + assert_eq!(centre("WSR"), [0, 255, 0], "and the check box above both"); +} diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index 4e232af616..ee1882ccdc 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -230,66 +230,94 @@ impl SignedData { } } -fn strip(b: &[u8]) -> &[u8] { - match b { - [0, rest @ ..] if !rest.is_empty() => rest, - _ => b, +fn strip(mut b: &[u8]) -> &[u8] { + while let [0, rest @ ..] = b + && !rest.is_empty() + { + b = rest; } + b +} + +fn parse_signer_id(sid: &Tlv<'_>) -> Result { + match sid.tag { + tag::SEQUENCE => { + let p = sid.children()?; + let [issuer, serial] = p.as_slice() else { return Err(bad("issuerAndSerialNumber")) }; + if issuer.tag != tag::SEQUENCE || serial.tag != tag::INTEGER || serial.value.is_empty() { + return Err(bad("issuerAndSerialNumber")); + } + Ok(SignerId::IssuerSerial { issuer: issuer.raw.to_vec(), serial: serial.value.to_vec() }) + } + t if t == tag::ctx_prim(0) => Ok(SignerId::KeyId(sid.value.to_vec())), + t if t == tag::ctx(0) => { + let key_id = match sid.inner() { + Ok(inner) if matches!(inner.tag, tag::OCTET_STRING | tag::OCTET_STRING_SEGMENTS) => inner.octets("sid")?.into_owned(), + _ => { + let mut seg = *sid; + seg.tag = tag::OCTET_STRING_SEGMENTS; + seg.octets("sid")?.into_owned() + } + }; + Ok(SignerId::KeyId(key_id)) + } + _ => Err(bad("sid")), + } +} + +type SignedAttrs = (Vec, Option>, Option, Option
"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let w = widgets(&form.pages[0]); + let gap = w[1].rect.y - w[0].rect.y; + assert!(gap < 72.0, "the check box took {gap}pt"); +} + #[test] fn the_form_packet_restores_a_caption_a_script_changed() { let body = r#""#; @@ -4630,3 +4872,126 @@ fn a_template_inside_the_data_is_not_the_form() { let real = ""; assert_eq!(parse(real).unwrap().0.root.common.name.as_deref(), Some("form1")); } + +/// A multi-select choice list is bound to a data group of `value` elements, one per pick, and its +/// value is theirs joined by newlines (XFA 3.3 p. 198). New picks replace the group's `value` +/// elements, values off the list included, as Acrobat discards them (p. 199): the group was left +/// as it was, so the old picks came back on reading, in the datasets and in the scripts' data. +#[test] +fn a_multi_select_lists_data_group_takes_its_new_picks() { + let shell = + crate::fixtures::static_shell("ryebarleywheat"); + let mut doc = Document::open(Arc::new(shell)).unwrap(); + let path = som_to_path("form1[0].page1[0].grains[0]"); + assert!(write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written); + let data = crate::script::data_of(&doc).unwrap(); + assert_eq!(data.value_at(&path).as_deref(), Some("wheat\nmillet & oats")); + assert_eq!(data.count(&path, "value"), 2, "one value element per pick"); + assert!(!write_data_value(&mut doc, &path, "wheat\nmillet & oats").unwrap().written, "the same picks change nothing"); + assert!(write_data_value(&mut doc, &path, "").unwrap().written); + assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some(""), "nothing picked"); + // Emptied, the group is marked as one (XFA 3.3 p. 150), so it is still written as picks. + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains(""), "{xdp}"); + assert!( + write_data_value( + &mut doc, + &path, + "rye +millet" + ) + .unwrap() + .written + ); + assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2, "picks again, not text"); + // A pick split or led by a comment is read whole: the same pick is no change, and clearing + // a pick that reads as empty through its first segment alone still clears it. + let commented = + crate::fixtures::static_shell("ryewheat"); + let mut doc = Document::open(Arc::new(commented)).unwrap(); + assert!( + !write_data_value( + &mut doc, + &path, + "rye +wheat" + ) + .unwrap() + .written, + "the same picks, read past the comments" + ); + assert_eq!( + crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), + Some( + "rye +wheat" + ), + "and read back without them" + ); + assert!(write_data_value(&mut doc, &path, "").unwrap().written, "cleared"); + assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 0); + // Where `xfa` names another namespace and `pcxfa` is taken too, the marker's prefix is a free + // one, declared on the group, so no attribute in scope changes meaning. + let shadowed = crate::fixtures::static_shell( + "rye", + ); + let mut doc = Document::open(Arc::new(shadowed)).unwrap(); + assert!(write_data_value(&mut doc, &path, "").unwrap().written); + let xdp = crate::read_packets(&doc).unwrap().unwrap().xdp; + assert!(xdp.contains("xmlns:pcxfa1=\"http://www.xfa.org/schema/xfa-data/1.0/\" pcxfa1:dataNode=\"dataGroup\""), "{xdp}"); + assert!(xdp.contains("xmlns:pcxfa=\"urn:taken\" pcxfa:note=\"x\""), "the taken prefix keeps its namespace: {xdp}"); + // With every candidate prefix taken, no marker can be written: the group keeps its pick + // and the skip is reported, rather than emptied into what would load as a data value. + let taken: String = (0..16).map(|i| if i == 0 { " xmlns:pcxfa=\"urn:t\"".to_string() } else { format!(" xmlns:pcxfa{i}=\"urn:t\"") }).collect(); + let crowded = + crate::fixtures::static_shell(&format!("rye")); + let mut doc = Document::open(Arc::new(crowded)).unwrap(); + let r = write_data_value(&mut doc, &path, "").unwrap(); + assert!(!r.written && r.warnings.iter().any(|w| w.contains("structured content")), "{:?}", r.warnings); + assert_eq!(crate::script::data_of(&doc).unwrap().value_at(&path).as_deref(), Some("rye")); + // One `value` whose text holds a line break is one pick, not the two it would join to. + let one = crate::fixtures::static_shell( + "one +two", + ); + let mut doc = Document::open(Arc::new(one)).unwrap(); + assert!( + write_data_value( + &mut doc, &path, "one +two" + ) + .unwrap() + .written, + "two picks replace the one" + ); + assert_eq!(crate::script::data_of(&doc).unwrap().count(&path, "value"), 2); + // The in-memory data, which scripts read before the packet is written, follows the same rule. + let mut tree = crate::script::data_of( + &Document::open(Arc::new(crate::fixtures::static_shell("rye"))).unwrap(), + ) + .unwrap(); + tree.apply(&DataOp::Value { path: path.clone(), text: "millet\nwheat".into() }); + assert_eq!(tree.value_at(&path).as_deref(), Some("millet\nwheat")); + // Emptied in memory, the group still takes picks, not text. + tree.apply(&DataOp::Value { path: path.clone(), text: String::new() }); + assert_eq!((tree.count(&path, "value"), tree.value_at(&path).as_deref()), (0, Some(""))); + tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() }); + assert_eq!((tree.count(&path, "value"), tree.text_at(&path)), (2, Some("")), "picks again, not text"); + assert_eq!(tree.value_at(&path).as_deref(), Some("rye\nmillet")); + let tree_of = |data: &str| crate::script::data_of(&Document::open(Arc::new(crate::fixtures::static_shell(data))).unwrap()).unwrap(); + // A group read emptied, as the packet writer marks it, takes picks the same way. + let mut tree = tree_of(""); + tree.apply(&DataOp::Value { path: path.clone(), text: "rye\nmillet".into() }); + assert_eq!(tree.count(&path, "value"), 2); + // A node holding anything but `value` leaves is not a list's picks: it is left as it was, + // as the packet writer leaves it (and reports the skip). + for structured in [ + "ryex", + "rye", + ] { + let mut tree = tree_of(structured); + let before = tree.clone(); + tree.apply(&DataOp::Value { path: path.clone(), text: "millet".into() }); + assert_eq!(tree, before, "{structured}"); + } +} diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 96e9179faa..52ce9e9508 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -143,9 +143,9 @@ fn values(doc: &Document) -> Vec<(String, Vec)> { fields(doc).into_iter().map(|f| (f.name, f.value)).collect() } -/// #2666: Replace Text writes a StrikeOut and a Caret that replies to it. XFDF names a reply by -/// the `/NM` of what it replies to, so when both carried one id the caret's `inreplyto` was its -/// own `name`: importing it found a comment replying to itself and the pair was lost. +/// #2666: Replace Text writes a Caret and a StrikeOut grouped under it. XFDF names the link by +/// the `/NM` of the annotation it points at, so when both carried one id the `inreplyto` was the +/// strikeout's own `name`: importing it found a comment replying to itself and the pair was lost. #[test] fn a_replace_text_pair_survives_an_xfdf_round_trip() { let mut src = blank(); @@ -170,8 +170,8 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let xfdf = export_xfdf(&src, true, false, "a.pdf"); assert!(xfdf.contains("name=\"strike\"") && xfdf.contains("name=\"caret\""), "each gets its own name: {xfdf}"); - assert!(xfdf.contains("inreplyto=\"strike\""), "the caret replies to the strikeout: {xfdf}"); - assert!(!xfdf.contains("inreplyto=\"caret\""), "never a reply to itself"); + assert!(xfdf.contains("inreplyto=\"caret\""), "the strikeout is grouped under the caret: {xfdf}"); + assert!(!xfdf.contains("inreplyto=\"strike\""), "never a reply to itself"); let mut dst = blank(); let r = import(&mut dst, xfdf.as_bytes()).unwrap(); @@ -180,6 +180,135 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let kinds: Vec<&str> = found.iter().map(|s| s.subtype.as_str()).collect(); assert!(kinds.contains(&"StrikeOut") && kinds.contains(&"Caret"), "{kinds:?}"); assert_eq!(comment_view(&dst), comment_view(&src), "the pair and its thread survive"); + // Review on #5689 (CodeRabbit): the intents travel too, so the imported pair is still + // Replace Text, and deleting its strikeout takes the caret and its replacement with it. + assert!(xfdf.contains("intent=\"Replace\"") && xfdf.contains("intent=\"StrikeOutTextEdit\""), "{xfdf}"); + let strike = summaries(&dst).into_iter().find(|s| s.subtype == "StrikeOut").expect("the strikeout"); + pdfcraft_annot::delete_annotation(&mut dst, 0, strike.index).unwrap(); + assert!(summaries(&dst).iter().all(|s| s.subtype != "Caret"), "the caret goes with the strikeout"); +} + +/// Review on #5689 (CodeRabbit): XFDF links a reply to its target by the target's `name`, so a +/// group whose primary has no `/NM` lost its `inreplyto` on export and came back as two loose +/// comments. Both layouts: the caret primary (this PR) and the strikeout primary (older files). +#[test] +fn an_unnamed_group_primary_gets_an_export_only_name() { + for caret_primary in [true, false] { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let unnamed = Meta { date: None, id: String::new() }; + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &unnamed, + caret_meta: &unnamed, + }, + ) + .unwrap(); + let page = pdfcraft_model::pages(&src)[0].obj; + let annots = src.get(page).as_dict().unwrap().get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + let refs: Vec = annots.iter().filter_map(Object::as_ref).collect(); + let is = |doc: &Document, r: ObjRef, s: &[u8]| doc.get(r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(s); + let strike = *refs.iter().find(|r| is(&src, **r, b"StrikeOut")).unwrap(); + let caret = *refs.iter().find(|r| is(&src, **r, b"Caret")).unwrap(); + let (primary, sub) = if caret_primary { (caret, strike) } else { (strike, caret) }; + if !caret_primary { + src.update_dict(strike, |d| { + d.remove(b"IRT"); + d.remove(b"RT"); + }) + .unwrap(); + src.update_dict(caret, |d| { + d.set(b"IRT".to_vec(), Object::Ref(strike)); + d.set(b"RT".to_vec(), Object::name("Group")); + }) + .unwrap(); + } + // A comment already named what the made-up name would be: the two must not collide. + let clash = format!("pdfcraft-export-{}-{}", primary.num, primary.generation); + let note = NewAnnotation { + page: 0, + shape: Shape::Note { at: [300.0, 300.0], icon: Default::default() }, + style: style.clone(), + contents: "note".into(), + author: "Ada".into(), + }; + add_annotation(&mut src, ¬e, &Meta { date: None, id: clash.clone() }).unwrap(); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + let doc = roxmltree::Document::parse(&xfdf).unwrap(); + let el = |t: &str| doc.descendants().find(|n| n.has_tag_name(t)).unwrap_or_else(|| panic!("no <{t}>: {xfdf}")); + let (tag, sub_tag) = if caret_primary { ("caret", "strikeout") } else { ("strikeout", "caret") }; + let name = el(tag).attribute("name").unwrap_or_else(|| panic!("the unnamed primary gets a name ({caret_primary}): {xfdf}")); + assert_eq!(el(sub_tag).attribute("inreplyto"), Some(name), "{xfdf}"); + assert_ne!(name, clash, "a made-up name never repeats a real /NM"); + assert_eq!(doc.descendants().filter(|n| n.attribute("name") == Some(name)).count(), 1, "{xfdf}"); + assert!(src.get(primary).as_dict().unwrap().get(b"NM").is_none(), "the name is for the export only"); + assert!(src.get(sub).as_dict().unwrap().get(b"NM").is_none()); + + let mut dst = blank(); + import(&mut dst, xfdf.as_bytes()).unwrap(); + // The imported subordinate is grouped under the imported primary again. + let dpage = pdfcraft_model::pages(&dst)[0].obj; + let dannots = dst.get(dpage).as_dict().unwrap().get(b"Annots").and_then(|a| dst.resolve(a).as_array().cloned()).unwrap(); + let drefs: Vec = dannots.iter().filter_map(Object::as_ref).collect(); + let (p_sub, s_sub): (&[u8], &[u8]) = if caret_primary { (b"Caret", b"StrikeOut") } else { (b"StrikeOut", b"Caret") }; + let dprimary = *drefs.iter().find(|r| is(&dst, **r, p_sub)).unwrap(); + let dsub = *drefs.iter().find(|r| is(&dst, **r, s_sub)).unwrap(); + let irt = dst.get(dsub).as_dict().unwrap().get(b"IRT").and_then(Object::as_ref); + assert_eq!(irt, Some(dprimary), "the group survives the round trip ({caret_primary})"); + } +} + +/// An annotation written inline in `/Annots` (a direct dictionary, not a reference) is exported +/// under its `/NM` too, so a made-up name must not repeat it either. +#[test] +fn an_export_only_name_never_repeats_an_inline_annotations_nm() { + let mut src = blank(); + let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; + let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() }; + let unnamed = Meta { date: None, id: String::new() }; + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &unnamed, + caret_meta: &unnamed, + }, + ) + .unwrap(); + let page = pdfcraft_model::pages(&src)[0].obj; + let mut annots = src.get(page).as_dict().unwrap().get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap(); + let primary = + annots.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().and_then(|d| d.name(b"Subtype")) == Some(&b"Caret"[..])).unwrap(); + // An inline note already named what the made-up name would be. + let clash = format!("pdfcraft-export-{}-{}", primary.num, primary.generation); + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Text")); + note.set(b"NM".to_vec(), PdfString::text(&clash)); + note.set(b"Rect".to_vec(), Object::Array(vec![300.into(), 300.into(), 320.into(), 320.into()])); + annots.push(Object::Dict(note)); + src.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + + let xfdf = export_xfdf(&src, true, false, "a.pdf"); + let doc = roxmltree::Document::parse(&xfdf).unwrap(); + let caret = doc.descendants().find(|n| n.has_tag_name("caret")).unwrap_or_else(|| panic!("no : {xfdf}")); + let name = caret.attribute("name").unwrap_or_else(|| panic!("the unnamed primary gets a name: {xfdf}")); + assert_ne!(name, clash, "a made-up name never repeats an inline annotation's /NM: {xfdf}"); + assert_eq!(doc.descendants().filter(|n| n.attribute("name") == Some(&clash[..])).count(), 1, "{xfdf}"); } #[test] diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index e2137a98f0..3368003483 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -99,31 +99,39 @@ fn read_own_keys(node: roxmltree::Node, d: &mut Dict) { } } -/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one -/// (other tools leave /NM out), a name new to the document for this export, so a reply copied -/// with it still finds it. +/// The name of every annotation, for `inreplyto`: its /NM, or, for one without one (other tools +/// leave /NM out) that is in `only` or that another annotation replies to or is grouped under +/// (`/IRT`, ISO 32000-2 §12.5.6.2; a Replace Text pair is such a group), a name new to the +/// document for this export, so the link survives the round trip. Made-up names are never written +/// into the PDF. fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap { let mut names: HashMap = HashMap::new(); let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new(); + let mut targets: HashSet = HashSet::new(); // Every name in use, those of annotations written straight into the page array too, so a // given name is new. let mut taken: HashSet = HashSet::new(); for (pi, p) in pages.iter().enumerate() { for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { - let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM")); + let d = doc.resolve(&a).as_dict().cloned(); + if let Some(t) = d.as_ref().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref) { + targets.insert(t); + } + let nm = d.as_ref().and_then(|d| text_of(doc, d, b"NM")).filter(|nm| !nm.is_empty()); if let Some(nm) = &nm { taken.insert(nm.clone()); } + // Only an indirect annotation can be an `/IRT` target, so only it is keyed here. let Some(r) = a.as_ref() else { continue }; match nm { Some(nm) => { names.insert(r, nm); } - None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)), - None => {} + None => unnamed.push((pi, ai, r)), } } } + unnamed.retain(|(pi, ai, r)| only.is_some_and(|only| only.contains(&(*pi, *ai))) || targets.contains(r)); for (pi, ai, r) in unnamed { let mut name = format!("pdfcraft-{pi}-{ai}"); while taken.contains(&name) { @@ -180,8 +188,8 @@ fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) { /// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste. fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) { let pages = pdfcraft_model::pages(doc); - // NM of every annotation, for inreplyto. A selected annotation without one (other tools - // leave /NM out) gets a name for this export, so a reply copied with it still finds it. + // NM of every annotation, for inreplyto. A selected or replied-to annotation without one + // (other tools leave /NM out) gets a name for this export, so its replies still find it. let names = annotation_names(doc, &pages, only); for (pi, p) in pages.iter().enumerate() { for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() { @@ -203,8 +211,8 @@ fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, u n(rect[1].max(rect[3])) ); } - // The name: /NM, or the one given above for this export. - if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) { + // Its `/NM`, or the name given above for this export. + if let Some(nm) = a.as_ref().and_then(|r| names.get(&r)).cloned().or_else(|| text_of(doc, &d, b"NM")) { let _ = write!(attrs, " name=\"{}\"", esc(&nm)); } for (key, attr) in [ diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index 0124c24851..3a38487ee0 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -122,6 +122,13 @@ sign before an amount: the line-start and line-end rules of JIS X 4051, as in Un breaking (UAX #14). Latin words inside such text are not split. Korean and Thai still break only at spaces. +Text pasted from a browser or a word processor often carries an invisible break instead of a +newline. Every Unicode row break starts a new row: U+2028 LINE SEPARATOR, U+2029 PARAGRAPH +SEPARATOR, U+0085 NEXT LINE, the vertical tab, the form feed and the information separators +(U+001C to U+001E), alongside the carriage return and the newline. These move the text down a +row rather than being looked up in a font, so a paste no longer fails because no face has an +outline for them. Other white space is still written as one space. + Select text within the editor before changing size, colour, font, underline or spacing to format that selection. Automation supports up to 1024 disjoint UTF-8 byte ranges with the same properties; invalid or overlapping ranges fail atomically. Untouched source formatting diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 1944575ce3..4e30fcdf0a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,24 +1,32 @@ # Localization parity -> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (Turkish: 2,867 entries, 0 missing in `cargo xtask i18n-report` on 2026-11-03; AI-assisted) -> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-11-03 · **Last updated:** 2026-11-03 · **Change:** minor (every catalog restated from `cargo xtask i18n-report` on `main` at `14ae839a`: 2,897 strings in the report's denominator; Hindi, Indonesian, Korean, Thai, Lao and Central Kurdish listed with their catalogs. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). ## How this was measured -- **Strings translated** is measured: the catalogs in `crates/ui-egui/src/i18n/.tsv` (since moved to craft-i18n's `locales//`), counted - as distinct `(context, English)` keys with a non-empty translation. The denominator is the union of - keys across all catalogs, counting language-specific context rows such as `theme`/`Appearance`. - **That union is a moving target**: every change that adds a string widens it, so a count is only - comparable with others measured on the same date, and a catalog that reached 100% falls behind - without losing a single translation. Every figure here therefore carries its date. The current - union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier - revisions quote the union of their own date (see the revision history), not this one. The - twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language - except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is +- **Strings translated** is measured with `cargo xtask i18n-report`, which CI also runs: the + English strings in its denominator, less the ones a catalog has no translation for. The + denominator is the UI source's literals, the command, All tools and engine wordings, and every + key of every bundled catalog, which brings in the contextual, plural and dynamically looked-up + strings the scan can't see. On `main` at `14ae839a` (2026-11-03) that is **2,897** strings, and + every count and percentage in the tables below is of that number. A key only some catalogs have + counts as missing in the others; a contextual one (such as `theme`/`Appearance`) is met by a + plain translation of the same English, but a `@plural` one only by its own plural forms. The + denominator still grows whenever the interface gains a string, so a catalog that was complete + falls behind without losing a translation, and every figure here carries its date. English is built in. +- **Catalogs:** the figures above were taken from the in-repo catalogs + (`crates/ui-egui/src/i18n/.tsv`), which have since moved to craft-i18n's `locales//`. + The report reads the catalogs the build embeds, so measuring again needs `CRAFT_I18N_DIR` set to a + craft-i18n checkout at the commit in `craft-i18n.rev`; without it the report says it was + skipped. +- **Before 2026-11-03** this page counted distinct `(context, English)` keys against the union of + keys across all catalogs: 2,143 keys on 2026-10-10, 2,798 on 2026-10-25, and 2,895 on + `14ae839a`. That union also grew whenever one catalog added a key no other had, so it moved every + row at once. Earlier revisions in the history quote it, at the size of their own date. - **Not counted:** error details from the engine and the operating system, which are shown in English inside a translated frame; there is no translated user documentation or help yet (Acrobat ships localized help). @@ -32,64 +40,71 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R ## The twelve key languages -| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | +| Language | Code | UI strings translated (of 2,897, 2026-11-03) | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | |---|---|---|---|---|---|---|---|---| | English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 | -| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | -| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | -| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 | -| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | -| French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | -| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | -| Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 | -| Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | -| German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | -| Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | +| Simplified Chinese | `zh-hans` | 2,560 (88.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | +| Spanish | `es` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | +| Hindi | `hi` | 2,825 (97.5%) | dialogs and tooltips; no help | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | partial | no | 10–18 | +| Arabic | `ar` | 2,836 (97.9%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | +| French | `fr` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | +| Portuguese (Brazil) | `pt-br` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | +| Indonesian | `id` | 2,825 (97.5%) | dialogs and tooltips; no help | Latin | no | partial | no | 3–5 | +| Japanese | `ja` | 2,597 (89.6%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | +| German | `de` | 2,600 (89.7%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | +| Korean | `ko` | 2,825 (97.5%) | dialogs and tooltips; no help | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | partial | yes | 6–10 | +| Vietnamese | `vi` | 2,851 (98.4%) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | -**Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only -60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and -Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** +**Localization dimension ≈ 68%** (scored 2026-10-13, before the counts above): the mean over the +twelve, counting a catalog's percentage of the 2,143-key snapshot but only 60% for Chinese and +Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and Korean, which had no +catalog yet. Vietnamese moved from 0% to 100% in that mean (59% → 68%). Hindi, Indonesian and +Korean now have catalogs, so the score understates them. It is not re-scored here, because that +decides how their script caveats count (Devanagari rendering unqualified, no Hangul UI face). **≈ 55–110 h** to bring all twelve to `full`, of which ≈ 35–60 h is engineering (remaining RTL qualification, a web Hans face, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and -none has translated help. The **To `full`** hours of Vietnamese and Spanish are therefore residual -help/docs polish (catalog and script work are already done), not missing UI strings. +none has translated help. The **To `full`** hours of Vietnamese and Spanish are therefore mostly +help/docs polish: their script work is done, and each catalog lacks 46 strings on 2026-11-03. ## Other shipped languages -| Language | Code | UI strings translated | Status | Acrobat ships it | +| Language | Code | UI strings translated (of 2,897, 2026-11-03) | Status | Acrobat ships it | |---|---|---|---|---| -| Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes | -| Russian | `ru` | 2,043 (95.3%) | partial | yes | -| Bulgarian | `bg` | 2,094 (97.7%) | partial | no | -| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes | -| Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | -| Hungarian | `hu` | 2,126 (99.2%) | partial | yes | -| Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes | -| Kazakh | `kk` | 2,539 entries | partial | no | -| Polish | `pl` | 2,611 entries | partial | yes | -| Slovenian | `sl` | 2,536 entries | partial | no | -| Greek | `el` | 2,545 entries | partial | no | -| Turkish | `tr` | 2,867 entries (0 missing in `cargo xtask i18n-report`, 2026-11-03) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes | -| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | -| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | -| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | -| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | -| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Traditional Chinese | `zh-hant` | 2,559 (88.3%) | partial (same Hans/Hant face caveat) | yes | +| Russian | `ru` | 2,515 (86.8%) | partial | yes | +| Bulgarian | `bg` | 2,564 (88.5%) | partial | no | +| Czech | `cs` | 2,847 (98.3%) | partial | yes | +| Telugu | `te` | 2,513 (86.7%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | +| Hungarian | `hu` | 2,599 (89.7%) | partial | yes | +| Ukrainian | `uk` | 2,600 (89.7%) | partial | yes | +| Italian | `it` | 2,790 (96.3%) | partial | yes | +| Kazakh | `kk` | 2,609 (90.1%) | partial | no | +| Polish | `pl` | 2,681 (92.5%) | partial | yes | +| Slovenian | `sl` | 2,618 (90.4%) | partial | no | +| Greek | `el` | 2,615 (90.3%) | partial | no | +| Turkish | `tr` | 2,895 (99.9%) | partial; AI-assisted, judgement calls reviewed by a native speaker, full proofreading pending | yes | +| Lithuanian | `lt` | 2,851 (98.4%) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Finnish | `fi` | 2,849 (98.3%) | partial; AI-assisted, native proofreading pending | yes | +| Albanian | `sq` | 2,604 (89.9%) | partial; not yet reviewed by a native speaker | no | +| Catalan | `ca` | 2,849 (98.3%) | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | +| Romanian | `ro` | 2,849 (98.3%) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Norwegian Bokmål | `nb` | 2,758 (95.2%) | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Persian | `fa` | 2,801 (96.7%) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Central Kurdish (Sorani) | `ckb` | 2,754 (95.1%) | partial; right-to-left like Arabic | no | +| Thai | `th` | 2,751 (95.0%) | partial | no | +| Lao | `lo` | 2,504 (86.4%) | partial | no | -**20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). -The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +**23 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). +Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Every catalog restated from `cargo xtask i18n-report` on `14ae839a` (of the 2,897 strings in the report's denominator), replacing the catalog-union measure, which had reached 2,895 keys and left rows "100%" that were 44 short (#5805). Hindi, Indonesian and Korean restated from 0 to their catalogs, and Thai, Lao and Central Kurdish added to the table. The ≈ 68% score is not re-scored | | 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged | | 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | diff --git a/docs/releasing.md b/docs/releasing.md index 72206e16f1..eaeb4a63f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -128,8 +128,14 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. this. - `installer-ui.wxs` supplies native welcome, maintenance, progress, files-in-use and outcome dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. - The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft" - right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create + The welcome dialog has an "Install to" row (a `PathEdit`, and Browse… into `BrowseFolder`, a + native folder browser: drive list, folder list, Up, New folder, typed path; #1139), a "Create a + desktop shortcut" checkbox and an "Edit with PdfCraft" right-click menu checkbox, both ticked by + default. Install sets the folder (`SetTargetPath`) before the dialog closes. `INSTALLFOLDER` is + secure, so the choice reaches the elevated install, and `INSTALLFOLDER=` works on the + command line. The row starts on the folder `install-policy.wxi` chose for the scope picked on the + first page (the recorded folder on an update), and an update installs into the recorded folder + unless the command line gives one (`INSTALLATION.md`). `/qn` and `/qb` stay unattended and create both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ce026b3a2..ac9bcbc9e1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -324,6 +324,7 @@ Estimates unchanged. | 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | | 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | +| 2026-11-02 | patch | M3 #5708: pages shorter than the window sit in its middle, on a whole physical pixel | | 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index cfc7d2d08e..f38f186c82 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,46 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + ja + zh-Hans + zh-Hant + cs + pt-BR + de + es + fr + ru + kk + bg + te + hu + uk + it + ar + ckb + fa + pl + sl + el + tr + lt + id + vi + fi + sq + th + ca + ro + lo + hi + ko + nb + CFBundleDisplayName PdfCraft CFBundleName diff --git a/packaging/web/README.md b/packaging/web/README.md index a066cab94a..f5a51b4259 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -160,7 +160,7 @@ The messages and both transports are the shared contract of [`craft-web-host`](h ### Driving it from the page (`window.pdfcraft`) -`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. +`?control` on the page's URL puts `window.pdfcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app (`index.html?control`, which can be combined with other flags). It is opt-in because the control channel keeps the accessibility tree of every frame. The browser already exposes PdfCraft's canvas (`id="pdfcraft"`) as `window.pdfcraft`, so the functions are added to that element. `pdfcraft.request(method, params)` calls any method of the UI control channel the desktop app serves with `--control` (`ui.state`, `ui.inspect`, `ui.click`, `ui.key`, `ui.set`…; `ui.open` needs a file system, so on the web it answers with an error and a file is dropped on the page instead), and `execute(command, params)` (`ui.command`: a registry command id), `commands()` (`ui.commands`), `docInspect(params)` (`ui.state`), `uiInspect(params)` (`ui.inspect`) and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app may still reach it later and run it (or reject it), as it does for a desktop client that timed out, so a timed-out call is not guaranteed to have been skipped. The API is installed after `pdfcraft:ready` is posted, and `window.pdfcraft` is the canvas element before that, so wait for a function rather than for the name: `await new Promise(r => { const t = setInterval(() => typeof pdfcraft.request === "function" && (clearInterval(t), r()), 50); })`. ```js const state = await pdfcraft.docInspect(); // open documents, page, zoom, panels, dialog diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 7bea8562f8..43a3a7087d 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -6,23 +6,61 @@ - + - - - - - - + + + + + + + - - + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 44f6012ba0..f11ca83a6f 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -133,14 +133,17 @@ ones point at a Windows Installer descriptor rather than the program, which Windows can't use as a file handler (#107), and 0.2.1's advertised one went missing from the Start menu (#143). Each has its own component with an HKLM key path, so Windows Installer removes it - on uninstall; in this per-machine package both folders are the all-users ones. --> + on uninstall; in this per-machine package both folders are the all-users ones. They take + their icon from pdfcraft.exe, not the Icon table: Windows Installer keeps that copy under + the ProductCode, which every release changes, so a taskbar pin made from a shortcut that + named it shows a blank icon once an update removes the old release. --> - + - + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 5dc4152d8f..63d03a3da1 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -73,15 +73,16 @@ $status = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallProg Assert-Equal $status[0] 'Please wait while setup completes.' 'Persistent progress message' Assert-NoRow 'SELECT `Event` FROM `EventMapping` WHERE `Dialog_` = ''InstallProgress'' AND `Control_` = ''Status''' 'progress text subscription' -# Plain (non-advertised) shortcuts to pdfcraft.exe, each in its own component; the desktop one is -# gated by INSTALLDESKTOPSHORTCUT, which is secure so the UI choice reaches the +# Plain (non-advertised) shortcuts to pdfcraft.exe, each in its own component and with no Icon +# table entry, so the shell takes the exe's own icon and a pin keeps it across updates. The +# desktop one is gated by INSTALLDESKTOPSHORTCUT, which is secure so the UI choice reaches the # elevated install. foreach ($entry in @(@('StartMenuShortcut', 'ProgramMenuFolder', 'PdfcraftStartMenuShortcut', ''), @('DesktopShortcut', 'DesktopFolder', 'PdfcraftDesktopShortcut', 'INSTALLDESKTOPSHORTCUT = 1'))) { $row = Read-Row ('SELECT `Directory_`, `Target`, `Icon_`, `WkDir`, `Component_` FROM `Shortcut` WHERE `Shortcut` = ''' + $entry[0] + '''') 5 Assert-Equal $row[0] $entry[1] "$($entry[0]) directory" Assert-Equal $row[1] '[#PdfcraftExe]' "$($entry[0]) target" - Assert-Equal $row[2] 'PdfcraftIcon.ico' "$($entry[0]) icon" + Assert-Equal $row[2] '' "$($entry[0]) icon" Assert-Equal $row[3] 'INSTALLFOLDER' "$($entry[0]) working directory" Assert-Equal $row[4] $entry[2] "$($entry[0]) component" $component = Read-Row ('SELECT `Directory_`, `Condition` FROM `Component` WHERE `Component` = ''' + $entry[2] + '''') 2 @@ -249,6 +250,31 @@ $pdfChecked = Read-Row 'SELECT `Value` FROM `CheckBox` WHERE `Property` = ''INST Assert-Equal $pdfChecked[0] '1' 'Welcome PDF context-menu value' Assert-NoRow 'SELECT `Registry` FROM `Registry` WHERE `Key` = ''Software\Classes\.pdf'' AND `Name` IS NULL' '.pdf default app' +# Install folder (#1139): the welcome dialog's "Install to" row (a PathEdit, and Browse… into a +# native folder browser) changes the folder install-policy.wxi chose for the scope. Its secure +# property, the recorded folder and the restore on update are check-install-policy.ps1's. +$installTo = Read-Row 'SELECT `Type`, `Property` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''InstallTo''' 2 +Assert-Equal $installTo[0] 'PathEdit' 'Welcome install-folder control' +Assert-Equal $installTo[1] 'INSTALLFOLDER' 'Welcome install-folder property' +$browse = Read-Row 'SELECT `Event`, `Argument` FROM `ControlEvent` WHERE `Dialog_` = ''InstallWelcome'' AND `Control_` = ''Browse''' 2 +Assert-Equal $browse[0] 'SpawnDialog' 'Welcome Browse event' +Assert-Equal $browse[1] 'BrowseFolder' 'Welcome Browse dialog' +# Each scope has its own Install button, and each sets the folder before the dialog closes. +foreach ($button in @('Install', 'InstallUser')) { + $setPath = Read-Row ('SELECT `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''InstallWelcome'' AND `Control_` = ''' + $button + ''' AND `Event` = ''SetTargetPath'' AND `Argument` = ''INSTALLFOLDER''') 1 + $endWelcome = Read-Row ('SELECT `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''InstallWelcome'' AND `Control_` = ''' + $button + ''' AND `Event` = ''EndDialog''') 1 + if ([int] $setPath[0] -ge [int] $endWelcome[0]) { throw "$button must set the install folder before closing the welcome dialog" } +} +foreach ($control in @('DirectoryCombo', 'DirectoryList', 'PathEdit')) { + $row = Read-Row ('SELECT `Type`, `Property` FROM `Control` WHERE `Dialog_` = ''BrowseFolder'' AND `Control` = ''' + $control + '''') 2 + Assert-Equal $row[0] $control "Browse $control control" + Assert-Equal $row[1] 'INSTALLFOLDER' "Browse $control property" +} +$browseOk = Read-Row 'SELECT `Argument` FROM `ControlEvent` WHERE `Dialog_` = ''BrowseFolder'' AND `Control_` = ''OK'' AND `Event` = ''SetTargetPath''' 1 +Assert-Equal $browseOk[0] 'INSTALLFOLDER' 'Browse OK sets the folder' +$browseCancel = Read-Row 'SELECT `Event` FROM `ControlEvent` WHERE `Dialog_` = ''BrowseFolder'' AND `Control_` = ''Cancel'' AND `Event` = ''Reset''' 1 +Assert-Equal $browseCancel[0] 'Reset' 'Browse Cancel puts the folder back' + # Negative sequences are Windows Installer's success/user-exit/failure paths. Only full UI # shows these dialogs: an unattended /qn or /qb install must never wait for a Finish click. foreach ($exit in @(@('InstallComplete', '-1'), @('InstallCancelled', '-2'), @('InstallFailed', '-3'))) { @@ -282,4 +308,4 @@ $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: dual-scope guards, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: dual-scope guards, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), install folder (welcome row and browse dialog), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 89f49372b0..6c124d328f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -46,8 +46,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0"] -notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged." +evidence = ["crates/cos/src/document/tests.rs::damaged_xref_is_reconstructed", "crates/cos/src/parser.rs::indirect_stream_with_wrong_length_recovers", "crates/cos/src/document/tests.rs::self_referential_length_does_not_overflow", "crates/engine/src/tests.rs::a_repaired_file_is_rewritten_by_save_even_without_edits", "crates/automation/tests/it/automation.rs::saving_a_repaired_file_puts_the_repair_on_disk", "crates/engine/src/tests.rs::a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits", "crates/cos/src/document/tests.rs::a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0", "crates/render/src/raster/tests.rs::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them", "crates/cos/src/document/tests.rs::an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note", "crates/render/src/inspect/core.rs::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt", "crates/cos/src/document/tests.rs::a_hybrid_update_without_prev_is_rebuilt_too", "crates/render/src/inspect/core.rs::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it"] +notes = "Save and doc_save write a reconstructed file in full even when nothing was edited, so the repair reaches the disk and the saved file opens without one (#1937); a signed one keeps its bytes, so its signatures stay valid. A repair does not mark the document as having unsaved changes, so closing it does not offer to save the repaired file. A classic table numbered 1 n that begins with object 0's free entry is read from 0 (#2676) and logged. A last update cut off before its trailer (objects after a last `startxref` more than 4 KiB from the end) is rebuilt from object headers by the renderer as by the document model, so both show its latest objects. A file whose last update has no /Prev (ISO 32000-2 §7.5.6, §7.5.8.2), so that its page tree is cut off the chain, (a hybrid section's /XRefStm stream counts as part of the same update) is rebuilt from its object headers by the document model and the renderer alike, with a repair note and an open warning (the signed PDFBOX-2720 and PDFBOX-2942 files)." [[feature]] id = "core.malformed-input-safety" @@ -58,7 +58,7 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully"] +evidence = ["crates/ui-egui/tests/it/ui.rs::garbage_input_is_rejected_without_panicking", "crates/cos/src/parser.rs::garbage_never_panics", "crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::a_form_that_draws_itself_is_drawn_once"] notes = "fuzzed nightly (M1.10)" [[feature]] @@ -118,8 +118,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit"] -notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. Image codecs remain outside this general-purpose filter implementation." +evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit", "crates/render/src/raster/tests/tiff_predicted_images.rs::an_eight_bit_rgb_image_with_tiff_predicted_rows_draws_its_colours", "crates/render/src/raster/tests/tiff_predicted_images.rs::a_sixteen_bit_grey_image_with_tiff_predicted_rows_draws_its_levels", "crates/filters/src/lib.rs::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples_and_reported", "crates/cos/src/budget.rs::an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples"] +notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. The renderer undoes PNG predictors, and TIFF predictor 2 at 8 and 16 bits per component, a byte row at a time. An image whose PNG predictor was declared but never applied (exactly /Height rows with no filter-type bytes) is read as its samples and reported as tolerated damage, in decoding and on screen. Image codecs remain outside this general-purpose filter implementation." [[feature]] id = "core.image-filters" @@ -226,8 +226,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone", "crates/cos/tests/it/encryption.rs::a_crypt_filter_selected_by_reference_is_used", "crates/cos/tests/it/encryption.rs::an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone", "crates/render/src/raster/tests.rs::a_stream_with_a_crypt_filter_of_its_own_is_drawn"] -notes = "a stream can name its own filter (/Crypt in /Filter, its name in /DecodeParms), read and written, each part given directly or by reference, in cos and in the renderer; /StmF, /StrF, /EFF and /CFM by reference too. A /Filter given by reference selects the crypt filter, but Stream::decoded still refuses it, so such a stream is decrypted and not decoded outside the renderer." +evidence = ["crates/crypt/src/tests.rs::identity_crypt_filters_leave_data_alone", "crates/cos/tests/it/encryption.rs::a_crypt_filter_selected_by_reference_is_used", "crates/cos/tests/it/encryption.rs::an_identity_crypt_filter_selected_by_reference_leaves_a_stream_alone", "crates/render/src/raster/tests.rs::a_stream_with_a_crypt_filter_of_its_own_is_drawn", "crates/crypt/src/tests.rs::a_v4_rc4_key_shorter_than_16_bytes_is_extended_with_zeros"] +notes = "a stream can name its own filter (/Crypt in /Filter, its name in /DecodeParms), read and written, each part given directly or by reference, in cos and in the renderer; /StmF, /StrF, /EFF and /CFM by reference too. A /Filter given by reference selects the crypt filter, but Stream::decoded still refuses it, so such a stream is decrypted and not decoded outside the renderer. A /V 4 RC4 filter with a 40- or 48-bit key reads as Acrobat writes it, the key extended to 16 bytes with zeros before Algorithm 1, in cos as in the renderer (pdf.js issue 19484)." [[feature]] id = "core.attachment-only-encryption" @@ -235,11 +235,11 @@ title = "Attachment-only encryption (/EFF) opens without a password" area = "A" tier = "P1" milestone = "M1" -status = "partial" +status = "shipped" commands = [] tools = [] -evidence = [] -notes = "two corpus files still ask for a password (M1.6)" +evidence = ["crates/cos/tests/it/efopen.rs::a_document_encrypting_only_its_embedded_files_opens_without_a_password", "crates/cos/tests/it/efopen.rs::saves_keep_the_encrypted_embedded_files_as_stored", "crates/render/src/inspect/tests.rs::a_document_encrypting_only_its_embedded_files_opens_without_a_password", "crates/engine/src/tests.rs::a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_password", "crates/organize/tests/efopen_attachments.rs::combining_does_not_copy_attachments_held_encrypted"] +notes = "a document whose encrypting crypt filters are all /AuthEvent /EFOpen, with /StmF and /StrF /Identity, opens without a password (ISO 32000-2 7.6.6, Table 25); with /EFF or a /Crypt filter of the file naming the filter; its attachments are listed but not read or copied without the password, and saves keep them encrypted (a full save is refused where the key depends on the object number, as is any save that would have to encrypt). The password given later (Document::unlock) unlocks the embedded files. Not yet: reading an attachment stored in the clear in such a document without the password (attachment_data reads through lopdf, which can't read the document without its key, so every attachment of it is refused), and the app asking for the password when such an attachment is saved. pdf.js encrypted-attachment.pdf has no /AuthEvent (DocOpen) and a non-empty user password, so it rightly asks for one; its broken startxref currently reports it as unreadable instead" [[feature]] id = "core.unencrypted-metadata" @@ -734,8 +734,8 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"] -notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat" +evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged", "crates/render/src/raster/tests.rs::a_font_without_a_subtype_is_drawn_by_its_base_font", "crates/render/src/raster/tests.rs::a_page_without_a_media_box_is_letter"] +notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. A font dictionary with a missing or empty `/Subtype` is drawn as a Type 1 font by its `/BaseFont`. A page with no `/MediaBox`, or one without area, is US Letter in the renderer, the page model and the inspector alike. Not measured against Acrobat" [[feature]] id = "view.render-shadings-patterns" @@ -782,8 +782,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = [] -notes = "hayro bootstrap with vendored patches. Text rendering modes follow ISO 32000-2 §9.3.6 for Type 3 fonts: 3 and 7 render nothing, the others render the glyph descriptions filled (never stroked), and 4 to 7 add nothing to the clipping path (hayro-interpret patches 42 and 64). Coverage is synthetic fixtures; no fidelity comparison across a Type 3 corpus yet" +evidence = ["crates/render/src/raster/tests/type3_proc_widths.rs::a_type3_font_without_widths_advances_by_its_glyph_procedures", "crates/render/src/raster/tests/type3_proc_widths.rs::widths_the_font_gives_are_kept", "crates/render/src/raster/tests/type3_proc_widths.rs::a_type3_font_without_widths_is_reported"] +notes = "hayro bootstrap with vendored patches. Text rendering modes follow ISO 32000-2 §9.3.6 for Type 3 fonts: 3 and 7 render nothing, the others render the glyph descriptions filled (never stroked), and 4 to 7 add nothing to the clipping path (hayro-interpret patches 42 and 64). A font without /Widths advances each glyph by its glyph procedure's d0/d1 width (§9.6.4) in rendering and text alike, and the page reports a render warning that the font's /Widths is missing (patch 90). Coverage is synthetic fixtures; no fidelity comparison across a Type 3 corpus yet" [[feature]] id = "view.render-cid-fonts" @@ -794,8 +794,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen"] -notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). Any other character the substitute lacks still falls back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726)" +evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode", "crates/render/src/raster/tests.rs::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen", "crates/render/src/raster/tests/non_embedded_arial.rs::an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name", "crates/render/src/raster/tests/non_embedded_arial.rs::codes_of_other_fonts_are_not_read_in_the_macintosh_order", "crates/render/src/raster/tests.rs::a_bfchar_cmap_reads_codes_of_its_codespace_length"] +notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters. A non-embedded Identity font whose `/ToUnicode` gives a soft hyphen (U+00AD, as Windows exporters write Arial's hyphen) draws the standard substitute's hyphen (#5888). A non-embedded Arial or Times New Roman (`CIDFontType2`; ArialMT, Arial-BoldMT, Arial,Bold, TimesNewRomanPSMT, TimesNewRoman,Bold and their other faces) whose codes no Unicode route draws is read as glyph indexes of the author's font, whose first 258 glyphs follow the standard Macintosh order: the substitute draws each glyph by that name, .notdef when it has none (#5889, #5726, #6449). Its text still can't be selected, searched or copied without a `/ToUnicode`, Courier New and other faces whose numbering isn't confirmed aren't read this way, and Arial and Times New Roman glyphs past the first 258 (Cyrillic, Greek) and every other font's characters the substitute lacks still fall back to the CID taken as one of the substitute's glyph indexes, an unrelated glyph (#3529, #5726). An embedded CMap that writes its CID mappings as `bfchar` reads each code at the length its codespace ranges give it (ISO 32000-2 §9.7.6.2), so a two-byte code is one glyph, not two (hayro-cmap patch 4)" [[feature]] id = "view.render-image-codecs" @@ -806,8 +806,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = [] -notes = "hayro bootstrap" +evidence = ["crates/render/src/raster/tests.rs::group4_fax_with_rows_zero_decodes_to_the_image_height", "crates/render/src/raster/tests.rs::group4_fax_with_rows_below_its_height_decodes_to_the_image_height", "crates/render/src/raster/tests.rs::group4_fax_with_rows_past_its_height_decodes_to_the_image_height"] +notes = "hayro bootstrap; a CCITT fax image is decoded to its /Height whatever its /DecodeParms /Rows says (0, fewer or more rows)" [[feature]] id = "view.render-annotation-appearances" @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" @@ -830,8 +830,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"] -notes = "" +evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering", "crates/render/src/raster/tests/coverage_budget.rs::a_page_past_its_coverage_leaves_the_rest_out_and_says_so"] +notes = "A page that paints more coverage than the rasterizer can index (one form drawn millions of times over) renders what fitted and says content was left out, rather than failing the page." [[feature]] id = "view.render-priority" @@ -878,8 +878,8 @@ milestone = "M3" status = "shipped" commands = ["view.layout.single"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"] -notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" +evidence = ["crates/ui-egui/tests/it/view.rs::pages_shorter_than_the_window_sit_in_its_middle", "crates/ui-egui/tests/it/view.rs::a_page_that_fits_without_its_margins_shows_whole", "crates/ui-egui/tests/it/view.rs::a_row_too_tall_for_the_window_starts_at_its_top_whichever_page_is_shorter", "crates/ui-egui/tests/it/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/it/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/it/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/it/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/it/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/it/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/it/view.rs::view_menu_page_display_uses_the_same_commands"] +notes = "a page or row shorter than the window sits in its middle, the spare height shared above and below, continuous scrolling included when every page fits (#5708); when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" [[feature]] id = "view.layout-two-up" @@ -1154,8 +1154,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"] -notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel" +evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it", "crates/ui-egui/tests/it/viewer.rs::end_shows_the_bottom_of_the_last_page"] +notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel; Home shows the first page's top and End the end of the document, the last page down to its bottom edge; Shift+Ctrl (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down do the same as Home and End (Shift+Ctrl+Page Up / Page Down used to turn one page, like Ctrl+Page Up / Page Down)" [[feature]] id = "view.goto-page-label" @@ -1299,7 +1299,7 @@ status = "shipped" commands = [] tools = ["doc_info"] evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/view.rs::bookmark_destinations_scroll_to_their_position"] -notes = "A bookmark goes to its destination's position and zoom, not just its page. The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)." +notes = "A bookmark goes to its destination's position and zoom, not just its page; one whose action goes elsewhere (a web address, another PDF) runs it as a link does (organize.bookmark-actions). The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)." [[feature]] id = "view.highlight-current-bookmark" @@ -1322,8 +1322,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect/tests.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] -notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)." +evidence = ["crates/render/src/inspect/tests.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab", "crates/ui-egui/tests/it/pickers.rs::a_saved_attachment_carries_the_documents_zone"] +notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). On Windows a saved attachment keeps the Mark-of-the-Web (Zone.Identifier) of the PDF it came from, or gets the Internet zone when the PDF has none (#3990). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)." [[feature]] id = "view.layers-panel" @@ -1406,8 +1406,8 @@ milestone = "M3" status = "shipped" commands = ["edit.find"] tools = ["text_find"] -evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable"] -notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic." +evidence = ["crates/ui-egui/tests/it/ui.rs::find_counts_matches_across_pages", "crates/ui-egui/tests/it/ui.rs::find_reports_no_matches", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/text/tests.rs::identity_v_columns_are_searchable_in_reading_order", "crates/render/src/text/tests.rs::toc_leaders_do_not_hide_word_gaps", "crates/render/src/text/tests.rs::short_tracked_ellipses_remain_searchable", "crates/render/tests/it/search.rs::find_skips_soft_hyphens_and_zero_width_characters", "crates/render/tests/it/search.rs::find_joins_a_word_divided_at_a_line_end_soft_hyphen", "crates/render/tests/it/search.rs::a_zero_width_space_is_a_word_boundary_for_whole_words", "crates/render/tests/it/search.rs::a_chinese_or_japanese_word_divided_by_a_line_end_is_found_on_both_lines", "crates/render/tests/it/search.rs::a_line_break_stays_a_space_beside_latin_or_hangul_and_between_side_by_side_text", "crates/render/tests/it/search.rs::a_word_divided_between_vertical_columns_is_found_in_both", "crates/render/tests/it/search.rs::each_unspaced_range_joins_and_spaced_scripts_do_not", "crates/render/tests/it/search.rs::a_soft_hyphen_and_a_chinese_or_japanese_line_end_combine"] +notes = "Horizontal and WMode 1 vertical text are grouped independently, including a horizontal heading above right-to-left vertical columns. Dotted TOC leaders preserve word spaces for phrase search (#3086), while short tracked ellipses remain intact and searchable; sparse lines and condensed OCR gaps below the existing height-based threshold remain limitations. Complex interleaved flows still use a geometric reading-order heuristic. Soft hyphens, zero width spaces and joiners, word joiners and U+FEFF inside a word are skipped, in the page and in the query; a skipped zero width space still ends a word for Whole words only. A line break reads as no word break when the line ends in a soft hyphen, or when it runs on between two Chinese or Japanese characters (East Asian Wide, Fullwidth or Halfwidth, not Hangul and not fullwidth Latin letters or digits), so a divided word is found, in horizontal lines and in vertical columns that continue to the left; the break stays a space beside Latin or Korean text and between text set side by side on one line or in staggered cells." [[feature]] id = "view.find-next-previous" @@ -1895,8 +1895,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance"] -notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" @@ -2333,9 +2333,9 @@ tier = "P1" milestone = "M7" status = "partial" commands = ["edit.link"] -tools = ["link_add"] -evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/it/pdf_links.rs::drawing_editing_and_deleting_links"] -notes = "go to a page (Fit view); named destinations and zoom pending" +tools = ["link_add", "link_edit", "link_list"] +evidence = ["crates/annot/src/tests.rs::links_are_added_edited_listed_and_removed", "crates/ui-egui/tests/it/pdf_links.rs::drawing_editing_and_deleting_links", "crates/annot/src/tests.rs::page_links_keep_their_zoom", "crates/automation/tests/it/automation.rs::link_zoom_through_tools", "crates/ui-egui/tests/it/pdf_links.rs::link_properties_sets_the_page_view_zoom", "crates/ui-egui/tests/it/pdf_links.rs::an_appearance_edit_keeps_the_destination_as_written", "crates/annot/src/tests.rs::a_rewritten_link_keeps_its_own_zoom_factor", "crates/annot/src/tests.rs::an_unchanged_go_to_keeps_its_destination_and_a_new_page_keeps_its_fit", "crates/automation/tests/it/automation.rs::link_edit_keeps_a_links_own_zoom_outside_the_range_it_writes"] +notes = "go to a page at a zoom: Fit Page, Fit Width, Fit Height, Fit Visible, Actual Size, Inherit Zoom or a percentage (Link Properties ▸ Zoom; link_add and link_edit zoom), written as /Fit, /FitH, /FitV, /FitB or /XYZ with the position null (ISO 32000-2 Table 149). Links other tools wrote read as the nearest zoom. One given a new page with its zoom unchanged keeps how that zoom is written (a /FitBH or /FitBV fit, its own /XYZ factor even outside the 1–6400% a chosen zoom is written within) but not its position; one edited with its own page and zoom (Link Properties, link_edit) is left as it is, position included. Missing: named destinations as targets, and a position on the page (Acrobat's Set Link takes the current view)" [[feature]] id = "edit.action-uri" @@ -3034,8 +3034,8 @@ milestone = "M4" status = "shipped" commands = ["page.combine"] tools = ["doc_combine"] -evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] -notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab. Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." +evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/ui-egui/tests/it/ui.rs::files_dropped_on_the_combine_tab_join_the_end_of_its_list_in_the_order_dropped", "crates/ui-egui/tests/it/ui.rs::a_file_combine_does_not_take_is_left_out_of_a_drop_and_named", "crates/ui-egui/tests/it/ui.rs::a_pdf_dropped_on_a_document_opens_while_the_combine_tab_waits_behind_it", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"] +notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab (desktop and web): a drop joins the end of the list in the order dropped, as one undo step, not at the place under the pointer; files of a kind Combine does not take are left out and named. Dropped elsewhere, files open as before. Dropping adds no engine behaviour, so it has no tool of its own (doc_combine takes the files). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed." [[feature]] id = "organize.combine-bookmarks" @@ -3379,11 +3379,11 @@ title = "Bookmark actions" area = "D" tier = "P1" milestone = "M4" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_info", "bookmark_list"] +evidence = ["crates/render/src/inspect/tests.rs::bookmark_actions_are_read_as_links_read_them", "crates/organize/src/tests.rs::only_a_go_to_action_names_a_page_here", "crates/ui-egui/tests/it/document_links.rs::a_web_bookmark_asks_first_like_a_link", "crates/ui-egui/tests/it/document_links.rs::a_bookmark_to_another_pdf_opens_it_instead_of_moving_here", "crates/automation/tests/it/automation.rs::bookmarks_report_their_actions_and_a_remote_go_to_names_no_page_here"] +notes = "Clicking a bookmark runs its action as a link runs it: a web address asks first, a remote go-to or a Launch of a PDF opens that file at its page, a set-layer-visibility action changes the view, and any other action type is named in a notice. Only a go-to's /D is a page of this document; a remote go-to's /D is the other file's (ISO 32000-2 §12.6.4.3), which bookmarks used to read as this file's page. doc_info lists each bookmark's action as links' targets are listed. Missing: adding or editing a bookmark's actions (Acrobat's Bookmark Properties ▸ Actions), /Next chains, and running Named and JavaScript actions." [[feature]] id = "organize.bookmark-expand-collapse" @@ -3574,7 +3574,7 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/ui-egui/tests/it/viewer.rs::initial_view_magnification_offers_25_and_100_percent", "crates/organize/src/tests.rs::choosing_100_percent_keeps_an_actual_size_opening_destination", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" [[feature]] @@ -3735,7 +3735,7 @@ status = "shipped" commands = ["comment.replace"] tools = ["comment_add"] evidence = ["crates/annot/src/tests.rs::replace_text_groups_a_strikeout_and_a_caret", "crates/ui-egui/tests/it/comments.rs::replacing_text_strikes_it_and_adds_a_caret", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"] -notes = "strikeout (/IT StrikeOutTextEdit) and a grouped caret (/RT /Group) with the replacement" +notes = "a caret holding the replacement, the group's primary (/IT /Replace), with the strikeout (/IT /StrikeOutTextEdit) grouped under it (/IRT, /RT /Group), as ISO 32000-2 §12.5.6.2 reads a group's text from its primary; deleting either deletes both. Missing: /RT /Group is not read yet, so the Comments panel and comment_list show the strikeout as a reply of the caret (#4357)." [[feature]] id = "comment.insert-text" @@ -4590,8 +4590,8 @@ milestone = "M3" status = "shipped" commands = ["form.fields"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] -notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too" +evidence = ["crates/ui-egui/tests/it/view.rs::field_list_jumps_to_a_fields_page", "crates/render/src/inspect/tests.rs::fields_listed_only_on_the_pages_are_listed", "crates/render/src/inspect/tests.rs::a_widget_whose_parent_is_missing_is_listed", "crates/forms/src/tests.rs::a_widget_whose_parent_is_missing_is_a_field_of_its_own", "crates/ui-egui/tests/it/bulk_fields.rs::fields_panel_selects_multiple_fields_and_opens_their_shared_properties", "crates/ui-egui/tests/it/bulk_fields.rs::double_clicking_a_field_in_the_panel_opens_its_individual_properties"] +notes = "list with jump-to-field; in Prepare a form, Shift/Ctrl/Command-click selects several fields and Properties edits their shared properties; double-click opens field properties; fields a file lists only as page widgets (empty /Fields) are adopted too, and a widget whose /Parent names a missing object is a field of its own" [[feature]] id = "form.highlight-fields" @@ -4770,8 +4770,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "SUM, PRD, AVG, MIN, MAX over fields and groups" +evidence = ["crates/forms/src/af/tests.rs::validation_and_calculation", "crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/af/tests.rs::a_calculation_over_fields_without_values_is_0_for_every_operation", "crates/forms/src/af/tests.rs::a_blank_option_of_a_single_space_is_a_value_worth_0", "crates/forms/src/tests.rs::an_order_form_with_blank_drop_down_quantities_totals_only_the_chosen_rows"] +notes = "SUM, PRD, AVG, MIN, MAX over fields and groups; fields without a value are left out, and when none of the listed fields has one, every operation gives 0 (a product too, not 1); a drop-down's blank option of a single space is a value worth 0" [[feature]] id = "form.simplified-notation" @@ -4794,8 +4794,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_script"] -evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools"] -notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively." +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text"] +notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I." [[feature]] id = "form.calculation-order" @@ -5351,8 +5351,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console" @@ -5435,8 +5435,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] -notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." +evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout", "crates/xfa/src/tests.rs::the_template_knows_its_floating_fields_and_sheet_roles", "crates/engine/src/tests.rs::text_shows_the_values_scripts_give_its_floating_fields", "crates/js/src/tests.rs::the_sheet_is_the_page_on_screen", "crates/xfa/src/tests.rs::embedded_text_shows_each_instances_data", "crates/xfa/src/tests.rs::an_embedded_sheet_number_counts_the_pages", "crates/engine/src/tests.rs::floating_fields_follow_their_data_whoever_writes_it"] +notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291). Text that embeds a field (a floating field, XFA 3.3 p. 1222) shows the field's value from the data, so a value an initialize, click or calculate script gives it shows, and an event that changes one lays the form out again when it ends; xfa.layout.sheet (from 0) and sheetCount treat each page as a sheet, and an embedded sheet number or count shows as the page number or count does. A repeating row shows its own instance's value, a page area's field the value written under the area, and a typed value or a reset lays the text out again. Missing: floating fields set by layout:ready scripts (current date and time), as those scripts are not run yet, and fields inside an exclusion group (#6160)." [[feature]] id = "form.xfa-formcalc" @@ -6547,8 +6547,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification." +evidence = ["crates/sign/tests/it/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/it/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/it/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/it/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/it/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/it/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/it/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/it/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/it/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/it/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/it/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry", "crates/sign/tests/it/pdf.rs::rewriting_a_certified_forms_xfa_data_is_a_form_fill", "crates/sign/tests/it/pdf.rs::the_catalogs_perms_do_not_change_under_no_changes", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_respelled_in_hex_is_no_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_string_whose_bytes_change_is_still_a_change", "crates/sign/src/pdf/revision_classification_tests.rs::a_widget_rect_saved_back_as_integers_is_a_form_fill"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787). Rewriting the XFA `datasets` or `form` packet the signed revision named is form fill, as a Reader Extensions (UR3) save of a certified form does (#2759); the packets' roles come from the signed revision. Under P=1 a change to the catalog's /Perms breaks the certification. Signed objects are compared by value: a string is its bytes, so one a later save rewrites from literal to hexadecimal (or back) is unchanged (ISO 32000-2 §7.3.4.1), and a number is its value, so a signed widget /Rect or page /MediaBox of whole reals saved back as integers is unchanged (§7.3.3)." [[feature]] id = "sign.view-signed-version" @@ -7203,8 +7203,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", "crates/export/src/tests.rs::a_bullet_is_never_a_table_cell", "crates/export/src/tests.rs::a_long_bulleted_list_keeps_every_bullet_with_its_item", "crates/export/src/tests.rs::a_dash_in_a_table_row_stays_a_cell", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_bulleted_list_as_paragraphs"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a page whose only text is short lines that end without punctuation, such as a poem (its longest line counts as full, so the lines join), a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7215,8 +7215,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; a list whose bullets are blocks of their own is a paragraph per item, not a table, as in Word export; page breaks. Images are left out." [[feature]] @@ -7267,8 +7267,10 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", + "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", + "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7327,8 +7329,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. The page names the PDF's language (``, from a well formed `/Lang`). Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table, though not yet a
    list), inline images, and detected tables with spans/empty cells. The page names the PDF's language (``, from a well formed `/Lang`). Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" @@ -7475,8 +7477,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.reduce"] tools = ["doc_reduce"] -evidence = ["crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] -notes = "images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" +evidence = ["crates/ui-egui/src/platform/pickers.rs::reduce_file_size_asks_where_to_save_before_it_compresses", "crates/ui-egui/src/platform/pickers.rs::cancelling_reduce_file_size_compresses_nothing", "crates/ui-egui/src/platform/pickers.rs::a_reduced_copy_that_cant_be_written_where_chosen_asks_again", "crates/ui-egui/tests/it/create.rs::reduce_file_size_writes_a_compact_copy", "crates/optimize/tests/optimize.rs::a_picture_with_a_stencil_mask_is_downsampled_under_its_mask", "crates/optimize/tests/optimize.rs::a_picture_whose_mask_is_not_a_stencil_is_left_alone", "crates/ui-egui/tests/it/create.rs::reduce_file_size_saves_nothing_when_the_file_is_already_small", "crates/engine/src/tests.rs::create_and_reduce", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::an_identity_decode_array_does_not_stop_reduce"] +notes = "the command asks where to save before it compresses, so a heavy file can finish by itself, and asks again if that place can't be written; images above 225 ppi to 150 ppi JPEG medium (after merging page pictures printed as one-pixel strips into one image), black-and-white images averaged down to 300 ppi above 450 ppi, and stored as CCITT Group 4 whenever that is smaller, thumbnails dropped, resources merged, object streams; scanner images with the default decode array are included; pictures under a stencil mask are included and the mask stays as it is, though samples it hides still count when resampling, so their colour can reach its edge; pictures with a colour-key mask are left alone; a copy that isn't smaller is not saved (\"already as small as it can be made\"); runs in the background with a progress bar and Cancel" [[feature]] id = "optimize.compatibility-level" @@ -8015,8 +8017,8 @@ milestone = "M11" status = "partial" commands = ["standards.pdfa"] tools = ["pdfa_verify"] -evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] -notes = "PDF/A-2b/3b structural rules: identification, encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." +evidence = ["crates/preflight/src/tests.rs::verify_reports_what_breaks_pdfa", "crates/preflight/src/tests.rs::standards_identification_uses_namespace_not_prefix", "crates/preflight/src/tests.rs::convert_fixes_all_but_fonts", "crates/preflight/src/tests.rs::verify_reports_an_output_intent_that_does_not_match_device_colour", "crates/preflight/src/tests.rs::xmp_simple_values_have_their_lexical_forms", "crates/preflight/src/tests.rs::xmp_arrays_have_their_kind_and_item_types", "crates/preflight/src/tests.rs::xmp_structures_have_their_field_types", "crates/preflight/src/tests.rs::xmp_value_types_are_reported_by_verify", "crates/preflight/src/tests.rs::xmp_mistyped_properties_name_their_type", "crates/preflight/src/tests.rs::xmp_properties_are_known_by_namespace_uri_and_named_as_written", "crates/automation/tests/it/automation.rs::pdfa_through_tools", "crates/ui-egui/tests/it/compare.rs::pdfa_dialog_verifies_and_converts"] +notes = "PDF/A-2b/3b structural rules: identification, the value types of the predefined XMP schemas (6.6.2.3.1: dc, xmp, xmpRights, xmpMM, xmpBJ, xmpTPg, xmpDM, pdf, photoshop, crs and their structures; the tiff, exif and aux namespaces, whose tables XMP Part 2 defers to CIPA DC-010, are not yet checked), encryption, output intent (present, and RGB/CMYK to match DeviceRGB/DeviceCMYK use), fonts embedded, annotations, actions, images, embedded files. Not yet: content-stream operator checks, transparency details, PDF/A-1 and -4, a veraPDF comparison." [[feature]] id = "optimize.pdfa-convert" @@ -8271,8 +8273,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex"] -notes = "2/4/6/9/16, four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused." +evidence = ["crates/print/src/tests.rs::multiple_pages_per_sheet", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/print/src/tests.rs::cut_stack_keeps_piles_in_order_and_blanks_in_place", "crates/print/src/tests.rs::cutting_and_stacking_recovers_every_selected_page", "crates/automation/tests/it/automation.rs::cut_stack_printing_through_tools", "crates/ui-egui/tests/it/print.rs::cut_stack_dialog_previews_saves_and_refuses_duplex", "crates/print/src/tests.rs::a_custom_grid_is_laid_out_as_given_on_the_chosen_sheet", "crates/automation/tests/it/automation.rs::custom_pages_per_sheet_prints_the_grid_as_given", "crates/ui-egui/tests/it/print.rs::custom_pages_per_sheet_takes_columns_by_rows"] +notes = "2/4/6/9/16 (the sheet turns to suit the grid with Auto orientation) or Custom columns by rows, 1-99 each, laid out across and down the sheet as oriented (Auto prints portrait; doc_print cols/rows); four sequential orders, borders, auto-rotate; also single-sided cut-and-stack order with aligned gutter cut marks, blank padding and range/reverse support (an extension, not an Acrobat parity claim). Duplex cut-and-stack is refused." [[feature]] id = "print.booklet" @@ -8343,8 +8345,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = [] -evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf"] -notes = "live sheet preview with sheet navigation" +evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_applies_the_print_auto_state"] +notes = "live sheet preview with sheet navigation; layers are drawn as they print (the Print event's auto state, ISO 32000-2 §8.11.4.4)" [[feature]] id = "print.grayscale" @@ -8391,8 +8393,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render"] -notes = "Save as PDF from the dialog" +evidence = ["crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/automation/tests/it/automation.rs::printing_through_tools", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/engine/src/tests.rs::printing_keeps_hidden_layers_hidden", "crates/engine/src/tests.rs::printing_applies_the_print_auto_state", "crates/engine/src/tests.rs::a_layer_prints_only_if_every_recommendation_is_on"] +notes = "Save as PDF from the dialog. Layers print as shown in the Layers panel, then as the default configuration's auto state for the Print event sets them (/AS, /PrintState; ISO 32000-2 §8.11.4.4), vector or as image; the print copy keeps those states in /D without /AS or the layers' /Usage, and the dialog's preview shows them. A Print entry's Print, View and Export categories are read; Zoom, User and Language give no recommendation when printing." [[feature]] id = "print.print-pages-from-thumbnails" @@ -9635,8 +9637,8 @@ milestone = "M2" status = "shipped" commands = [] tools = ["doc_info", "page_render", "text_extract"] -evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find"] -notes = "" +evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "apps/pdfcraft-cli/tests/it/info_repairs.rs::info_lists_a_page_tree_the_app_rebuilt_by_scanning"] +notes = "`info` lists the repair notes the app shows in Document Properties (`repairs`)." [[feature]] id = "misc.cli-edit" @@ -9995,8 +9997,8 @@ milestone = "M12" status = "partial" commands = [] tools = ["model_render"] -evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] -notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, with headlight shading. Missing: the 3D view in the app (no activation, no navigation), U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" +evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/automation/tests/it/model_render.rs::an_orbit_frames_the_model_from_a_named_view_and_its_moves", "crates/prc-render/src/nav/orbit_tests.rs::a_pressed_point_off_centre_keeps_its_place_as_the_view_turns", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] +notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, or by orbit navigation (named views about a chosen up axis, turn, zoom about a picture point, pan) from prc-render's GUI-free nav module, with headlight shading. Missing: the 3D view in the app (no activation, no on-screen navigation; nav is not wired to a viewer yet), walk and fly navigation, U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.3d-add" diff --git a/vendor/README.md b/vendor/README.md index 7ae867cb11..cd29f5e128 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -27,7 +27,7 @@ licence files unchanged. Every patch is marked `PdfCraft patch:` in the source, PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases the fix. Vendoring copyleft code is never allowed (plan/adr/0001). -A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and unchanged nearly-zero A4 fallback. Accepted page-unit policy and licences are unchanged. +A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and the nearly-zero fallback, which is US Letter since patch 44. Accepted page-unit policy and licences are unchanged. In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device space before intersecting clip and shading bounds; `raster::tests::shaded_type3_glyph_draws_at_multiple_positions` covers translated glyphs (#3178). @@ -39,21 +39,22 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp` | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so); (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) ; (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render) | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (36) a stream with no `endstream` left after it 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), when the file holds that many (`parse_declared`, tried after the search for `endstream` finds none): a producer that writes `endstrea` after every stream lost every font and page content, and its pages drew blank while other readers draw them; (37) a document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25: every encrypting crypt filter is EFOpen, `/EFF` names one, `/StmF` and `/StrF` don't), opens without a password with the pass-through decryptor: its strings and streams are in the clear and nothing drawn reads an embedded file; (38) a page box that encloses no area is read as a missing one: a `/MediaBox` without area (`[0 0 0 0]`) as missing (an inherited one with area, else the default page, US Letter since patch 44), and a `/CropBox` without area, or with none in common with the media box, as missing (an inherited one, else the media box; ISO 32000-2 §14.11.2, Table 33); kept, the visible region had no area, so nothing was drawn and no text extracted (pdf.js `boundingBox_invalid`); (40) the last Flate filter of an image whose PNG predictor (`/Predictor` 10–15) its writer declared but never applied gives the image's samples as they are: when the inflated data is exactly `/Height` rows of the predictor's row length with no filter-type byte before each (where predicted rows would need one more byte each, ISO 32000-2 §7.4.4.4), `untagged_png_rows` takes it for unpredicted samples and reports it (`warn!`), as PDFium, MuPDF and Poppler read it; read with the predictor, each row's first sample was taken for a filter type and the image came out a row short and shifted, drawn in fragments (PDFBOX-3932). `Filter::apply` is given the image's rows for its last filter only; any other length is predicted as before; (41) CCITT `/Rows` above the image's `/Height` decodes to the height too, so the decoder is never asked for more rows than the image has (ISO 32000-2 Table 87): a scan with `/Rows 1084975104` on 2,592 columns had the size check refuse every page, and all 17 drew blank (PDFBOX-771); `/Rows` still counts for a stream decoded without an image's height; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels; (43) a last update cut off before its trailer (the last `startxref` more than 4 KiB from the end of the file, as `pdfcraft-cos` judges the footer, with `N G obj` headers after it: `cut_off_update_follows`) is read by rebuilding the table from object headers (`Pdf::new_with_password` calls `fallback` first, after the table the last `startxref` names has refused a wrong password, and takes the rebuilt table only when it is encrypted exactly when that one is), the latest definition of each object winning, as `pdfcraft-cos` reads such a file; the older table the last `startxref` names is read only when the rebuilt table has no page (a file cut short inside the update, whose new page tree names pages that are not there). The renderer had shown the older revision while the document model read the newer one (PDFBOX-3934: 12 pages against 13; ISO 32000-2 §7.5.6). A `startxref` near the end, or one followed only by data that holds no object header, is read as before; (44) a page with no `/MediaBox`, its own or inherited, or one without area (`[0 0 0 0]`, which now counts as missing, so the page is placed and clipped by the region it is sized by and its content shows), is US Letter (`LETTER`, 612 × 792 pt), not A4, and so is the `base_dimensions` fallback for a visible region without area: ISO 32000-2 Table 31 requires the box and gives no default, other readers use Letter, and PdfCraft's page model and inspector already did (PDFBOX-4001); (45) `root_xref`: when the newest cross-reference section names no earlier one (no `/Prev`, ISO 32000-2 §7.5.6, §7.5.8.2; a hybrid section's `/XRefStm` stream belongs to the same update and doesn't count, §7.5.8.4), the file holds another `startxref`, and the catalog's `/Pages` can't be read, the table is rebuilt by scanning the file (`XRef::repair`) before the page tree is read, so the cut-off page tree is found instead of the page dictionaries `Pages::new_brute_force` turns up (the signed files of PDFBOX-2720 and PDFBOX-2942 showed 2 blank pages for 1, 10, 13 and 1754); `Pdf::rebuilt_unlinked_update` says so, and `pdfcraft-cos` rebuilds under the same conditions | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (36) `raster::tests::a_content_stream_without_endstream_is_drawn_to_its_length` (pdfcraft-render); (37) `inspect::tests::a_document_encrypting_only_its_embedded_files_opens_without_a_password`; (38) `text::tests::text_on_a_page_whose_box_has_no_area_is_extracted` (pdfcraft-render); (40) `filter::lzw_flate::pdfcraft_png_predictor_tests::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples`, `an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone`, `data_of_neither_length_decodes_as_it_did`, `rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic` (hayro-syntax), `raster::tests::png_predicted_images::an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples`, `an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor`; (41) `raster::tests::group4_fax_with_rows_past_its_height_decodes_to_the_image_height`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images`; (43) `raster::tests::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them`, `raster::tests::a_complete_update_reads_as_before`, `raster::tests::trailing_data_without_objects_reads_as_before`, `raster::tests::a_short_cut_off_update_reads_as_before`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt`, `raster::tests::trailing_data_with_an_object_header_reads_as_the_model_reads_it`, `raster::tests::an_update_cut_off_before_its_new_pages_keeps_the_older_table`, `raster::tests::an_encrypted_file_with_a_cut_off_update_still_needs_its_password`, `raster::tests::a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken` (pdfcraft-render); (44) `raster::tests::a_page_without_a_media_box_is_letter`, `raster::tests::raw_crop_unit_floor_preserves_nearly_zero_fallback` (pdfcraft-render), `pdfcraft-model::tests::{a_page_without_a_media_box_is_letter, a_page_whose_media_box_has_no_area_is_letter}`; (45) `inspect::core::unlinked_update_tests::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt`, `inspect::core::unlinked_update_tests::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it` (pdfcraft-render) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | -| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | -| hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | -| hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | +| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical; (2) `StripStorage::room_for_path{,_within}`: whether one more path's coverage (at most the whole surface, counted twice) still fits below 2^31 values, the most a strip's packed alpha index reaches (`Strip::new` asserts it), or below a smaller limit; (4) `flatten::stroke`: when kurbo 0.13.1's stroker returns an outline containing NaN, which `fill` would drop whole, the path is stroked again from a copy (`robust_for_stroking`, one element out per element in) in which each curve whose control points lie within the stroke tolerance of its chord is a line (the #4907 fix, a nearly straight cubic given a NaN join, kurbo issues #617 and #519; its row here was lost in a merge and is restored with this entry) and each cubic whose second control point is its end point, as `y` builds (ISO 32000-2 §8.5.2.1 Table 58), has that control point moved the tolerance towards its first (`open_zero_end_handle`; the curve moves by at most 4/9 of the tolerance): kurbo's cusp search on such a curve's offset can stop on the end point, where the derivative is zero, and normalising it gives NaN, so at 16, 32 and 63 pt a two-curve `y` path drew nothing and a file in the PDFBox tracker lost its 32 pt "XX" outline. Only an exact zero triggers it (none of 192,000 random strokes with a handle 1e-9 long, nor of 256,000 `v` strokes, came back with NaN; 1,073 of 192,000 random `y`/`v` strokes at 1–64 pt did before and none after). A finite outline takes the old path unchanged | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate; (2) `strip_generator::pdfcraft_tests::strips_have_room_until_a_surface_short_of_the_limit`; (4) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` fail without the near-line replacement, `flatten::tests::a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn` (widths 1–64, every join and cap, `y` and the mirrored `v`) and the render crate's `stroke_zero_end_tangent` fail without the moved control point; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` pass either way | +| vello_cpu | 0.0.8 | Apache-2.0 OR MIT | (1) the single-threaded dispatcher leaves out a fill, stroke, fast rectangle or layer clip once the scene has no room for one more path's coverage (`StripStorage::room_for_path_within`, vello_common patch 2), records it (`RenderContext::coverage_full`, cleared by `reset`) and can be kept to fewer values (`RenderContext::set_max_coverage`, kept across `reset`). Every path's coverage stays in one buffer until the scene is rasterized, so a page that painted one form 2^22 times over (PDFBOX-5415) grew it past 2^31 values at about 7 GB and panicked in `Strip::new` (`alpha_idx` too large); it now renders what fitted. The multi-threaded dispatcher is unchanged (PdfCraft renders single-threaded); the backport of linebender/vello#2000 is recorded under the KenM76 integration patches (#5199) | (1) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` renders a page that fills a circle 2^10 times with the rasterizer kept to 200,000 values, and fails without the dispatcher's check; `strokes_past_the_coverage_are_left_out`, `fast_rectangles_past_the_coverage_are_left_out` and `layer_clips_past_the_coverage_are_left_out` (the last through `RenderContext` directly, as hayro pushes no clipped layers, with the first clip kept below the limit and its pixels checked) each fail without that path's check, and `a_layer_clip_past_the_coverage_is_itself_left_out` fails when the clip is generated without room; vello_cpu's own tests need upstream assets that are not vendored | +| hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254); (3) a token the scanner can't read (a stray `>` after a CMap name, a CMap name with spaces in it) is stepped over with `parse_object_or_skip`, at most 1,024 times per CMap, instead of ending the scan before the mappings that follow it (pdf.js `issue11651.pdf`, `issue7492.pdf`: no text could be copied); (4) `lookup_cid_code`'s `bfchar` fallback (used where a CMap writes its CID mappings as `bfchar`) applies only when the codespace ranges give the code the byte length being tried (ISO 32000-2 §9.7.6.2; `codespace_allows`, a CMap without codespace ranges defers to its `usecmap` base, and with none anywhere the entry's own source length decides, now recorded as `BfRange::code_len` for text CMaps): the first byte of `` had matched the entry `<00fe>`, so the first byte of `` matched the entry `<00fe>` and every two-byte code drew as two glyphs (PDFBOX-1282 `Characters_Arial.pdf` page 3). An unmapped code still advances one byte | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit`; (3) `hayro-cmap::tests::{mappings_after_an_unreadable_token_are_read, a_number_run_into_an_operator_does_not_start_its_mappings, the_scan_steps_over_at_most_max_skipped_unreadable_tokens, a_cmap_of_unreadable_tokens_only_reports_failure}`, `raster::tests::a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse` (pdfcraft-render); (4) `hayro-cmap::tests::{bfchar_cid_fallback_takes_the_codespace_length, bfchar_cid_fallback_checks_each_byte_of_the_codespace, bfchar_cid_fallback_without_codespace_takes_the_source_length}`, `raster::tests::a_bfchar_cmap_reads_codes_of_its_codespace_length` (pdfcraft-render) | +| hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263); (2) `Scanner::parse_object_or_skip`: after an object that can't be read, the scanner reads on from where the failed read stopped, or from one byte past where its token starts (after whitespace and comments) when it read nothing, so a caller can read on, one failure per unreadable token, and the data is read about once; a number that can't be read goes to the end of its token, so `1beginbfchar` is not read as `beginbfchar` (hayro-cmap patch 3) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped`; (2) `hayro-postscript::tests::{parse_object_or_skip_steps_over_an_unreadable_token, parse_object_or_skip_skips_the_rest_of_an_unreadable_number, parse_object_or_skip_does_not_read_a_failed_read_again, parse_object_or_skip_stays_at_the_end}`, `raster::tests::a_to_unicode_cmap_reads_its_mappings_past_a_token_it_cannot_parse` (pdfcraft-render) | -`hayro-postscript` is the crates.io 0.1.0 package plus patch (1). The local Cargo +`hayro-postscript` is the crates.io 0.1.0 package plus patches (1) and (2). The local Cargo registry cache metadata for that package (`.cargo_vcs_info.json`) records upstream VCS commit `a2354aebaf86f5ab13e5fd6b6b0521306488bf9d`. The registry copy and this vendored tree's -source files differ only in `src/object.rs` (the patch above); the vendored tree +source files differ only in `src/object.rs` (patch 1) and `src/lib.rs` (patch 2); the vendored tree omits the registry dotfiles `.cargo_vcs_info.json` and `.cargo-ok`, so the upstream commit is recorded here. @@ -75,6 +76,11 @@ Covered by `raster::tests::tiff_predictor_packed_rgb_rows_keep_their_byte_bounda horizontal differencing and an 8-bit control) and `raster::tests::tiff_predictor_packed_rgb_image_renders_every_row`. +Legacy encryption key length: hayro-syntax refuses a revision 2–4 encryption dictionary whose +`/Length` is above 128 bits with `InvalidEncryption`. Those revisions take the file key from a +16-byte MD5 hash, so a `/V 2 /R 3 /Length 256` dictionary sliced past its end and panicked (#1769). +Covered by `crypto::pdfcraft_password_tests::legacy_key_length_above_128_bits_is_refused_not_a_panic`. + JPEG 2000 palettes (#3179): `hayro-syntax::filter::jpx::decode` applies a JP2 palette (`pclr`) when the image dictionary has no `/ColorSpace`, since the colour then comes from the JP2 header (ISO 32000-2 §7.4.9). With a `/ColorSpace` the raw indices are kept, as an `/Indexed` space needs. @@ -91,8 +97,10 @@ Glyph names outside the Adobe Glyph List (#3169, #3170): hayro-interpret's `glyp maps a name by the glyph list specification's full mapping (drop from the first `.`, split on `_`, each part through the list, `uniXXXX…` or `uXXXX`; a `uni` part with any group that isn't a character maps to nothing), so `f_i`, `f_l` and `hyphen.case` read as text. A name that only -numbers the glyph (`C39`, `c251`) still reads as nothing: the code's StandardEncoding character -would be a guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review on #3239). Covered by `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text`. +numbers the glyph (`c251` at 97, `C1` at 233) still reads as nothing: the code's StandardEncoding +character would be a guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review +on #3239). The one exception is patch (100): a font whose numbered names all name their own codes. +Covered by `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text`. Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). diff --git a/vendor/hayro-cmap/src/bcmap/mod.rs b/vendor/hayro-cmap/src/bcmap/mod.rs index e20c45903f..182dff2c60 100644 --- a/vendor/hayro-cmap/src/bcmap/mod.rs +++ b/vendor/hayro-cmap/src/bcmap/mod.rs @@ -410,6 +410,8 @@ fn parse_bf_segment( entries.push(BfRange { range: Range { start, end }, dst_base, + // PdfCraft patch (4): a binary CMap doesn't record the length. + code_len: 0, }); prev_end = Some(end); diff --git a/vendor/hayro-cmap/src/lib.rs b/vendor/hayro-cmap/src/lib.rs index 2ef3c6d190..ebf5b6adac 100644 --- a/vendor/hayro-cmap/src/lib.rs +++ b/vendor/hayro-cmap/src/lib.rs @@ -359,8 +359,9 @@ const MAX_NESTING_DEPTH: u32 = 16; #[derive(Debug, Clone)] pub struct CMap { metadata: Metadata, - // Note that we don't actually use this, because Acrobat _seems_ to ignore - // it, too. + // Note that the CID lookup doesn't check this, because Acrobat _seems_ to + // ignore it, too. PdfCraft patch (4): the `bfchar` fallback of + // `lookup_cid_code` does check it (see there). _codespace_ranges: Vec, cid_ranges: PartitionedRanges, notdef_ranges: PartitionedRanges, @@ -449,7 +450,20 @@ impl CMap { // See pdfjs_bug920426, it uses bf chars for encoding the characters // of a text-showing operator, so try that as well. We don't want to // recurse though, only check the bf chars of this level. - if let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false) { + // + // PdfCraft patch (4): bf entries don't record how many bytes their code + // has, so the fallback only applies when the codespace ranges give + // the code `byte_len` bytes (ISO 32000-2 §9.7.6.2). Otherwise the + // first byte of `` matched a `<00fe>` entry and a two-byte code + // drew as two glyphs. A CMap with no codespace ranges anywhere uses + // the length the entry's own source code was written with instead. + let length_fits = match self.codespace_allows(code, byte_len, 0) { + Some(allowed) => allowed, + None => self.bf_code_has_length(code, byte_len), + }; + if length_fits + && let Some(BfString::Char(lookup)) = self.lookup_bf_string_inner(code, false) + { return Some(lookup as u32); } @@ -459,6 +473,40 @@ impl CMap { .and_then(|b| b.lookup_cid_code(code, byte_len)) } + /// PdfCraft patch (4): whether this level's bf entry for `code` was written with `byte_len` + /// bytes (`<00fe>` is a two-byte code, not the one-byte ``), or doesn't record its length. + /// Used where no codespace range gives the code its length. + fn bf_code_has_length(&self, code: u32, byte_len: u8) -> bool { + find_in_ranges(&self.bf_entries, code) + .is_some_and(|entry| entry.code_len == 0 || entry.code_len == byte_len) + } + + /// PdfCraft patch (4): whether a code of `byte_len` bytes lies in a codespace range of that + /// length (each byte within the range's bounds for its position, ISO + /// 32000-2 §9.7.6.2). A CMap without codespace ranges defers to its base, + /// and with none anywhere the answer is `None`. + fn codespace_allows(&self, code: u32, byte_len: u8, depth: u32) -> Option { + if self._codespace_ranges.is_empty() { + return match &self.base { + Some(base) if depth < MAX_NESTING_DEPTH => { + base.codespace_allows(code, byte_len, depth.saturating_add(1)) + } + _ => None, + }; + } + + Some(self._codespace_ranges.iter().any(|range| { + range.number_bytes == byte_len + && (0..u32::from(byte_len)).all(|i| { + // `byte_len` is at most 4 here (the range's own length), + // so `shift` is at most 24. + let shift = (u32::from(byte_len) - 1 - i) * 8; + let byte = |v: u32| v.checked_shr(shift).map_or(0, |b| b & 0xFF); + byte(range.low) <= byte(code) && byte(code) <= byte(range.high) + }) + })) + } + /// Look up a bf string in the cmap. This is usually /// used for mapping character codes to Unicode codepoints in a /// `ToUnicode` cmap. @@ -584,6 +632,9 @@ impl PartitionedRanges { pub(crate) struct BfRange { pub(crate) range: Range, pub(crate) dst_base: Vec, + /// PdfCraft patch (4): how many bytes the entry's source code was written with (1 to 4), + /// or 0 where that isn't recorded (a binary CMap). + pub(crate) code_len: u8, } impl HasRange for BfRange { @@ -594,7 +645,6 @@ impl HasRange for BfRange { /// A codespace range defining valid character code byte sequences. #[derive(Debug, Clone)] -#[allow(dead_code)] pub(crate) struct CodespaceRange { pub(crate) number_bytes: u8, pub(crate) low: u32, @@ -1128,6 +1178,96 @@ endbfchar ); } + /// Parse `body` after the metadata of [`PREAMBLE`] but without its + /// codespace ranges. + fn parse_without_codespace(body: &[u8]) -> CMap { + let mut data = Vec::new(); + data.extend_from_slice( + br#"/CIDSystemInfo 3 dict dup begin + /Registry (Adobe) def + /Ordering (Identity) def + /Supplement 0 def +end def +/CMapName /Test def +"#, + ); + data.extend_from_slice(body); + CMap::parse(&data, |_| None).unwrap() + } + + /// PdfCraft patch (4) test: a `bfchar` entry used as a CID mapping matches only + /// codes of the length the codespace ranges give it (ISO 32000-2 §9.7.6.2). + #[test] + fn bfchar_cid_fallback_takes_the_codespace_length() { + let cmap = parse_without_codespace( + br#" +1 begincodespacerange +<0000> +endcodespacerange +2 beginbfchar +<0020> <0020> +<2041> <0042> +endbfchar +"#, + ); + // The first byte of <2041> alone is not a code of this CMap. + assert_eq!(cmap.lookup_cid_code(0x20, 1), None); + assert_eq!(cmap.lookup_cid_code(0x2041, 2), Some(0x42)); + assert_eq!(cmap.lookup_cid_code(0x0020, 2), Some(0x20)); + // ToUnicode-style lookups are unchanged. + assert_eq!(cmap.lookup_bf_string(0x20), Some(BfString::Char(' '))); + } + + /// PdfCraft patch (4) test: mixed codespaces give each code its own length, + /// byte by byte, and a CMap without codespace ranges keeps the fallback. + #[test] + fn bfchar_cid_fallback_checks_each_byte_of_the_codespace() { + let cmap = parse_without_codespace( + br#" +2 begincodespacerange +<00> <80> +<8140> <9FFC> +endcodespacerange +3 beginbfchar +<41> <0041> +<8141> <0100> +<8220> <0101> +endbfchar +"#, + ); + assert_eq!(cmap.lookup_cid_code(0x41, 1), Some(0x41)); + assert_eq!(cmap.lookup_cid_code(0x8141, 2), Some(0x100)); + // 0x8220 lies numerically between 0x8140 and 0x9FFC, but its second + // byte 0x20 is below 0x40. + assert_eq!(cmap.lookup_cid_code(0x8220, 2), None); + + let no_codespace = parse_without_codespace(b"1 beginbfchar <20> <0020> endbfchar\n"); + assert_eq!(no_codespace.lookup_cid_code(0x20, 1), Some(0x20)); + } + + /// PdfCraft patch (4) test: without codespace ranges, a `bfchar` entry used as a CID + /// mapping matches only codes of the length its source was written with, so the first + /// byte of `` is not taken for `<00fe>`. + #[test] + fn bfchar_cid_fallback_without_codespace_takes_the_source_length() { + let cmap = parse_without_codespace( + br#" +2 beginbfchar +<00fe> <0041> + <0042> +endbfchar +1 beginbfrange +<0100> <0102> <0050> +endbfrange +"#, + ); + assert_eq!(cmap.lookup_cid_code(0xfe, 1), None); + assert_eq!(cmap.lookup_cid_code(0xfe96, 2), Some(0x42)); + assert_eq!(cmap.lookup_cid_code(0x00fe, 2), Some(0x41)); + assert_eq!(cmap.lookup_cid_code(0x0101, 2), Some(0x51)); + assert_eq!(cmap.lookup_cid_code(0x0101, 3), None); + } + #[test] fn stream_cut_off_inside_a_block_keeps_the_entries_before_it() { let cmap = parse_with_preamble(b"1 beginbfchar <41> <0058>\n<42> <00"); @@ -1176,6 +1316,60 @@ endbfchar assert!(cmap.lookup_bf_string(0x41).is_some()); } + // PdfCraft patch (3): a token the scanner can't read (a stray `>` after a CMap name, a name + // with spaces in it) is stepped over, so the mappings after it are still read; at most + // `MAX_SKIPPED` of them per CMap. + + #[test] + fn mappings_after_an_unreadable_token_are_read() { + for header in [ + &b"/CMapName /Adobe-Identity-UC> def"[..], + b"/CMapName /-usr-share-fonts-Panton Family - Panton.otf,000-UTF16 def", + ] { + let mut data = header.to_vec(); + data.extend_from_slice( + b"\n1 beginbfchar <41> <0058> endbfchar\n1 beginbfrange <50> <52> <0061> endbfrange", + ); + let cmap = parse_with_preamble(&data); + assert_eq!(cmap.lookup_bf_string(0x41), Some(BfString::Char('X'))); + assert_eq!(cmap.lookup_bf_string(0x51), Some(BfString::Char('b'))); + } + } + + #[test] + fn a_number_run_into_an_operator_does_not_start_its_mappings() { + // `1beginbfchar` is one token, not `1` and `beginbfchar`: the pairs after it are not + // mappings, and the well-formed block after them still reads. + let cmap = parse_with_preamble( + b"1beginbfchar <41> <0058> endbfchar\n1 beginbfchar <42> <0059> endbfchar", + ); + assert_eq!(cmap.lookup_bf_string(0x41), None); + assert_eq!(cmap.lookup_bf_string(0x42), Some(BfString::Char('Y'))); + } + + /// A CMap with `count` unreadable tokens, each after a space, before one `bfchar` mapping. + fn mapping_after_unreadable_tokens(count: usize) -> Option { + let mut data = PREAMBLE.to_vec(); + data.extend_from_slice(&b" >".repeat(count)); + data.extend_from_slice(b"\n1 beginbfchar <41> <0058> endbfchar"); + CMap::parse(&data, |_| None) + } + + #[test] + fn the_scan_steps_over_at_most_max_skipped_unreadable_tokens() { + let limit = parse::MAX_SKIPPED; + let cmap = mapping_after_unreadable_tokens(limit).expect("read up to the limit"); + assert_eq!(cmap.lookup_bf_string(0x41), Some(BfString::Char('X'))); + // One more ends the scan before the mapping, and a CMap with no mappings fails. + assert!(mapping_after_unreadable_tokens(limit + 1).is_none()); + } + + #[test] + fn a_cmap_of_unreadable_tokens_only_reports_failure() { + assert!(CMap::parse(b"> ) >", |_| None).is_none()); + assert!(CMap::parse(&b" >".repeat(4 * parse::MAX_SKIPPED), |_| None).is_none()); + } + #[test] fn bad_bfchar_code_keeps_later_entries_aligned() { // The malformed 5-byte code still has a destination object; both diff --git a/vendor/hayro-cmap/src/parse.rs b/vendor/hayro-cmap/src/parse.rs index a936209275..6bebf1b047 100644 --- a/vendor/hayro-cmap/src/parse.rs +++ b/vendor/hayro-cmap/src/parse.rs @@ -15,6 +15,9 @@ struct Context { get_cmap: F, } +/// PdfCraft patch (3): most unreadable tokens one CMap may step over before its scan stops. +pub(crate) const MAX_SKIPPED: usize = 1024; + pub(crate) fn parse_inner<'a>( data: &[u8], get_cmap: impl Fn(CMapName<'_>) -> Option<&'a [u8]> + Clone + 'a, @@ -61,10 +64,20 @@ pub(crate) fn parse_inner<'a>( // must see failure rather than an empty map, so its encoding fallback // paths run exactly as they did before the tolerant-usecmap change. let mut usecmap_failed = false; + let mut skipped = 0usize; while !scanner.at_end() { - let Ok(obj) = scanner.parse_object() else { + // PdfCraft patch (3): a token that can't be read (a stray `>` after a CMap name, a + // name with spaces in it, both written into ToUnicode CMaps) is stepped over, so the + // mappings after it are still read; it used to end the scan before any of them. + // At most `MAX_SKIPPED` such tokens: a failed read may scan to the end of the data (an + // unclosed string), so a stream of them would cost its length squared. + let Ok(obj) = scanner.parse_object_or_skip() else { truncated = true; - break; + skipped += 1; + if skipped > MAX_SKIPPED { + break; + } + continue; }; let Object::Name(name) = &obj else { continue }; @@ -333,6 +346,8 @@ fn parse_bf_char( let _ = scanner.parse_object(); continue; }; + // PdfCraft patch (4): 1 to 4 (`extract_u32_code` checked). + let code_len = u8::try_from(ctx.buf.len()).unwrap_or(0); let Ok(dst) = scanner.parse_string() else { continue }; if dst.decode_into(&mut ctx.buf).is_err() { continue } let Some(dst_base) = decode_be(&ctx.buf) else { continue }; @@ -343,6 +358,7 @@ fn parse_bf_char( end: code, }, dst_base, + code_len, }); } } @@ -367,6 +383,8 @@ fn parse_bf_range( let _ = scanner.parse_object(); continue; }; + // PdfCraft patch (4): 1 to 4 (`extract_u32_code` checked). + let code_len = u8::try_from(ctx.buf.len()).unwrap_or(0); let Some(end) = read_u32_code(scanner, &mut ctx.buf) else { // Skip the destination object as well to stay aligned. let _ = scanner.parse_object(); @@ -383,6 +401,7 @@ fn parse_bf_range( entries.push(BfRange { range: Range { start, end }, dst_base, + code_len, }); } Object::Array(array) => { @@ -403,6 +422,7 @@ fn parse_bf_range( end: code, }, dst_base, + code_len, }); } } diff --git a/vendor/hayro-interpret/Cargo.lock b/vendor/hayro-interpret/Cargo.lock index c10e174988..fee8cbd0f9 100644 --- a/vendor/hayro-interpret/Cargo.lock +++ b/vendor/hayro-interpret/Cargo.lock @@ -146,9 +146,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.11.3" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -297,6 +297,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "phf" version = "0.13.1" @@ -387,12 +393,13 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.39.2" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", "font-types", + "once_cell", ] [[package]] @@ -415,9 +422,9 @@ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skrifa" -version = "0.42.1" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" dependencies = [ "bytemuck", "read-fonts", diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index bbc26e163f..cb885bd6a7 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -1,10 +1,11 @@ use crate::util::hash128; +use crate::{InterpreterWarning, WarningSinkFn}; use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Null, ObjRef, Object, Stream}; use kurbo::Affine; use std::any::Any; use std::collections::HashMap; use std::collections::hash_map::Entry; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; type CacheMap = HashMap>>; @@ -69,6 +70,14 @@ impl Cache { /// them; when an insert would pass the budget the whole map is dropped (a fresh epoch), which /// only costs re-decoding, and an image larger than the whole budget is never kept. The budget /// caps memory per interpreter cache. A poisoned lock decodes without the cache. +/// +/// PdfCraft patch (91): a decode that fails is kept too, as the warnings it reported, so an +/// image drawn again and again that doesn't decode (damaged data, a JPX or JBIG2 stream its +/// decoder refuses) isn't decoded again at every draw: a later draw reports the same warnings +/// and gets nothing, as the decode did. A failure counts [`FAILED_DECODE_BYTES`] towards the +/// budget, so failures can't grow the map without bound either. A failure that may come from +/// what the page had left (its JBIG2 allowance, `ContentTruncated`) isn't kept: another page, +/// or the same page drawn again, starts with a fresh allowance. #[derive(Clone)] pub(crate) struct DecodedImageCache { inner: Arc>, @@ -108,14 +117,55 @@ pub(crate) struct DecodedImageKey { pub(crate) default_space: Option, pub(crate) target: Option<(u32, u32)>, pub(crate) preview: bool, + /// PdfCraft patch (82): the size a soft mask far larger than where it is drawn was averaged + /// down to (`ImageXObject::decoded_soft_mask`), `None` for a mask kept at its own size. + pub(crate) shrunk_to: Option<(u32, u32)>, +} + +/// PdfCraft patch (91): what the budget charges for a failed decode, about what its key and +/// entry take in the map. +const FAILED_DECODE_BYTES: usize = 128; + +/// PdfCraft patch (91): what a [`DecodedImageCache`] keeps for a key. +enum DecodedEntry { + /// The decoded image. + Decoded(Box), + /// The decode failed, reporting these warnings ([`warning_bit`] flags). + Failed(u8), +} + +/// PdfCraft patch (91): `warning` as a flag in a set of warnings. +fn warning_bit(warning: InterpreterWarning) -> u8 { + match warning { + InterpreterWarning::UnsupportedFont => 1, + InterpreterWarning::ImageDecodeFailure => 2, + InterpreterWarning::ContentTruncated => 4, + InterpreterWarning::ColorFallback => 8, + InterpreterWarning::Type3WidthsMissing => 16, + } +} + +/// PdfCraft patch (91): the warnings in a set of [`warning_bit`] flags. +fn warnings_in(bits: u8) -> impl Iterator { + [ + InterpreterWarning::UnsupportedFont, + InterpreterWarning::ImageDecodeFailure, + InterpreterWarning::ContentTruncated, + InterpreterWarning::ColorFallback, + InterpreterWarning::Type3WidthsMissing, + ] + .into_iter() + .filter(move |warning| bits & warning_bit(*warning) != 0) } #[derive(Default)] struct DecodedImageCacheInner { - map: HashMap>>, + map: HashMap, bytes: usize, /// PdfCraft patch (32): how many keyed decodes ran (cache misses). decodes: usize, + /// PdfCraft patch (82): how many of `decodes` were of masks (keys with `mask_output`). + mask_decodes: usize, } impl DecodedImageCache { @@ -132,6 +182,15 @@ impl DecodedImageCache { self.inner.lock().map(|locked| locked.decodes).unwrap_or(0) } + /// PdfCraft patch (82): how many of [`Self::decodes`] were of soft masks and `/Mask` images, + /// for tests. 0 when the lock is poisoned. + pub(crate) fn mask_decodes(&self) -> usize { + self.inner + .lock() + .map(|locked| locked.mask_decodes) + .unwrap_or(0) + } + /// The same cache, for a preview (`true`) or a final render. pub(crate) fn for_render(&self, preview: bool) -> Self { Self { @@ -140,44 +199,76 @@ impl DecodedImageCache { } } - /// The decoded image for `key`, from the cache or from `f`. `None` as the key decodes - /// without the cache. + /// The decoded image for `key`, from the cache or from `f`, which decodes reporting its + /// warnings to the sink it is handed. `None` as the key decodes without the cache. + /// PdfCraft patch (91): `sink` gets the warnings of the decode, or those of the failed + /// decode kept for `key`. pub(crate) fn get_or_insert_with( &self, key: Option, budget: usize, size_of: impl FnOnce(&T) -> usize, - f: impl FnOnce() -> Option, + sink: &WarningSinkFn, + f: impl FnOnce(&WarningSinkFn) -> Option, ) -> Option { - let Some(key) = key else { return f() }; - // Fast path: a hit clones the pixels without decoding. + let Some(key) = key else { return f(sink) }; + // Fast path: a hit clones the pixels without decoding, and a kept failure reports its + // warnings again without decoding. let hit = match self.inner.lock() { - Ok(locked) => locked - .map - .get(&key) - .and_then(|val| val.as_ref()) - .and_then(|val| val.downcast_ref::().cloned()), + Ok(locked) => match locked.map.get(&key) { + Some(DecodedEntry::Decoded(val)) => val.downcast_ref::().cloned().map(Ok), + Some(DecodedEntry::Failed(bits)) => Some(Err(*bits)), + None => None, + }, // A thread panicked while holding the lock: decode without the cache. - Err(_) => return f(), + Err(_) => return f(sink), }; - if let Some(hit) = hit { - return Some(hit); + match hit { + Some(Ok(hit)) => return Some(hit), + Some(Err(bits)) => { + warnings_in(bits).for_each(|warning| sink(warning)); + return None; + } + None => {} } - // Miss: decode without holding the lock (decoding must never deadlock the cache). - let val = f(); + // Miss: decode without holding the lock (decoding must never deadlock the cache), + // noting the warnings it reports. + let reported = Arc::new(AtomicU8::new(0)); + let recording: WarningSinkFn = { + let (reported, sink) = (reported.clone(), sink.clone()); + Arc::new(move |warning| { + reported.fetch_or(warning_bit(warning), Ordering::Relaxed); + sink(warning); + }) + }; + let val = f(&recording); + let reported = reported.load(Ordering::Relaxed); let Ok(mut locked) = self.inner.lock() else { return val; }; locked.decodes = locked.decodes.saturating_add(1); + if key.mask_output { + locked.mask_decodes = locked.mask_decodes.saturating_add(1); + } // Another thread may have decoded it meanwhile: keep the first copy. - if let Some(existing) = locked.map.get(&key) - && let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::()) - { - return Some(existing.clone()); + match locked.map.get(&key) { + Some(DecodedEntry::Decoded(existing)) => { + if let Some(existing) = existing.downcast_ref::() { + return Some(existing.clone()); + } + } + Some(DecodedEntry::Failed(_)) if val.is_none() => return None, + _ => {} } let size = match &val { Some(v) => size_of(v), - None => 0, + // A failure the page's JBIG2 allowance may have caused isn't the image's to keep. + None if reported & warning_bit(InterpreterWarning::ContentTruncated) != 0 + || hayro_syntax::page_jbig2_work_spent() => + { + return None; + } + None => FAILED_DECODE_BYTES, }; if size > budget { return val; @@ -186,12 +277,14 @@ impl DecodedImageCache { locked.map.clear(); locked.bytes = 0; } + let entry = match &val { + Some(val) => DecodedEntry::Decoded(Box::new(val.clone())), + None => DecodedEntry::Failed(reported), + }; + // An entry replaced (a failure another thread kept) isn't given back: the budget + // errs towards a fresh epoch. locked.bytes = locked.bytes.saturating_add(size); - locked.map.insert( - key, - val.clone() - .map(|val| Box::new(val) as Box), - ); + locked.map.insert(key, entry); val } } diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index de3907b1ff..00aa0cfddb 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -5,7 +5,7 @@ use crate::font::{Font, StandardFont}; use crate::interpret::state::{ClipType, State, TextStateFont}; use crate::ocg::OcgState; use crate::util::{BezPathExt, Float64Ext}; -use crate::{ClipPath, Device, FillRule, InterpreterSettings, StrokeProps}; +use crate::{ClipPath, Device, FillRule, InterpreterSettings, InterpreterWarning, StrokeProps}; use hayro_syntax::content::ops::Transform; use hayro_syntax::object::Dict; use hayro_syntax::object::Name; @@ -37,6 +37,12 @@ std::thread_local! { // PdfCraft patch (18): whether this page's content budget ran out, reported as // `InterpreterWarning::ContentTruncated`. static CONTENT_TRUNCATED: core::cell::Cell = const { core::cell::Cell::new(false) }; + // PdfCraft patch (89): the form XObjects being drawn on this page, outermost first, across + // the contexts of the tiling patterns, Type 3 glyphs, soft masks and groups painted while + // they draw. A form already on it draws itself and is skipped. Its length is bounded by the + // nesting limits: every form on it is nested in the ones before it. + static FORMS_DRAWING: RefCell> = + const { RefCell::new(Vec::new()) }; } /// PdfCraft patch: decoded content-stream bytes one page may hold at once: its contents, and @@ -86,6 +92,37 @@ pub(crate) fn decode_content<'a>(stream: &Stream<'a>) -> Option usize { + CONTENT_LEFT.with(core::cell::Cell::get) +} + +/// PdfCraft patch (87): `len` bytes of content held within the page's [`MAX_PAGE_CONTENT`] +/// until dropped, as [`DecodedContent`] holds what it decoded: content kept from an earlier +/// decode (a cached Type 3 glyph procedure) counts as it did when it was decoded. +pub(crate) struct HeldContent(usize); + +impl Drop for HeldContent { + fn drop(&mut self) { + let held = self.0; + CONTENT_LEFT.with(|c| c.set(c.get().saturating_add(held).min(MAX_PAGE_CONTENT))); + } +} + +/// PdfCraft patch (87): hold `len` bytes of content within what is left of the page's +/// [`MAX_PAGE_CONTENT`]. `None`, and the page reported truncated, when they don't fit, as +/// [`decode_content`] skips content the budget can't hold. +pub(crate) fn hold_content(len: usize) -> Option { + let left = content_left(); + if left == 0 || left < len { + CONTENT_TRUNCATED.with(|truncated| truncated.set(true)); + warn!("page content budget exceeded"); + return None; + } + charge_content(len); + Some(HeldContent(len)) +} + /// PdfCraft patch: count `len` bytes held for the rest of the page (its own contents). pub(crate) fn charge_content(len: usize) { CONTENT_LEFT.with(|c| c.set(c.get().saturating_sub(len))); @@ -123,6 +160,22 @@ pub struct InterpreterCache<'a> { /// PdfCraft patch: decoded images, shared by every render through this cache so a tile /// no longer decodes its page's images again. pub(crate) decoded_cache: DecodedImageCache, + /// PdfCraft patch (87): what the Type 3 fonts of this cache keep of their decoded glyph + /// procedures, together (see `font::type3`). + pub(crate) type3_procs: Rc, +} + +/// PdfCraft patch (87): decoded Type 3 glyph procedure bytes all the fonts of one +/// [`InterpreterCache`] keep together. Fonts live as long as the cache, so the bound is on the +/// cache, not on each font: a document can have thousands of Type 3 fonts. +pub(crate) const MAX_CACHED_TYPE3_PROCS: usize = 32 << 20; + +/// PdfCraft patch (87): the decoded glyph procedure bytes the Type 3 fonts of a cache keep, +/// and how many procedures they decoded. +#[derive(Default)] +pub(crate) struct Type3Procs { + pub(crate) held: core::cell::Cell, + pub(crate) decodes: core::cell::Cell, } impl<'a> Default for InterpreterCache<'a> { @@ -138,15 +191,29 @@ impl<'a> InterpreterCache<'a> { font_cache: Rc::new(RefCell::new(HashMap::new())), object_cache: Cache::new(), decoded_cache: DecodedImageCache::new(), + type3_procs: Rc::default(), } } + /// PdfCraft patch (87): how many Type 3 glyph procedures the fonts of this cache decoded, + /// for diagnostics and tests. A procedure kept from an earlier copy of its glyph isn't + /// decoded again. + pub fn type3_proc_decodes(&self) -> usize { + self.type3_procs.decodes.get() + } + /// PdfCraft patch (32): how many images were decoded through this cache (its misses, not /// counting images that are never cached), for diagnostics and tests. pub fn decoded_image_decodes(&self) -> usize { self.decoded_cache.decodes() } + /// PdfCraft patch (82): how many of [`Self::decoded_image_decodes`] were of soft masks and + /// `/Mask` images, for diagnostics and tests. + pub fn decoded_mask_decodes(&self) -> usize { + self.decoded_cache.mask_decodes() + } + /// PdfCraft patch (48): how many `/ICCBased` profiles were read through this cache, for /// diagnostics and tests. A profile used again, by the same stream object or by a copy with /// the same dictionary and bytes, is read once. @@ -218,6 +285,8 @@ impl<'a> Context<'a> { NESTED_PAINTS.with(|n| n.set(0)); CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT)); CONTENT_TRUNCATED.with(|truncated| truncated.set(false)); + // PdfCraft patch (89): no form of another page is being drawn. + FORMS_DRAWING.with(|forms| forms.borrow_mut().clear()); // PdfCraft patch (73): and fresh JBIG2 decoding, which every image of it shares. hayro_syntax::set_page_jbig2_work(settings.jbig2_work); // PdfCraft patch (55): a fallback left over from another page is not this page's. @@ -267,6 +336,56 @@ impl<'a> Context<'a> { } } + /// PdfCraft patch (89): begins drawing the form `id`; `false` when that form is already + /// being drawn, so drawing it again would never end (ISO 32000-2 §8.10.1: a form is a + /// self-contained description; one that contains itself describes nothing finite). The + /// skip is reported as `InterpreterWarning::ContentTruncated`. A form without an object + /// number can't be told apart and is only bounded by the nesting limits. + /// + /// The forms being drawn are those of this page's context and of every context painting + /// inside it (a tiling pattern, a Type 3 glyph, a soft mask or a group), so a form that + /// reaches itself through one of those is skipped too, while a form drawn again after it + /// has finished draws as usual. + pub(crate) fn enter_form( + &mut self, + id: Option, + ) -> bool { + let Some(id) = id else { + return true; + }; + // The stack is never borrowed across a call, so the borrow succeeds; if it didn't, + // the form draws, bounded by the nesting limits as before this patch. + let entered = FORMS_DRAWING.with(|forms| { + let Ok(mut forms) = forms.try_borrow_mut() else { + return None; + }; + if forms.contains(&id) { + return Some(false); + } + forms.push(id); + Some(true) + }); + if entered == Some(false) { + warn!("form XObject draws itself; the inner drawing is skipped"); + CONTENT_TRUNCATED.with(|truncated| truncated.set(true)); + return false; + } + true + } + + /// PdfCraft patch (89): ends drawing a form begun with `enter_form`. + pub(crate) fn leave_form(&mut self, id: Option) { + if let Some(id) = id { + FORMS_DRAWING.with(|forms| { + if let Ok(mut forms) = forms.try_borrow_mut() + && forms.last() == Some(&id) + { + forms.pop(); + } + }); + } + } + pub(crate) fn save_state(&mut self) { let Some(cur) = self.states.last() else { warn!("attempted to save state without existing state"); @@ -528,6 +647,12 @@ impl<'a> Context<'a> { .clone() }; + // PdfCraft patch (90): tell the caller a Type 3 font lacks the /Widths it requires, + // so the spacing taken from its glyph procedures can be reported. + if resolved.as_ref().is_some_and(Font::type3_widths_missing) { + (self.settings.warning_sink)(InterpreterWarning::Type3WidthsMissing); + } + if let Some(resolved) = resolved { Some(TextStateFont::Font(resolved)) } else { diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index 8a76ee4177..0635e34731 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -226,7 +226,8 @@ fn encode_axial_shading( } /// PdfCraft patch: pixel visits one mesh shading may make, per pixel of the area it is drawn in -/// and per triangle. Sampling walks each triangle's bounding box (rounded out to whole pixels), +/// and per triangle. Sampling walks each triangle's bounding box (rounded out to whole pixels; +/// patch 99: only the columns of each row the triangle can reach), /// so triangles far off the page (a fuzzed patch decoded onto ±40000) asked for 65536 × 65536 /// samples, and many triangles covering the same pixels repeat the work. A triangle's box is /// about twice its area, and a sub-pixel triangle's box is at most 2 × 2 pixels. @@ -346,13 +347,43 @@ impl TriangleSampler { } self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE); + let (columns_start, columns_end) = (bbox.x0.floor() as u16, bbox.x1.ceil() as u16); for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) { - for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) { - if self.visits_left == 0 { - warn!("mesh shading visit budget exceeded; the rest is not drawn"); + // PdfCraft patch (99): only the columns where this row's sample points (the + // corner and the centre, at heights `y` and `y + 0.5`) can lie in the triangle, + // not the whole box. A thin triangle lying across the pixel grid has a box many + // times its area (the cells of a narrow curved band of patches, cut into a fixed + // 19 × 19 grid, are a few pixels long and a fraction of one wide, at every angle), + // and walking the box spent the visit budget before the last patches: painted + // through a path the size of its own box, a rainbow of tensor patches lost its + // inner bands. A box two columns wide or less is walked whole (no row of it is + // narrower by more than a column, which isn't worth finding), and a row with no + // column still costs a visit, so the walk stays within the budget however tall + // the triangle is. + let columns = if columns_end.saturating_sub(columns_start) <= 2 { + columns_start..columns_end + } else { + let top = f64::from(y); + match x_extent_between(&t, top - SPAN_SLACK, top + 0.5 + SPAN_SLACK) { + // Pixel `x` samples at `x` and `x + 0.5`; the casts saturate. + Some((lo, hi)) => { + let start = (lo - 0.5 - SPAN_SLACK).floor() as u16; + let end = ((hi + SPAN_SLACK).floor() as u16).saturating_add(1); + columns_start.max(start)..columns_end.min(end) + } + None => 0..0, + } + }; + if columns.is_empty() { + if !self.take_visit() { + return false; + } + continue; + } + for x in columns { + if !self.take_visit() { return false; } - self.visits_left -= 1; let corner = Point::new(x as f64, y as f64); let sampled = if t.contains_point(corner) { self.map.insert((x, y), t.interpolate(corner)); @@ -379,6 +410,53 @@ impl TriangleSampler { } true } + + /// Spend one visit; `false` (with a warning) when none is left. + fn take_visit(&mut self) -> bool { + if self.visits_left == 0 { + warn!("mesh shading visit budget exceeded; the rest is not drawn"); + return false; + } + self.visits_left -= 1; + true + } +} + +/// PdfCraft patch (99): how far past a row's sample points the triangle's extent is still +/// walked, in pixels. A sample a little outside the triangle can still pass `covers`, whose +/// tolerance is relative to the triangle's size (1e-9 of it, under 1e-4 pixel on a 65536-pixel +/// canvas); this is wider than that and than the rounding of the extent itself. A triangle +/// whose corners all but coincide (two of them 1e-13 pixel apart, its area 1e-14) can pass +/// `covers` for points well outside it, through rounding in its barycentric division; the +/// box walk took such points, the extent leaves them out. +const SPAN_SLACK: f64 = 1e-3; + +/// PdfCraft patch (99): the least and greatest x of the part of `t` between the heights `y0` +/// and `y1`, or `None` when no part of it is there: the ends of that part are the triangle's +/// corners inside the band and the points where its edges cross the band's two lines. +fn x_extent_between(t: &Triangle, y0: f64, y1: f64) -> Option<(f64, f64)> { + let corners = [t.p0.point, t.p1.point, t.p2.point]; + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + let mut add = |x: f64| { + lo = lo.min(x); + hi = hi.max(x); + }; + for (a, b) in corners.iter().zip(corners.iter().cycle().skip(1)) { + if (y0..=y1).contains(&a.y) { + add(a.x); + } + // A level edge lies along the band or misses it; its corners are taken above. + if a.y != b.y { + for line in [y0, y1] { + let s = (line - a.y) / (b.y - a.y); + if (0.0..=1.0).contains(&s) { + add(a.x + s * (b.x - a.x)); + } + } + } + } + // NaN corners add nothing (every comparison fails), so this is `None` for them. + (lo <= hi).then_some((lo, hi)) } /// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 0aa8da435d..184f7d5a19 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -67,18 +67,23 @@ impl CffFontBlob { let font = CffFontRef::new(data.as_ref().as_ref(), 0, None).ok()?; let cff = Cff::read(ReadFontData::new(data.as_ref().as_ref())).ok()?; let charset = font.charset(); + // PdfCraft patch (93): a font dict that cannot be read (a CID font's FDArray entry + // emptied by a subsetter, with no Private DICT) leaves only the glyphs it selects + // missing. The font is dropped only when no font dict is usable. let subfonts = (0..font.num_subfonts()) .map(|index| font.subfont(index, &[]).ok()) - .collect::>>()?; + .collect::>(); + if !subfonts.iter().any(Option::is_some) { + return None; + } - let yoke = Yoke::, FontData>::attach_to_cart(data.clone(), |data| { + // PdfCraft patch (93): the subfonts read above own their data, so they move into the + // yoke instead of being read again. + let yoke = Yoke::, FontData>::attach_to_cart(data.clone(), move |data| { let bytes = data.as_ref(); let font = CffFontRef::new(bytes, 0, None).unwrap(); let cff = Cff::read(ReadFontData::new(bytes)).unwrap(); let charset = font.charset(); - let subfonts = (0..font.num_subfonts()) - .map(|index| font.subfont(index, &[]).unwrap()) - .collect(); CFFYoke { font, cff, @@ -87,7 +92,7 @@ impl CffFontBlob { } }); - let _ = (cff, charset, subfonts); + let _ = (cff, charset); Some(Self(Arc::new(yoke), Arc::new(OnceLock::new()))) } @@ -105,7 +110,7 @@ impl CffFontBlob { fn subfont(&self, glyph: GlyphId) -> Option<&Subfont> { let index = self.font().subfont_index(glyph)? as usize; - self.0.as_ref().get().subfonts.get(index) + self.0.as_ref().get().subfonts.get(index)?.as_ref() } pub(crate) fn outline_glyph(&self, glyph: GlyphId) -> BezPath { @@ -195,8 +200,48 @@ impl Debug for OpenTypeFontBlob { } } +/// PdfCraft patch (83): a copy of the font with its `head.indexToLocFormat` set to the format its +/// `loca` table is in, when the field holds neither 0 (short offsets) nor 1 (long), or `None` when +/// it needs no change. The glyph outline reader takes only 1 as long, so a font whose field reads +/// 256 (the value's bytes swapped) had its long `loca` read as short offsets: every glyph came +/// out wrong, and a line of text set in it drew boxes (pdf.js `issue2537r.pdf`). The `loca` +/// table's length decides: room for `numGlyphs + 1` offsets of 4 bytes is long, room for only +/// that many of 2 bytes short (a table may carry padding after its offsets), and a table too +/// short for either is left as it is. +fn with_loca_format_fixed(data: &[u8], index: u32) -> Option> { + use skrifa::raw::types::Tag; + const HEAD: Tag = Tag::new(b"head"); + const LOCA: Tag = Tag::new(b"loca"); + // The `indexToLocFormat` field's offset in `head`. + const FORMAT_AT: usize = 50; + let f = FontRef::from_index(data, index).ok()?; + let format = f.head().ok()?.index_to_loc_format(); + if format == 0 || format == 1 { + return None; + } + let offsets = usize::from(f.maxp().ok()?.num_glyphs()).checked_add(1)?; + let loca_len = f.table_data(LOCA)?.len(); + let long = if loca_len >= offsets.checked_mul(4)? { + true + } else if loca_len >= offsets.checked_mul(2)? { + false + } else { + return None; + }; + let head_at = f.table_directory.table_records().iter().find(|r| r.tag() == HEAD)?.offset() as usize; + let at = head_at.checked_add(FORMAT_AT)?; + let mut fixed = data.to_vec(); + fixed.get_mut(at..at.checked_add(2)?)?.copy_from_slice(&i16::from(long).to_be_bytes()); + Some(fixed) +} + impl OpenTypeFontBlob { pub(crate) fn new(data: FontData, index: u32) -> Option { + // PdfCraft patch (83): see `with_loca_format_fixed`. + let data = match with_loca_format_fixed(data.as_ref().as_ref(), index) { + Some(fixed) => Arc::new(fixed) as FontData, + None => data, + }; // Check first whether the font is valid so we can unwrap in the closure. let f = FontRef::from_index(data.as_ref().as_ref(), index).ok()?; // Reject fonts with invalid post table version, fixes pdf.js issue 9462. Not sure if there @@ -377,7 +422,8 @@ struct CFFYoke<'a> { font: CffFontRef<'a>, cff: Cff<'a>, charset: Option>, - subfonts: Vec, + /// PdfCraft patch (93): `None` for a font dict that could not be read. + subfonts: Vec>, } #[cfg(test)] diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 6c4ea2bc9c..8115cb9e47 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -16,10 +16,12 @@ use kurbo::{BezPath, Vec2}; use skrifa::attribute::Style; use skrifa::charmap::MapVariant; use skrifa::raw::collections::int_set::Domain; +use skrifa::raw::tables::post::DEFAULT_GLYPH_NAMES; use skrifa::{FontRef, GlyphId, MetadataProvider}; use std::collections::HashMap; use std::ops::Deref; use std::sync::Arc; +use std::sync::OnceLock; #[derive(Debug)] pub(crate) struct Type0Font { @@ -42,6 +44,9 @@ pub(crate) struct Type0Font { /// Whether the `to_unicode` map is a UCS2 `CMap` (CID-indexed) rather than /// a `ToUnicode` `CMap` (code-indexed). to_unicode_is_cid_indexed: bool, + /// PdfCraft patch (84): the character collection's UCS2 CMap (by CID), for the codes the + /// font's own `/ToUnicode` gives nothing for, whether or not its program is embedded. + ucs2: Option, /// PdfCraft patch: whether the CIDs belong to an Adobe character collection (Japan1, GB1, /// CNS1, Korea1), whose glyph numbering no substitute other than a CID-keyed CFF font shares. adobe_collection: bool, @@ -50,6 +55,11 @@ pub(crate) struct Type0Font { /// such text as the code's Unicode value; the identity CMap the name resolves to maps codes /// to CIDs only and gave no text at all. identity_to_unicode: bool, + /// PdfCraft patch: an Arial or a Times New Roman the PDF doesn't embed, as a TrueType CID + /// font (`CIDFontType2`) whose CIDs aren't of an Adobe character collection. Its CIDToGIDMap + /// gives glyph indexes in the author's font, which numbers its first 258 glyphs in the + /// standard Macintosh order of the TrueType `post` table (#5889, #6449). + mac_glyph_order: bool, } impl Type0Font { @@ -131,18 +141,24 @@ impl Type0Font { .is_some_and(|n| matches!(n.as_ref(), b"Identity-H" | b"Identity-V")); let mut to_unicode_is_cid_indexed = false; + // PdfCraft patch (84): the character collection's UCS2 CMap (ISO 32000-2 §9.10.2), CID + // to Unicode, for a font of an Adobe collection whether or not its program is embedded. + let mut ucs2 = character_collection + .as_ref() + .and_then(|cc| cc.family.ucs2_cmap()) + .and_then(|name| (cmap_resolver)(name)) + .map(|data| Ucs2 { + data, + resolver: cmap_resolver.clone(), + map: OnceLock::new(), + }); // If there is no ToUnicode map, try to get the UCS2 CMap. if fallback && to_unicode.is_none() - && let Some(cc) = character_collection.as_ref() - && let Some(ucs2_name) = cc.family.ucs2_cmap() - && let Some(data) = (cmap_resolver)(ucs2_name) + && let Some(ucs2_cmap) = ucs2.take().and_then(|u| u.parse()) { - let resolver = cmap_resolver.clone(); - if let Some(ucs2_cmap) = CMap::parse(data, move |n| (resolver)(n)) { - to_unicode = Some(ucs2_cmap); - to_unicode_is_cid_indexed = true; - } + to_unicode = Some(ucs2_cmap); + to_unicode_is_cid_indexed = true; } let adobe_collection = character_collection @@ -153,6 +169,17 @@ impl Type0Font { .get::>(BASE_FONT) .map(|n| strip_subset_prefix(n.as_str()).to_string()); + // PdfCraft patch: the descendant names the font program itself; a Type 0 font's own + // `/BaseFont` may carry the CMap's name after it. + let mac_glyph_order = fallback + && !adobe_collection + && descendant_font + .get::>(SUBTYPE) + .is_some_and(|s| s.deref() == CID_FONT_TYPE2) + && descendant_font + .get::>(BASE_FONT) + .is_some_and(|n| numbers_glyphs_in_mac_order(strip_subset_prefix(n.as_str()))); + // Extract font flags from descriptor let font_flags = font_descriptor .get::(FLAGS) @@ -173,8 +200,10 @@ impl Type0Font { font_flags, fallback, to_unicode_is_cid_indexed, + ucs2, adobe_collection, identity_to_unicode, + mac_glyph_order, }) } @@ -198,6 +227,16 @@ impl Type0Font { return glyph; } + // PdfCraft patch: the CID of an Arial or a Times New Roman the PDF doesn't embed names + // one of that font's glyphs, which the substitute draws by that name. Taken as the + // substitute's own glyph index it drew an unrelated glyph: "Low Power" as + // "@ck 'Dck Yf" (#5889), "Salarié(e) du régime" as "Salari²*g+f w't²i lo" (#6449). + if self.mac_glyph_order + && let Some(glyph) = self.map_via_mac_glyph_name(cid) + { + return glyph; + } + // PdfCraft patch: a substitute that isn't CID-keyed numbers its glyphs its own way, so // the CID of an Adobe character collection taken as its glyph index draws an unrelated // glyph. Draw .notdef, as for a CID the font has no glyph for. @@ -307,6 +346,27 @@ impl Type0Font { } } + /// PdfCraft patch: the substitute's glyph for the author's glyph that `cid` gives, found by + /// that glyph's name in the standard Macintosh order. `None` past the 258 glyphs the order + /// names, and for a substitute that isn't a name-keyed CFF font (the standard substitutes + /// are); .notdef for a name the substitute has no glyph for. + fn map_via_mac_glyph_name(&self, cid: u32) -> Option { + let FontType::Cff(c) = &self.font_type else { + return None; + }; + if c.is_cid() { + return None; + } + let gid = self.cid_to_gid_map.map(u16::try_from(cid).ok()?); + let name = *DEFAULT_GLYPH_NAMES.get(usize::try_from(gid.to_u32()).ok()?)?; + // `.null` and `nonmarkingreturn` draw nothing, and `nonbreakingspace` is a space. + let name = match name { + ".null" | "nonmarkingreturn" | "nonbreakingspace" => "space", + name => name, + }; + Some(c.glyph_index_by_name(name).unwrap_or(GlyphId::NOTDEF)) + } + fn code_to_cid(&self, code: u32) -> Option { for byte_len in 1..=4_u8 { if let Some(cid) = self.encoding.lookup_cid_code(code, byte_len) { @@ -415,6 +475,13 @@ impl Type0Font { } } + /// PdfCraft patch: the code's width w0 (its horizontal displacement, from `/W` or `/DW`) + /// in either writing mode. In vertical writing it is the glyph's extent across the column + /// (ISO 32000-2 §9.7.4.3). + pub(crate) fn code_horizontal_width(&self, code: u32) -> f32 { + self.horizontal_width(self.code_to_cid(code).unwrap_or(0)) + } + fn horizontal_width(&self, cid: u32) -> f32 { self.widths.get(&cid).copied().unwrap_or(self.dw) } @@ -466,13 +533,63 @@ impl Type0Font { } else { code }; - return to_unicode.lookup_bf_string(key); + // PdfCraft patch (85): a `/ToUnicode` written as a CID CMap (`begincidrange` / + // `begincidchar`, whose destinations are numbers) gives the code's Unicode value as + // that number, as glyph lookup already reads it (`map_via_unicode`) and as pdftotext + // and PDFium extract it. ISO 32000-2 §9.10.3 has `beginbfrange` / `beginbfchar` here; + // Word writes CID ranges, and such text extracted nothing. + if let Some(s) = to_unicode.lookup_bf_string(key).or_else(|| { + (1..=4) + .find_map(|len| { + to_unicode + .lookup_cid_code(key, len) + .and_then(|cid| char::from_u32(cid.to_u32())) + }) + .map(BfString::Char) + }) { + return Some(s); + } + } + // PdfCraft patch (84): then the collection's UCS2 CMap, by the code's CID. + if let Some(ucs2) = self.ucs2.as_ref().and_then(Ucs2::cmap) + && let Some(s) = self + .code_to_cid(code) + .and_then(|cid| ucs2.lookup_bf_string(cid)) + { + return Some(s); } None } } +/// PdfCraft patch (84): a collection's UCS2 CMap, parsed the first time a code needs it: a font +/// whose `/ToUnicode` maps every code it shows never parses it. +struct Ucs2 { + data: &'static [u8], + resolver: CMapResolverFn, + map: OnceLock>, +} + +impl Ucs2 { + fn parse(&self) -> Option { + let resolver = self.resolver.clone(); + CMap::parse(self.data, move |n| (resolver)(n)) + } + + fn cmap(&self) -> Option<&CMap> { + self.map.get_or_init(|| self.parse()).as_ref() + } +} + +impl std::fmt::Debug for Ucs2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Ucs2") + .field("bytes", &self.data.len()) + .finish_non_exhaustive() + } +} + impl CacheKey for Type0Font { fn cache_key(&self) -> u128 { self.cache_key @@ -582,6 +699,37 @@ fn is_variation_selector(c: char) -> bool { matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}') } +/// PdfCraft patch: whether `name`, a `/BaseFont` without its subset tag, is one of the four +/// faces of Arial or Times New Roman as exporters name them: `ArialMT`, `Arial-BoldMT`, +/// `Arial-ItalicMT`, `Arial-BoldItalicMT`, `TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT` and so +/// on, or `Arial` or `TimesNewRoman` followed by `,Bold`, `,Italic` or `,BoldItalic`. Both +/// number their first 258 glyphs in the standard Macintosh order. Arial Narrow, Arial Black, +/// Arial Unicode MS and Arial Rounded are other fonts, numbered their own way. Letter case +/// doesn't matter, `MT` included. +fn numbers_glyphs_in_mac_order(name: &str) -> bool { + let (family, style) = name.split_once([',', '-', ' ']).unwrap_or((name, "")); + let style: String = style + .chars() + .filter(|c| !matches!(c, ',' | '-' | ' ')) + .collect(); + ["Arial", "TimesNewRoman", "TimesNewRomanPS"] + .iter() + .any(|f| without_mt(family).eq_ignore_ascii_case(f)) + && ["", "Regular", "Bold", "Italic", "BoldItalic"] + .iter() + .any(|s| without_mt(&style).eq_ignore_ascii_case(s)) +} + +/// PdfCraft patch: `name` without a trailing `MT` in any letter case (Monotype's mark in +/// PostScript names such as `ArialMT`). +fn without_mt(name: &str) -> &str { + name.len() + .checked_sub(2) + .and_then(|stem| Some((name.get(..stem)?, name.get(stem..)?))) + .filter(|(_, suffix)| suffix.eq_ignore_ascii_case("MT")) + .map_or(name, |(stem, _)| stem) +} + /// PdfCraft patch: the largest CID (ISO 32000-2 §9.7.2; CIDs are at most two bytes). const MAX_CID: u32 = 65_535; diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index e0fc75981b..77078bf5d3 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -87,6 +87,8 @@ impl Glyph<'_> { /// 1. `ToUnicode` cmap /// 2. Glyph name → Unicode (via Adobe Glyph List) /// 3. Unicode naming conventions (e.g., "uni0041", "u0041") + /// 4. PdfCraft patch (100), Type1 and CFF fonts only: a name that only numbers its glyph, as + /// for Type3 fonts below /// /// **For CID Fonts (Type0):** /// 1. `ToUnicode` cmap @@ -94,6 +96,12 @@ impl Glyph<'_> { /// /// **For Type3 Fonts:** /// 1. `ToUnicode` cmap + /// 2. PdfCraft patch: the `/Encoding` glyph name → Unicode (an Annex D.2 alias code reads as + /// the ordinary character) + /// 3. PdfCraft patch: a printable ASCII code (32–126) as itself + /// 4. PdfCraft patch (100): above 126, a name that only numbers its glyph (`C174`) as the + /// code's Latin-1 character, when the number is that code and every numbered name in the + /// font names its own code /// /// Returns `None` if the Unicode value could not be determined. /// @@ -179,6 +187,19 @@ impl OutlineGlyph { self.font.glyph_advance_width(self.char_code) } + /// PdfCraft patch: for a glyph of a vertical-writing Type0 font, its width w0 (the + /// horizontal displacement from `/W` or `/DW`), which is how wide the glyph sits across the + /// column (ISO 32000-2 §9.7.4.3). `None` for horizontal writing, where + /// [`advance_width`](Self::advance_width) gives it. + pub fn vertical_glyph_width(&self) -> Option { + match &self.font { + OutlineFont::Type0(font) if !font.is_horizontal() => { + Some(font.code_horizontal_width(self.char_code)) + } + _ => None, + } + } + /// Get the cache key for this glyph's font. /// /// This identifies the font uniquely, even when `font_data()` returns `None` @@ -216,9 +237,11 @@ impl<'a> Type3Glyph<'a> { .render_glyph(self, transform, glyph_transform, paint, device); } - /// Returns the Unicode code point for this glyph, if available. + /// Returns the Unicode code point for this glyph, if available: the `ToUnicode` cmap, then + /// the `/Encoding` glyph name, a printable ASCII code, and a numbered glyph name at its own + /// code (PdfCraft patches). /// - /// Note: Type3 fonts can only provide Unicode via `ToUnicode` cmap. + /// See [`Glyph::as_unicode`] for details on the fallback chain used. pub fn as_unicode(&self) -> Option { self.font.char_code_to_unicode(self.char_code) } @@ -244,8 +267,14 @@ impl<'a> Font<'a> { font_resolver: &FontResolverFn, cmap_resolver: &CMapResolverFn, ) -> Option { - let f_type = match dict.get::>(SUBTYPE)?.deref() { - TYPE1 | MM_TYPE1 => { + // PdfCraft patch (94): a font dictionary whose `/Subtype` is missing or the empty name + // (`/Subtype /`) is read as a Type 1 font, so its `/BaseFont` still picks the font: a + // `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). `/Subtype` is + // required (ISO 32000-2 §9.6.2.1, Table 109); other readers fall back to Type 1 this way. + let subtype = dict.get::>(SUBTYPE); + let subtype: &[u8] = subtype.as_deref().unwrap_or(b""); + let f_type = match subtype { + b"" | TYPE1 | MM_TYPE1 => { FontType::Type1(Rc::new(Type1Font::new(dict, font_resolver, cmap_resolver)?)) } // PDFBOX-5463: PDF viewers seem to accept OpenType as well. @@ -271,6 +300,12 @@ impl<'a> Font<'a> { Some(Self(cache_key, f_type)) } + /// PdfCraft patch (90): whether this is a Type 3 font without `/Widths` (or + /// `/FirstChar`/`/LastChar`), whose glyphs advance by their `d0`/`d1` widths. + pub(crate) fn type3_widths_missing(&self) -> bool { + matches!(&self.1, FontType::Type3(t) if t.widths_missing()) + } + pub(crate) fn new_standard( standard_font: StandardFont, font_resolver: &FontResolverFn, @@ -669,7 +704,7 @@ pub(crate) fn glyph_name_to_unicode(name: &str) -> Option { return unicode_str.chars().next(); } - unicode_from_name(name).or_else(|| { + agl_unicode_from_name(name).or_else(|| { warn!("failed to map glyph name {} to unicode", name); None @@ -719,6 +754,99 @@ pub(crate) fn glyph_name_to_text(name: &str) -> Option { } } +/// PdfCraft patch: the numbers a glyph name that only numbers its glyph gives (`C39`, `G27`, +/// `MT174`: one to three ASCII letters, then one to six decimal or hexadecimal digits), for +/// every split into letters and digits, read in decimal and in hexadecimal (`GA9` is `G` with +/// hex `A9`); nothing for any other name. Everything from the first `.` is dropped first, as +/// `glyph_name_to_text` drops it. +fn glyph_name_numbers(name: &str) -> impl Iterator + '_ { + let base = name.split('.').next().unwrap_or_default(); + (1..=3).flat_map(move |prefix| { + // `from_str_radix` would also take a leading `+`: the number is digits only, and at + // most six of them (a code is under 256, leading zeros as in `C0032`). + let digits = base + .get(..prefix) + .zip(base.get(prefix..)) + .and_then(|(letters, digits)| { + (letters.bytes().all(|b| b.is_ascii_alphabetic()) + && (1..=6).contains(&digits.len()) + && digits.bytes().all(|b| b.is_ascii_hexdigit())) + .then_some(digits) + }); + digits.into_iter().flat_map(|d| { + [10, 16] + .into_iter() + .filter_map(move |radix| u32::from_str_radix(d, radix).ok()) + }) + }) +} + +/// PdfCraft patch: whether every glyph name in a simple font's encoding that only numbers its +/// glyph (see `glyph_name_numbers`) names the very code it sits at, in decimal or hexadecimal. +/// Names the glyph list or its `uni`/`u` forms read are not numbered names. A font whose names +/// number something else (`/Differences [1 /C70 /C105 …]`, a glyph's order in the subset) +/// fails, and so does one where a single name numbers another code: there a name that happens +/// to match its code (`/MT40` at 0x40, `/C1` at 233 beside `/C70` at 70, review on #3239) is a +/// coincidence, not evidence. A subset that counts its glyphs in order of first use from code 1 +/// (`/Differences [1 /C1 /C2 … /C80]`) names its own codes too, but the counter says nothing +/// about the characters (`/C65` is the 65th glyph used, not `A`): a numbered name at a C0 +/// control code (below 32), which no text shows, gives such a counter away and fails the font. +pub(crate) fn numbered_names_name_their_codes<'a>( + names: impl IntoIterator, +) -> bool { + names + .into_iter() + .filter(|(_, name)| { + glyph_names::get(name).is_none() + && unicode_from_name(name).is_none() + && glyph_name_to_text(name).is_none() + }) + .all(|(code, name)| { + let mut numbers = glyph_name_numbers(name).peekable(); + numbers.peek().is_none() || (code >= 0x20 && numbers.any(|n| n == u32::from(code))) + }) +} + +/// PdfCraft patch: the text of a glyph name outside the glyph list that only numbers its glyph +/// (see `glyph_name_numbers`) when that number is the very code the glyph is shown with: the +/// code's Latin-1 character, for codes above 127 too (`C174` reads `®`), as pdftotext, PDFium +/// and MuPDF read it. The name and the code then agree, so nothing is guessed from an +/// encoding table. A number naming another code (`/C1` at 233, review on #3239) and a control +/// character read as nothing. Callers apply it only in a font where +/// `numbered_names_name_their_codes` holds. +pub(crate) fn numbered_glyph_name_at_its_code(name: &str, code: u8) -> Option { + let c = char::from(code); + if c.is_control() { + return None; + } + glyph_name_numbers(name) + .any(|n| n == u32::from(code)) + .then_some(BfString::Char(c)) +} + +/// PdfCraft patch (92): the character a `uniXXXX` or `uXXXX` to `uXXXXXX` glyph name stands +/// for, by the Adobe Glyph List Specification: `uni` and exactly four uppercase hex digits, or +/// `u` and four to six; a value in the surrogate range or past U+10FFFF is no character. The +/// text of a glyph goes by this rule, so `uni65`, `uni20ac` and `u41` read as nothing, as they +/// do in [`glyph_name_to_text`]. +pub(crate) fn agl_unicode_from_name(name: &str) -> Option { + let upper_hex = |d: &str| d.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')); + let digits = name + .strip_prefix("uni") + .filter(|d| d.len() == 4) + .or_else(|| { + name.strip_prefix('u') + .filter(|d| (4..=6).contains(&d.len())) + }) + .filter(|d| upper_hex(d))?; + u32::from_str_radix(digits, 16) + .ok() + .and_then(char::from_u32) +} + +/// Upstream's lenient reading of a `uni…` or `u…` name (any number of hex digits, either case), +/// kept for finding a glyph in a font: PdfCraft patch (92) reads text by +/// [`agl_unicode_from_name`] instead. pub(crate) fn unicode_from_name(name: &str) -> Option { let convert = |input: &str| u32::from_str_radix(input, 16).ok().and_then(char::from_u32); diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index 7eb25ff2c7..b253d8c5ea 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -4,7 +4,7 @@ use crate::font::generated::{glyph_names, metrics, standard, symbol, zapf_dings} use crate::font::true_type::{Width, read_encoding, read_widths}; use crate::font::{ Encoding, FontData, FontQuery, glyph_name_to_unicode, normalized_glyph_name, stretch_glyph, - strip_subset_prefix, + strip_subset_prefix, unicode_from_name, }; use hayro_syntax::object::Dict; use hayro_syntax::object::Name; @@ -201,6 +201,32 @@ impl StandardFont { } } +/// PdfCraft patch (98): the style names that may follow `Symbol` or `SymbolMT` after a `,` or +/// `-` and still name the Symbol font (any case). +const SYMBOL_STYLES: [&str; 9] = [ + "Bold", + "Italic", + "BoldItalic", + "Oblique", + "BoldOblique", + "Regular", + "Roman", + "Normal", + "Plain", +]; + +/// PdfCraft patch (98): whether `name` (without a subset prefix) is `Symbol` or `SymbolMT`, alone +/// or followed by `,` or `-` and a style from [`SYMBOL_STYLES`]. Another name after the +/// separator (`Symbol-Helvetica`) is a different font. +fn is_symbol_name(name: &str) -> bool { + let (family, style) = match name.split_once([',', '-']) { + Some((family, style)) => (family, Some(style)), + None => (name, None), + }; + matches!(family, "Symbol" | "SymbolMT") + && style.is_none_or(|style| SYMBOL_STYLES.iter().any(|s| s.eq_ignore_ascii_case(style))) +} + enum StandardFontFamily { Helvetica, Courier, @@ -233,6 +259,13 @@ pub(crate) fn select_standard_font( _ => {} } + // PdfCraft patch (98): `SymbolMT` (the name Symbol has as a TrueType font) and the style + // variants `Symbol,Bold`, `SymbolMT,Italic` or `Symbol-Bold` are the Symbol font, drawn with + // its built-in encoding like `Symbol` itself, whatever `/Encoding` and flags they declare. + if is_symbol_name(name) { + return Some((StandardFont::Symbol, true)); + } + // Now, we bruteforce, trying to determine a suitable font based on the // keywords that appear in the name. let lower = name.to_ascii_lowercase(); @@ -769,7 +802,11 @@ fn accented_parts(name: &str) -> Option<(&'static str, &'static str, Place)> { .map(|&(_, base, accent, place)| (base, accent, place)) }; by_name(name).or_else(|| { - let c = glyph_name_to_unicode(name)?; + // Drawing keeps upstream's lenient reading of `uni` names; text goes by the glyph list + // specification (PdfCraft patch 92). + let c = glyph_names::get(name) + .and_then(|s| s.chars().next()) + .or_else(|| unicode_from_name(name))?; match COMMA_BELOW_BY_CHAR.iter().find(|(letter, _)| *letter == c) { Some((_, comma_below)) => by_name(comma_below), None => by_name(glyph_names::get_reverse(c)?), diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index 25b3ebc4e7..336dfc169e 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -18,6 +18,7 @@ use kurbo::BezPath; use skrifa::attribute::Style; use skrifa::raw::TableProvider; use skrifa::raw::tables::cmap::PlatformId; +use skrifa::raw::tables::post::DEFAULT_GLYPH_NAMES; use skrifa::{GlyphId, MetadataProvider}; use std::cell::RefCell; use std::collections::HashMap; @@ -220,6 +221,9 @@ struct EmbeddedKind { cached_mappings: RefCell>, /// PostScript name from the PDF. postscript_name: Option, + /// PdfCraft patch (101): whether the font may take the Mac OS Roman reading at all, worked out + /// once when it is loaded (`EmbeddedKind::mac_roman_name`). + mac_roman_eligible: bool, } impl EmbeddedKind { @@ -247,6 +251,27 @@ impl EmbeddedKind { .get::>(BASE_FONT) .map(|n| strip_subset_prefix(n.as_str()).to_string()); + // PdfCraft patch (101): the font names no base encoding, not even one this reader doesn't + // know (which `read_encoding` reads as the built-in one), and isn't Symbol or ZapfDingbats, + // whose built-in encodings aren't Latin (ISO 32000-2 §9.6.6.1). Both as the editor's + // `unicode_map` decides them, Symbol and ZapfDingbats recognised by name as there. + let names_base_encoding = dict.get::>(ENCODING).is_some() + || dict + .get::>(ENCODING) + .is_some_and(|d| d.get::>(BASE_ENCODING).is_some()); + let symbolic_builtin = dict.get::>(BASE_FONT).is_some_and(|n| { + let name = n.as_str(); + let flat: String = name + .rsplit('+') + .next() + .unwrap_or(name) + .chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect(); + flat.contains("symbol") || flat.contains("zapf") || flat.contains("dingbat") + }); + Some(Self { base_font, differences, @@ -258,6 +283,7 @@ impl EmbeddedKind { encoding, cached_mappings: RefCell::new(HashMap::new()), postscript_name, + mac_roman_eligible: !names_base_encoding && !symbolic_builtin, }) } @@ -291,6 +317,8 @@ impl EmbeddedKind { // `Encoding::text_alias`. .or_else(|| self.encoding.text_alias(code)) .or_else(|| self.encoding.map_code(code)) + // PdfCraft patch (101): see `EmbeddedKind::mac_roman_name`. + .or_else(|| self.mac_roman_name(code)) // The last resort reads the code as WinAnsi whatever the font said, so it takes the // same aliasing. .or_else(|| Encoding::WinAnsi.text_alias(code)) @@ -400,6 +428,68 @@ impl EmbeddedKind { glyph } + /// PdfCraft patch (101): the Mac OS Roman glyph name a code of a symbolic font without an + /// encoding or glyph names reads as, 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 has single bytes "look up the associated glyph descriptions" there, and + /// has a (3, 0) subtable map "the single-byte codes in the (1, 0) subtable" prepended with + /// 0xF0; + /// - the glyph sits where the standard Macintosh glyph order (the 258 names of a version 1.0 + /// `post` table) puts that character. Fonts distilled from Macintosh originals keep that + /// order and drop the names (`post` version 3.0): pdf.js's issue2017r.pdf draws code 0x88 + /// with glyph 106, `agrave`, Mac OS Roman 0x88. + /// + /// The second test is needed: Windows subsets in the same order carry a (1, 0) subtable too, + /// but their codes are WinAnsi (code 0x92 draws glyph 183, `quoteright`, not Mac OS Roman's + /// `iacute`), and reading every such code as Mac OS Roman turned "USDA’s" into "USDAís". + /// A font that names its glyphs in its `post` table needn't keep the order (a Wingdings subset + /// draws code 0x83 with glyph 101, `Eacute` in the order, which it names `threesans`). + /// `None` when the font is non-symbolic, names a base encoding (one this reader doesn't know + /// too), is Symbol or ZapfDingbats (whose + /// built-in encodings aren't Latin, ISO 32000-2 §9.6.6.1; recognised by name as the editor's + /// `unicode_map` recognises them), is a CFF program, names its glyphs, or either test fails + /// (SAS's AMT fonts carry a wrong (1, 0) table, #4156): the code then reads as before. Glyph + /// selection is unchanged. + fn mac_roman_name(&self, code: u8) -> Option<&'static str> { + if self.is_non_symbolic() + || !self.mac_roman_eligible + || !matches!(self.encoding, Encoding::BuiltIn) + || self.cff_blob.is_some() + || !self.glyph_names.is_empty() + { + return None; + } + let glyph = self.map_code(code); + if glyph == GlyphId::NOTDEF { + return None; + } + let cmap = self.base_font.font_ref().cmap().ok()?; + let mac = cmap.encoding_records().iter().find_map(|record| { + if record.platform_id() == PlatformId::Macintosh && record.encoding_id() == 0 { + record + .subtable(cmap.offset_data()) + .ok()? + .map_codepoint(code) + } else { + None + } + })?; + if mac != glyph { + return None; + } + let standard = match *DEFAULT_GLYPH_NAMES.get(usize::try_from(glyph.to_u32()).ok()?)? { + // The order's name for Mac OS Roman 0xCA; the tables here call it `nbspace`. + "nonbreakingspace" => "nbspace", + name => name, + }; + let name = mac_os_roman::get(code).or_else(|| mac_roman::get(code))?; + if name != standard { + return None; + } + Encoding::MacRoman.text_alias(code).or(Some(name)) + } + fn glyph_width(&self, code: u8) -> f32 { match self.widths.get(code as usize).copied() { Some(Width::Value(w)) => w, diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index cad88a5850..366dd45311 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -3,7 +3,7 @@ use crate::font::standard_font::{StandardFont, StandardKind, select_standard_fon use crate::font::true_type::{Width, read_encoding, read_widths}; use crate::font::{ Encoding, FallbackFontQuery, glyph_name_to_text, glyph_name_to_unicode, normalized_glyph_name, - read_to_unicode, + numbered_glyph_name_at_its_code, numbered_names_name_their_codes, read_to_unicode, }; use crate::{CMapResolverFn, CacheKey, FontResolverFn}; use hayro_cmap::{BfString, CMap}; @@ -13,10 +13,17 @@ use hayro_syntax::object::dict::keys::{FONT_DESC, FONT_FILE, FONT_FILE3}; use kurbo::BezPath; use skrifa::GlyphId; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; #[derive(Debug)] -pub(crate) struct Type1Font(u128, Kind, Option); +pub(crate) struct Type1Font( + u128, + Kind, + Option, + /// PdfCraft patch: whether the font's numbered glyph names all name their own codes + /// (`numbered_names_name_their_codes`), worked out on first use. + OnceLock, +); impl Type1Font { pub(crate) fn new( @@ -50,23 +57,29 @@ impl Type1Font { resolver, )?), to_unicode.clone(), + OnceLock::new(), )) }; let inner = if is_cff(dict) { if let Some(cff) = CffKind::new(dict) { - Self(cache_key, Kind::Cff(cff), to_unicode) + Self(cache_key, Kind::Cff(cff), to_unicode, OnceLock::new()) } else { return fallback(); } } else if is_type1(dict) { if let Some(f) = Type1Kind::new(dict) { - Self(cache_key, Kind::Type1(f), to_unicode) + Self(cache_key, Kind::Type1(f), to_unicode, OnceLock::new()) } else { return fallback(); } } else if let Some(standard) = StandardKind::new(dict, resolver) { - Self(cache_key, Kind::Standard(standard), to_unicode) + Self( + cache_key, + Kind::Standard(standard), + to_unicode, + OnceLock::new(), + ) } else { return fallback(); }; @@ -78,7 +91,7 @@ impl Type1Font { let dict = Dict::default(); let standard = StandardKind::new_with_standard(&dict, font, true, resolver)?; - Some(Self(0, Kind::Standard(standard), None)) + Some(Self(0, Kind::Standard(standard), None, OnceLock::new())) } pub(crate) fn map_code(&self, code: u8) -> GlyphId { @@ -128,15 +141,30 @@ impl Type1Font { } // PdfCraft patch (#3169, #3170): a name outside the glyph list still reads by the // glyph list specification's full mapping (`f_i`, `hyphen.case`). A name that only - // numbers the glyph (`C39`, `c251`) says nothing about the character and reads as - // nothing: the code's StandardEncoding character would be a guess, wrong wherever the - // font's /Encoding puts the glyph elsewhere (review on #3239). - let name = match &self.1 { + // numbers the glyph (`C39`, `c251`) reads as the code's Latin-1 character only when its + // number is that code (`C39` at 39) and every numbered name in the font names its own + // code, as pdftotext, PDFium and MuPDF read it. Anywhere else the number is no + // evidence and reads as nothing: the code's StandardEncoding character would be a + // guess, wrong wherever the font's /Encoding puts the glyph elsewhere (review on #3239). + let name = self.code_to_ps_name(code)?; + glyph_name_to_text(name).or_else(|| { + let own_codes = *self.3.get_or_init(|| { + numbered_names_name_their_codes( + (0..=u8::MAX).filter_map(|c| Some((c, self.code_to_ps_name(c)?))), + ) + }); + own_codes + .then(|| numbered_glyph_name_at_its_code(name, code)) + .flatten() + }) + } + + fn code_to_ps_name(&self, code: u8) -> Option<&str> { + match &self.1 { Kind::Standard(s) => s.code_to_ps_name(code), Kind::Cff(c) => c.code_to_ps_name(code), Kind::Type1(t) => t.code_to_ps_name(code), - }?; - glyph_name_to_text(name) + } } } diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index c0e0d0b2df..e5c313ab7e 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -1,9 +1,14 @@ use crate::CMapResolverFn; -use crate::context::Context; +use crate::context::{ + Context, HeldContent, MAX_CACHED_TYPE3_PROCS, Type3Procs, content_left, hold_content, +}; use crate::device::Device; use crate::font::glyph_simulator::GlyphSimulator; use crate::font::true_type::{Width, read_encoding, read_widths}; -use crate::font::{Encoding, Glyph, Type3Glyph, UNITS_PER_EM, read_to_unicode}; +use crate::font::{ + Encoding, Glyph, Type3Glyph, UNITS_PER_EM, numbered_glyph_name_at_its_code, + numbered_names_name_their_codes, read_to_unicode, +}; use crate::interpret::state::TextState; use crate::soft_mask::SoftMask; use crate::util::RectExt; @@ -19,12 +24,18 @@ use hayro_syntax::object::dict::keys::{CHAR_PROCS, FONT_BBOX, FONT_MATRIX, RESOU use hayro_syntax::page::Resources; use kurbo::{Affine, BezPath, Rect}; use skrifa::GlyphId; +use std::cell::{OnceCell, RefCell}; use std::collections::HashMap; +use std::rc::Rc; +use std::sync::OnceLock; #[derive(Debug)] pub(crate) struct Type3<'a> { widths: Vec, missing_width: f32, + /// PdfCraft patch (90): the width each code's glyph procedure gives with `d0`/`d1`, read + /// the first time the code is shown in a font without `/Widths`. One cell per code. + proc_widths: Vec>>, encoding: Encoding, encodings: HashMap, dict: Dict<'a>, @@ -33,8 +44,23 @@ pub(crate) struct Type3<'a> { font_bbox: Rect, matrix: Affine, to_unicode: Option, + /// PdfCraft patch (87): glyph procedures already decoded, by glyph name, so a glyph shown + /// again and again (a Type 3 font used as a fill pattern draws one glyph hundreds of + /// thousands of times) isn't inflated again for every copy. Only procedures of at most + /// [`MAX_CACHED_PROC`] bytes are kept, and the fonts of one interpreter cache keep at most + /// `context::MAX_CACHED_TYPE3_PROCS` bytes together. + decoded_procs: RefCell>>, + /// PdfCraft patch: whether the font's numbered glyph names all name their own codes + /// (`numbered_names_name_their_codes`), worked out on first use. + own_code_names: OnceLock, } +/// PdfCraft patch (87): the largest decoded glyph procedure [`Type3`] keeps. +const MAX_CACHED_PROC: usize = 64 << 10; +/// PdfCraft patch (87): what a kept procedure costs the cache besides its bytes and its glyph +/// name (the map entry and the shared allocation's counts), so many empty procedures count too. +const CACHED_PROC_OVERHEAD: usize = 64; + impl<'a> Type3<'a> { pub(crate) fn new(dict: &Dict<'a>, cmap_resolver: &CMapResolverFn) -> Option { let (encoding, encodings) = read_encoding(dict); @@ -71,13 +97,57 @@ impl<'a> Type3<'a> { char_procs, widths, missing_width, + proc_widths: vec![OnceCell::new(); usize::from(u8::MAX) + 1], encodings, matrix, dict: dict.clone(), to_unicode, + decoded_procs: RefCell::new(HashMap::new()), + own_code_names: OnceLock::new(), }) } + /// PdfCraft patch (87): the decoded procedure of glyph `name`, held within the page's + /// content budget while the glyph is painted, whether it was decoded now or kept from an + /// earlier copy, so a page skips the same glyphs either way once the budget is spent. It is + /// decoded the first time and kept, when small enough and while `procs` has room, for the + /// next copies. Only a procedure decoded with more budget left than [`MAX_CACHED_PROC`] is + /// kept, so one the budget may have cut short isn't. + fn decoded_proc( + &self, + name: &str, + program: &Stream<'a>, + procs: &Type3Procs, + ) -> Option<(Rc<[u8]>, HeldContent)> { + let kept = self.decoded_procs.borrow().get(name).cloned(); + if let Some(bytes) = kept { + let held = hold_content(bytes.len())?; + return Some((bytes, held)); + } + let left = content_left(); + // PdfCraft patch: see `context::MAX_PAGE_CONTENT`. + let decoded = crate::context::decode_content(program)?; + procs.decodes.set(procs.decodes.get().saturating_add(1)); + let bytes: Rc<[u8]> = Rc::from(decoded.as_ref()); + drop(decoded); + let held = hold_content(bytes.len())?; + let cost = bytes + .len() + .saturating_add(name.len()) + .saturating_add(CACHED_PROC_OVERHEAD); + let total = procs.held.get().saturating_add(cost); + if left > MAX_CACHED_PROC + && bytes.len() <= MAX_CACHED_PROC + && total <= MAX_CACHED_TYPE3_PROCS + { + procs.held.set(total); + self.decoded_procs + .borrow_mut() + .insert(name.to_string(), bytes.clone()); + } + Some((bytes, held)) + } + pub(crate) fn map_code(&self, code: u8) -> GlyphId { self.encodings .get(&code) @@ -88,13 +158,53 @@ impl<'a> Type3<'a> { } pub(crate) fn glyph_width(&self, code: u8) -> f32 { + // PdfCraft patch (90): /Widths, /FirstChar and /LastChar are required for Type 3 fonts + // (Table 110), but a font without them gave every glyph no advance and its text was drawn on one spot. The glyph + // procedure's `d0`/`d1` operator states the glyph's width too (9.6.4), so such a font + // takes that width, as PDFium does. A font with /Widths is read as before: a code + // outside FirstChar..LastChar has width 0 (Table 110), as in Poppler and MuPDF. let w = match self.widths.get(code as usize).copied() { Some(Width::Value(w)) => w, + _ if self.widths.is_empty() => self.proc_width(code).unwrap_or(self.missing_width), _ => self.missing_width, }; (w * self.matrix.as_coeffs()[0] as f32) * UNITS_PER_EM } + /// PdfCraft patch (90): whether the font has no `/Widths` (or `/FirstChar`/`/LastChar`), so + /// that `glyph_width` falls back on the glyph procedures' `d0`/`d1` widths. + pub(crate) fn widths_missing(&self) -> bool { + self.widths.is_empty() + } + + /// PdfCraft patch (90): the horizontal width `wx` that the glyph procedure for `code` + /// sets with `d0` or `d1`, in glyph space. `None` when the code has no procedure, the + /// procedure sets no width or its width isn't finite. A procedure the page's content + /// budget can't decode isn't remembered, so a later page may still read it. + fn proc_width(&self, code: u8) -> Option { + let cell = self.proc_widths.get(usize::from(code))?; + if let Some(width) = cell.get() { + return *width; + } + let name = self.glyph_simulator.glyph_to_string(self.map_code(code))?; + let program = self.char_procs.get(&name)?; + let decoded = crate::context::decode_content(program)?; + let mut iter = TypedIter::new(decoded.as_ref()); + let mut width = None; + while let Some(op) = iter.next() { + match op { + TypedInstruction::ColorGlyph(d0) => width = Some(d0.0.as_f32()), + TypedInstruction::ShapeGlyph(d1) => width = Some(d1.0.as_f32()), + _ => continue, + } + break; + } + let width = width.filter(|w| w.is_finite()); + // Setting can't fail here: the cell was empty above and nothing in between fills it. + let _ = cell.set(width); + width + } + pub(crate) fn char_code_to_unicode(&self, char_code: u32) -> Option { // PdfCraft patch: upstream only consulted ToUnicode. Many Type3 fonts (e.g. TeX/dvips // bitmap fonts) have none; fall back to the /Encoding glyph names, then to printable @@ -115,9 +225,30 @@ impl<'a> Type3<'a> { name.and_then(crate::font::glyph_name_to_unicode) .or_else(|| (0x20..0x7f).contains(&code).then_some(code as char)) .map(BfString::Char) + // PdfCraft patch (100): above 126, a name that only numbers its glyph reads + // as the code's Latin-1 character when the number is that code and every + // numbered name in the font names its own code (`C174` at 174 reads `®`), + // as in a Type 1 font. + .or_else(|| { + let own_codes = *self.own_code_names.get_or_init(|| { + numbered_names_name_their_codes( + (0..=u8::MAX).filter_map(|c| Some((c, self.code_to_name(c)?))), + ) + }); + name.filter(|_| own_codes) + .and_then(|name| numbered_glyph_name_at_its_code(name, code)) + }) }) } + /// PdfCraft patch: the glyph name `/Encoding` gives a code, as `map_code` reads it. + fn code_to_name(&self, code: u8) -> Option<&str> { + self.encodings + .get(&code) + .map(String::as_str) + .or_else(|| self.encoding.map_code(code)) + } + pub(crate) fn render_glyph( &self, glyph: &Type3Glyph<'a>, @@ -144,9 +275,8 @@ impl<'a> Type3<'a> { let name = self.glyph_simulator.glyph_to_string(glyph.glyph_id)?; let program = self.char_procs.get(&name)?; - // PdfCraft patch: see `context::MAX_PAGE_CONTENT`. - let decoded = crate::context::decode_content(program)?; - let iter = TypedIter::new(decoded.as_ref()); + let (decoded, _held) = self.decoded_proc(&name, program, &glyph.cache.type3_procs)?; + let iter = TypedIter::new(&decoded); let is_shape_glyph = { let mut iter = iter.clone(); diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 332000e248..d86264ccb8 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -166,6 +166,10 @@ pub enum InterpreterWarning { /// read, or a Separation or DeviceN tint transform that cannot be evaluated) and was painted /// in a fallback colour. Reported at most once per content stream. ColorFallback, + /// PdfCraft patch (90): a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`), + /// which ISO 32000-2 Table 110 requires, was selected; its glyphs advance by the widths + /// their glyph procedures set with `d0`/`d1`. Reported each time such a font is selected. + Type3WidthsMissing, } /// PdfCraft patch (73): ends a page's JBIG2 allowance with the page, so that what decodes on its @@ -200,7 +204,15 @@ pub fn interpret_page<'a>( // PdfCraft patch (29): bracket the annotations so devices can tell their paints from // the document's own (the reading-colours setting recolours the document, not comments). device.begin_annotations(); + // PdfCraft patch (86): form fields' widgets are drawn after the page's other + // annotations, each group in `/Annots` order, as other readers draw them: a filled + // comment listed after a check box covered the box. ISO 32000-2 doesn't order the two. + // Two passes over the array, so nothing is held for every entry of a long one. + for widgets in [false, true] { for annot in annot_arr.iter::>() { + if (annot.get::>(SUBTYPE).as_deref() == Some(b"Widget")) != widgets { + continue; + } let flags = annot.get::(F).unwrap_or(0); // Annotation should be hidden (Hidden = bit 2, NoView = bit 6). @@ -316,6 +328,7 @@ pub fn interpret_page<'a>( draw_highlight_without_appearance(&annot, context, device); } } + } device.end_annotations(); } } diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index a08a60e3bd..32f40c3c08 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -2,7 +2,7 @@ use crate::color::{Color, ColorSpace, device_cmyk_to_gray}; use crate::context::{Context, path_as_rect}; use crate::device::Device; use crate::util::{BezPathExt, Float32Ext}; -use crate::{FillRule, Paint, PathDrawMode, StrokeProps}; +use crate::{BlendMode, FillRule, Paint, PathDrawMode, StrokeProps}; use kurbo::{BezPath, Cap, Join, PathEl}; use smallvec::smallvec; @@ -29,13 +29,62 @@ pub(crate) fn fill_stroke_path<'a>( device: &mut impl Device<'a>, fill_rule: FillRule, ) { - fill_path_impl(context, device, fill_rule, None); - stroke_path_impl(context, device, None); + with_fill_stroke_group(context, device, |context, device| { + fill_path_impl(context, device, fill_rule, None); + stroke_path_impl(context, device, None); + }); apply_pending_clip(context, device); context.path_mut().truncate(0); } +/// PdfCraft patch: when one operator both fills and strokes a path (`B`, `B*`, `b`, `b*`) or +/// paints a glyph in text rendering mode 2 or 6, the fill and stroke form an implicit +/// non-isolated knockout transparency group that is composited as a unit with the current +/// soft mask and blend mode (ISO 32000-2 §11.7.4.4). +pub(crate) fn with_fill_stroke_group<'a, D: Device<'a>>( + context: &mut Context<'a>, + device: &mut D, + f: impl FnOnce(&mut Context<'a>, &mut D), +) { + let gs = &context.get().graphics_state; + let has_pattern = gs.stroke_cs.is_pattern() + || gs.stroke_pattern.is_some() + || gs.none_stroke_cs.is_pattern() + || gs.non_stroke_pattern.is_some(); + let needs_group = context.ocg_state.is_visible() + && !device.in_knockout_group() + && (gs.stroke_alpha < 1.0 + || gs.non_stroke_alpha < 1.0 + || gs.soft_mask.is_some() + || gs.blend_mode != BlendMode::Normal + || has_pattern); + + if !needs_group { + f(context, device); + return; + } + + let soft_mask = context.get_mut().graphics_state.soft_mask.take(); + let blend_mode = std::mem::replace( + &mut context.get_mut().graphics_state.blend_mode, + BlendMode::Normal, + ); + + device.set_soft_mask(None); + device.push_transparency_group(1.0, soft_mask.clone(), blend_mode); + device.set_knockout(); + device.set_blend_mode(BlendMode::Normal); + + f(context, device); + + device.pop_transparency_group(); + context.get_mut().graphics_state.soft_mask = soft_mask.clone(); + context.get_mut().graphics_state.blend_mode = blend_mode; + device.set_soft_mask(soft_mask); + device.set_blend_mode(blend_mode); +} + /// PdfCraft patch: a clip set by `W` / `W*` takes effect once the path-painting operator that /// ends the path has painted it, whichever operator that is (ISO 32000-2 §8.5.4). Upstream /// applied it only on `n`, and otherwise left it pending for some later, unrelated `n`. diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index b7b1b2a2b7..303a8abe12 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -2,7 +2,7 @@ use crate::GlyphDrawMode; use crate::context::Context; use crate::device::Device; use crate::font::Glyph; -use crate::interpret::path::get_paint; +use crate::interpret::path::{get_paint, with_fill_stroke_group}; use crate::interpret::state::TextStateFont; use hayro_syntax::object; use hayro_syntax::page::Resources; @@ -102,20 +102,22 @@ pub(crate) fn show_glyph<'a>( ); } TextRenderingMode::FillStroke => { - device.draw_glyph( - glyph, - ctx.get().ctm, - glyph_transform, - &get_paint(ctx, false), - &GlyphDrawMode::Fill, - ); - device.draw_glyph( - glyph, - ctx.get().ctm, - glyph_transform, - &get_paint(ctx, true), - &GlyphDrawMode::Stroke(stroke_props), - ); + with_fill_stroke_group(ctx, device, |ctx, device| { + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, false), + &GlyphDrawMode::Fill, + ); + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, true), + &GlyphDrawMode::Stroke(stroke_props), + ); + }); } TextRenderingMode::Invisible => { // Still call draw_glyph for invisible text, so that it can @@ -163,20 +165,22 @@ pub(crate) fn show_glyph<'a>( } TextRenderingMode::FillAndStrokeAndClip => { clip_glyph(ctx, glyph, glyph_transform); - device.draw_glyph( - glyph, - ctx.get().ctm, - glyph_transform, - &get_paint(ctx, false), - &GlyphDrawMode::Fill, - ); - device.draw_glyph( - glyph, - ctx.get().ctm, - glyph_transform, - &get_paint(ctx, true), - &GlyphDrawMode::Stroke(stroke_props), - ); + with_fill_stroke_group(ctx, device, |ctx, device| { + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, false), + &GlyphDrawMode::Fill, + ); + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, true), + &GlyphDrawMode::Stroke(stroke_props), + ); + }); } } } diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 04750f3761..f76bfdf58b 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -23,6 +23,10 @@ use hayro_syntax::xref::XRef; use kurbo::{Affine, BezPath, Rect, Shape}; use std::fmt::{Debug, Formatter}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// PdfCraft patch (78): the source of [`TilingPattern::instance`]. +static NEXT_TILING_INSTANCE: AtomicU64 = AtomicU64::new(0); /// A PDF pattern. #[derive(Debug, Clone)] @@ -156,6 +160,8 @@ impl CacheKey for ShadingPattern { #[derive(Clone)] pub struct TilingPattern<'a> { cache_key: u128, + /// PdfCraft patch (78): see [`Self::instance`]. + instance: u64, ctx_bbox: Rect, /// The bbox of the tiling pattern. pub bbox: Rect, @@ -233,6 +239,7 @@ impl<'a> TilingPattern<'a> { Some(Self { cache_key, + instance: NEXT_TILING_INSTANCE.fetch_add(1, Ordering::Relaxed), bbox, x_step, y_step, @@ -310,6 +317,23 @@ impl<'a> TilingPattern<'a> { } } +impl TilingPattern<'_> { + /// PdfCraft patch (78): which setting of the pattern (`scn`/`SCN`) this is. Each setting is + /// numbered apart, and its copies keep the number. A coloured pattern's cell depends only on + /// what the setting captured (its stream, resources, settings and the bounding box then in + /// effect) and on the scale it is drawn at, so a renderer can draw the cell once per number + /// and scale for every fill of that setting. An uncoloured pattern's cell also takes the + /// colour and opacity of the paint, which copies can change. + pub fn instance(&self) -> u64 { + self.instance + } + + /// Whether the pattern is coloured (`/PaintType 1`). + pub fn is_coloured(&self) -> bool { + self.is_color + } +} + impl CacheKey for TilingPattern<'_> { fn cache_key(&self) -> u128 { self.cache_key diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 6fcb34a5d7..849213a7f8 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -121,6 +121,24 @@ pub(crate) fn draw_form_xobject<'a, 'b>( x_object: &'b FormXObject<'a>, context: &mut Context<'a>, device: &mut impl Device<'a>, +) { + // PdfCraft patch (89): a form already being drawn is not drawn inside itself. A name its + // content uses can reach it again (through its own resources, or the page's, which a form + // with resources of its own still falls back on), and every such level used to run until + // the page's nested-paint budget was spent. + let id = x_object.dict.obj_id(); + if !context.enter_form(id) { + return; + } + draw_form_xobject_once(resources, x_object, context, device); + context.leave_form(id); +} + +fn draw_form_xobject_once<'a, 'b>( + resources: &Resources<'a>, + x_object: &'b FormXObject<'a>, + context: &mut Context<'a>, + device: &mut impl Device<'a>, ) { if !context.ocg_state.is_visible() { return; @@ -578,7 +596,8 @@ impl<'a> ImageXObject<'a> { self.decode_key(cache, true, target_dimension), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, - || decode_mask(self, target_dimension), + &self.warning_sink, + |sink| decode_mask(&self.reporting_to(sink), target_dimension), ) } @@ -598,7 +617,8 @@ impl<'a> ImageXObject<'a> { self.decode_key(cache, false, target_dimension), MAX_DECODED_IMAGE_BYTES, decoded_raster_size, - || decode_raster(self, target_dimension), + &self.warning_sink, + |sink| decode_raster(&self.reporting_to(sink), target_dimension), ) } @@ -627,13 +647,73 @@ impl<'a> ImageXObject<'a> { interpolate: self.interpolate, // PdfCraft patch (38): the same image drawn in a default colour space or without one. default_space: self.default_space, - // PdfCraft patch (32): see `decode_reads_target`. + // PdfCraft patch (32): see `decode_reads_target`; patch (82): see `has_larger_mask`. target: if self.decode_reads_target() { target_dimension + } else if self.has_larger_mask() { + target_dimension.map(size_bucket) } else { None }, preview: cache.preview, + shrunk_to: None, + }) + } + + /// PdfCraft patch (82): this soft mask (or `/Mask` image) for an image of `parent` samples + /// drawn at `target_dimension` device pixels. A mask larger than its image and more than + /// [`SOFT_MASK_SHRINK_FACTOR`] times larger than where it is drawn, on a side, is averaged down + /// to that size, and the result is cached at that size: drawn + /// through a form many times (a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 times on + /// one slide, pdf.js `issue16263.pdf`), it was decoded, copied and drawn at full size each + /// time, about 3.6 s apiece, and the page took minutes. Any other mask decodes as before; a mask + /// no larger than its image keeps its size, so the renderer still samples it onto the image's + /// grid. + pub(crate) fn decoded_soft_mask(&self, target_dimension: Option<(u32, u32)>, parent: (u32, u32)) -> Option { + let larger_than_image = self.width > parent.0 || self.height > parent.1; + let shrink_to = target_dimension.filter(|&(tw, th)| { + larger_than_image + && tw > 0 + && th > 0 + && (u64::from(self.width) > u64::from(tw) * SOFT_MASK_SHRINK_FACTOR + || u64::from(self.height) > u64::from(th) * SOFT_MASK_SHRINK_FACTOR) + }); + // Decoded within the image's own decode, as before: the image's cache entry holds it as + // its alpha, and patch (91) keeps that entry when the mask fails, so a second entry for + // the mask would only count it twice. + let Some((tw, th)) = shrink_to else { + return decode_mask(self, target_dimension); + }; + // Averaged down to the drawn size rounded up to a power of two (`size_bucket`), so draws a + // pixel apart share one mask and the renderer filters the rest, and only on the sides that + // are larger than that. + let (bw, bh) = size_bucket((tw, th)); + let to = (bw.min(self.width).max(1), bh.min(self.height).max(1)); + // Decoded in full and averaged down to `to`. A truncated stream decodes fewer rows than + // the dictionary says: never more target samples than decoded ones on a side. + let from_full = |image: &Self, to: (u32, u32)| { + let full = decode_mask(image, target_dimension)?; + Some(DecodedMask { luma: shrink_luma_within(full.luma, to)? }) + }; + let Some(cache) = &self.decoded_cache else { + return from_full(self, to); + }; + let key = |to| self.decode_key(cache, true, target_dimension).map(|key| DecodedImageKey { shrunk_to: Some(to), ..key }); + // One image drawn at several sizes (rotated and scaled copies on one slide) needs the + // mask at several sizes. Each comes from one intermediate averaged-down mask, cached at + // most `SOFT_MASK_INTERMEDIATE` on a side, so the full mask is decoded once, not once per + // size; a size larger than the intermediate comes from the full mask. + let between = (self.width.min(SOFT_MASK_INTERMEDIATE), self.height.min(SOFT_MASK_INTERMEDIATE)); + if to == between || to.0 > between.0 || to.1 > between.1 { + return cache.get_or_insert_with(key(to), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, &self.warning_sink, |sink| { + from_full(&self.reporting_to(sink), to) + }); + } + cache.get_or_insert_with(key(to), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, &self.warning_sink, |sink| { + let mid = cache.get_or_insert_with(key(between), MAX_DECODED_IMAGE_BYTES, decoded_mask_size, sink, |sink| { + from_full(&self.reporting_to(sink), between) + })?; + Some(DecodedMask { luma: shrink_luma_within(mid.luma, to)? }) }) } @@ -649,6 +729,29 @@ impl<'a> ImageXObject<'a> { || dict.get::>(MASK).is_some_and(|s| uses_jpx(&s)) } + /// PdfCraft patch (82): whether this image has a soft mask (or `/Mask` image) larger than + /// itself, which may be averaged down to the size the image is drawn at + /// (`decoded_soft_mask`): the decoded raster holds it as its alpha, so it depends on that size, + /// to the [`size_bucket`] it is averaged down to. + fn has_larger_mask(&self) -> bool { + let dict = self.stream.dict(); + let larger = |s: &Stream<'_>| { + let d = s.dict(); + // The dimension keys as `ImageXObject::new` reads them, abbreviations first. + let (w, h) = (d.get::(W).or_else(|| d.get::(WIDTH)), d.get::(H).or_else(|| d.get::(HEIGHT))); + w.is_some_and(|w| w > self.width) || h.is_some_and(|h| h > self.height) + }; + dict.get::>(SMASK).is_some_and(|s| larger(&s)) || dict.get::>(MASK).is_some_and(|s| larger(&s)) + } + + /// PdfCraft patch (91): this image, reporting its warnings to `sink`. + fn reporting_to(&self, sink: &WarningSinkFn) -> Self { + Self { + warning_sink: sink.clone(), + ..self.clone() + } + } + pub(crate) fn width(&self) -> u32 { self.width } @@ -672,6 +775,71 @@ impl<'a> ImageXObject<'a> { /// image pixels shared across tiles and scales. Past it the cache starts a fresh epoch. const MAX_DECODED_IMAGE_BYTES: usize = 64 << 20; +/// PdfCraft patch (82): how many times larger than where it is drawn, on a side, a soft mask must be +/// before it is averaged down to that size (`ImageXObject::decoded_soft_mask`). Below it the mask +/// keeps its own resolution, which the renderer filters as it draws. +const SOFT_MASK_SHRINK_FACTOR: u64 = 2; + +/// PdfCraft patch (82): the largest side of the intermediate mask that every smaller averaged-down +/// size of one soft mask is made from (`ImageXObject::decoded_soft_mask`): 4096 × 4096 one-byte +/// samples are 16 MiB, a quarter of the decoded-image budget, so it stays cached between draws. +const SOFT_MASK_INTERMEDIATE: u32 = 4096; + +/// PdfCraft patch (82): the size a large soft mask is averaged down to for an image drawn at +/// `(w, h)` device pixels: each side rounded up to a power of two. Draws of one image a pixel or so +/// apart (scaled by 0.36 and 0.36167, say) share one averaged mask and one cache entry, and a mask +/// is never averaged below where it is drawn, so the renderer shrinks the rest at most twofold. +fn size_bucket((w, h): (u32, u32)) -> (u32, u32) { + (w.checked_next_power_of_two().unwrap_or(u32::MAX), h.checked_next_power_of_two().unwrap_or(u32::MAX)) +} + +/// PdfCraft patch (82): `luma` averaged down to `to` (no larger than it on either side): each +/// target sample is the mean of the source samples whose centres fall in it. Source sample `x` +/// of `sw`, centred at `x + ½`, falls in target sample `⌊(2x + 1)·tw / 2sw⌋`. The only buffers are +/// the target's sums and counts, reserved fallibly; nothing is sized by the source's width. +fn shrink_luma(luma: LumaData, to: (u32, u32)) -> Option { + let (sw, sh) = (luma.width as usize, luma.height as usize); + let (tw, th) = (to.0 as usize, to.1 as usize); + if sw == 0 || sh == 0 || tw == 0 || th == 0 || tw > sw || th > sh || luma.data.len() != sw.checked_mul(sh)? { + return None; + } + let cells = tw.checked_mul(th)?; + let (mut sums, mut counts): (Vec, Vec) = (Vec::new(), Vec::new()); + sums.try_reserve_exact(cells).ok()?; + counts.try_reserve_exact(cells).ok()?; + sums.resize(cells, 0); + counts.resize(cells, 0); + // The target sample a source sample's centre falls in, along an axis of `src` samples + // shrunk to `dst`. `2·i + 1 < 2·src` and `dst ≤ src ≤ 2^32`, so the product fits a u64. + let centre = |i: usize, src: usize, dst: usize| -> usize { + let at = (2 * i as u64 + 1) * dst as u64 / (2 * src as u64); + (at as usize).min(dst - 1) + }; + for (y, row) in luma.data.chunks_exact(sw).enumerate() { + let base = centre(y, sh, th) * tw; + for (x, &v) in row.iter().enumerate() { + let i = base + centre(x, sw, tw); + if let (Some(sum), Some(count)) = (sums.get_mut(i), counts.get_mut(i)) { + *sum += u64::from(v); + *count += 1; + } + } + } + let data = sums.iter().zip(&counts).map(|(&sum, &count)| if count == 0 { 0 } else { (sum / u64::from(count)) as u8 }).collect(); + Some(LumaData { data, width: to.0, height: to.1, ..luma }) +} + +/// PdfCraft patch (82): `luma` averaged down to `to`, but never to more samples than it has on a +/// side (a truncated stream decodes fewer rows than its dictionary says), and returned as it is +/// when that is its own size. +fn shrink_luma_within(luma: LumaData, to: (u32, u32)) -> Option { + let to = (to.0.min(luma.width).max(1), to.1.min(luma.height).max(1)); + if to == (luma.width, luma.height) { + return Some(luma); + } + shrink_luma(luma, to) +} + /// PdfCraft patch: pixel bytes held by a decoded mask. fn decoded_mask_size(mask: &DecodedMask) -> usize { mask.luma.width as usize * mask.luma.height as usize @@ -1127,7 +1295,8 @@ fn resolve_alpha( { let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; - decode_mask(&obj, target_dimension).map(|decoded| decoded.luma) + // PdfCraft patch (82): averaged down when far larger than where it is drawn, and cached. + obj.decoded_soft_mask(target_dimension, (width, *height)).map(|decoded| decoded.luma) } else if let Some(color_key_mask) = dict.get::>(MASK) { let mut mask_data = vec![]; diff --git a/vendor/hayro-jbig2/src/file.rs b/vendor/hayro-jbig2/src/file.rs index 5b8bbe983b..c5b59b27ca 100644 --- a/vendor/hayro-jbig2/src/file.rs +++ b/vendor/hayro-jbig2/src/file.rs @@ -88,6 +88,19 @@ pub(crate) struct File<'a> { /// (D.4.1) const FILE_HEADER_ID: [u8; 8] = [0x97, 0x4A, 0x42, 0x32, 0x0D, 0x0A, 0x1A, 0x0A]; +/// PdfCraft patch (17): an embedded stream that starts with a file header (D.4), which some +/// producers write into a PDF's `JBIG2Decode` data although ISO 32000-1 7.4.7 has the embedded +/// organization (D.3) there. Returns the reader past the header and the organization the header +/// declares, or `None` when the data doesn't start with the header's ID string. +pub(crate) fn after_file_header(data: &[u8]) -> Result, FileOrganization)>> { + if !data.starts_with(&FILE_HEADER_ID) { + return Ok(None); + } + let mut reader = Reader::new(data); + let header = parse_file_header(&mut reader)?; + Ok(Some((reader, header.organization))) +} + /// Parse a standalone JBIG2 file. pub(crate) fn parse_file(data: &[u8]) -> Result> { let mut reader = Reader::new(data); @@ -170,7 +183,9 @@ fn parse_segments<'a>( FileOrganization::Sequential => { parse_segments_sequential(reader, &mut segments, &mut SegmentCount::default())?; } - FileOrganization::RandomAccess => parse_segments_random_access(reader, &mut segments)?, + FileOrganization::RandomAccess => { + parse_segments_random_access(reader, &mut segments, &mut SegmentCount::default())?; + } } Ok(segments) @@ -213,12 +228,12 @@ pub(crate) fn parse_segments_sequential<'a>( /// is followed by a sequence of segments headers; the last segment header is /// followed by the data for the first segment, then the data for the second /// segment, and so on." (D.2) -fn parse_segments_random_access<'a>( +pub(crate) fn parse_segments_random_access<'a>( reader: &mut Reader<'a>, segments: &mut Vec>, + count: &mut SegmentCount, ) -> Result<()> { let mut headers = Vec::new(); - let mut count = SegmentCount::default(); loop { if reader.at_end() { diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index e977ad72d6..7eb08d5ef3 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -75,7 +75,10 @@ pub use error::{ SegmentError, SymbolError, TemplateError, }; -use crate::file::{SegmentCount, parse_segments_sequential}; +use crate::file::{ + FileOrganization, SegmentCount, after_file_header, parse_segments_random_access, + parse_segments_sequential, +}; use bitmap::Bitmap; use decode::CombinationOperator; use decode::generic; @@ -117,7 +120,9 @@ impl<'a> Image<'a> { /// Parse an embedded JBIG2 image with optional global segments. /// /// The file is expected to use the embedded organization defined in - /// Annex D.3. + /// Annex D.3. PdfCraft patch (17): data that starts with a file header (D.4) + /// is read past it, in the organization it declares, as Acrobat and PDFBox + /// draw such streams; its segments still count together with the globals'. pub fn new_embedded(data: &'a [u8], globals: Option<&'a [u8]>) -> Result { let mut segments = Vec::new(); let mut count = SegmentCount::default(); @@ -126,8 +131,18 @@ impl<'a> Image<'a> { parse_segments_sequential(&mut reader, &mut segments, &mut count)?; }; - let mut reader = Reader::new(data); - parse_segments_sequential(&mut reader, &mut segments, &mut count)?; + match after_file_header(data)? { + Some((mut reader, FileOrganization::RandomAccess)) => { + parse_segments_random_access(&mut reader, &mut segments, &mut count)?; + } + Some((mut reader, FileOrganization::Sequential)) => { + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; + } + None => { + let mut reader = Reader::new(data); + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; + } + } segments.sort_by_key(|seg| seg.header.segment_number); diff --git a/vendor/hayro-postscript/src/lib.rs b/vendor/hayro-postscript/src/lib.rs index e16d55562d..e254d37b6f 100644 --- a/vendor/hayro-postscript/src/lib.rs +++ b/vendor/hayro-postscript/src/lib.rs @@ -60,6 +60,37 @@ impl<'a> Scanner<'a> { object::read(&mut self.reader) } + /// PdfCraft patch (2): parse the next object; when it can't be read, read on from where + /// the failed read stopped, or, when it read nothing, from one byte past where its token + /// starts, so a caller can read on past a token it can't parse (a stray `>` in a name, a + /// CMap name with spaces in it) instead of stopping there. Every failure moves forward, one + /// failure per unreadable token, and what a failed read went through (an unclosed string, + /// to the end of the data) is not read again, so a loop over this ends and reads the data + /// about once. A number that can't be read is skipped to the end of its token: its tail + /// (`beginbfchar` in `1beginbfchar`) is not read as a token of its own. + pub fn parse_object_or_skip(&mut self) -> Result> { + // The whitespace and comments before the token are not the token: skipped first, they + // don't count as progress, so a token after a space fails once, not twice. + object::skip_whitespace_and_comments(&mut self.reader); + let start = self.reader.offset(); + // `object::read` reads a token starting with one of these bytes as a number. + let number = matches!( + self.reader.peek_byte(), + Some(b'.' | b'+' | b'-' | b'0'..=b'9') + ); + let read = object::read(&mut self.reader); + // A number read stops at the first byte that can't be part of a number, which may be + // inside the token; the rest of the token goes with it. + if read.is_err() && number { + self.reader.forward_while(reader::is_regular); + } + if read.is_err() && self.reader.offset() <= start { + self.reader + .jump(start.saturating_add(1).min(self.reader.data.len())); + } + read + } + /// Parse the next object as a [`Number`]. pub fn parse_number(&mut self) -> Result { match self.parse_object()? { @@ -176,4 +207,69 @@ endcmap"#; let mut scanner = Scanner::new(b"42 "); assert!(scanner.parse_name().is_err()); } + + /// PdfCraft patch (2) test: a token that can't be read fails once and is stepped over, + /// whether or not whitespace or a comment comes before it, and the objects after it read. + #[test] + fn parse_object_or_skip_steps_over_an_unreadable_token() { + let mut s = Scanner::new(b"/A> /B % note\n > /C"); + assert_eq!( + s.parse_object_or_skip(), + Ok(Object::Name(Name::new(b"A", true))) + ); + assert!(s.parse_object_or_skip().is_err()); + assert_eq!( + s.parse_object_or_skip(), + Ok(Object::Name(Name::new(b"B", true))) + ); + assert!(s.parse_object_or_skip().is_err()); + assert_eq!( + s.parse_object_or_skip(), + Ok(Object::Name(Name::new(b"C", true))) + ); + assert!(s.at_end()); + } + + /// PdfCraft patch (2) test: a number that can't be read fails once with the rest of its + /// token, which is not read as a name of its own; the tokens after it still read. + #[test] + fn parse_object_or_skip_skips_the_rest_of_an_unreadable_number() { + let mut s = Scanner::new(b"1beginbfchar 2.5.3x 16#zz! -x /C"); + for _ in 0..4 { + assert!(s.parse_object_or_skip().is_err()); + } + assert_eq!( + s.parse_object_or_skip(), + Ok(Object::Name(Name::new(b"C", true))) + ); + assert!(s.at_end()); + } + + /// PdfCraft patch (2) test: what a failed read went through is not read again. An unclosed + /// string reads to the end of the data, so one failure leaves nothing to read; the nested + /// `(` inside it are not each read to the end once more. + #[test] + fn parse_object_or_skip_does_not_read_a_failed_read_again() { + let mut s = Scanner::new(b"/A (((( x"); + assert_eq!( + s.parse_object_or_skip(), + Ok(Object::Name(Name::new(b"A", true))) + ); + assert!(s.parse_object_or_skip().is_err()); + assert!(s.at_end()); + } + + /// PdfCraft patch (2) test: at the end of the data a read fails and the scanner stays at + /// the end; it never steps past the data. + #[test] + fn parse_object_or_skip_stays_at_the_end() { + for input in [&b""[..], b" ", b">"] { + let mut s = Scanner::new(input); + for _ in 0..3 { + assert!(s.parse_object_or_skip().is_err()); + assert!(s.reader.offset() <= input.len()); + } + assert!(s.at_end()); + } + } } diff --git a/vendor/hayro-syntax/Cargo.lock b/vendor/hayro-syntax/Cargo.lock index ebc07dfff7..679204a59a 100644 --- a/vendor/hayro-syntax/Cargo.lock +++ b/vendor/hayro-syntax/Cargo.lock @@ -168,6 +168,7 @@ dependencies = [ "rustc-hash", "sha2", "smallvec", + "stringprep", "zune-jpeg", ] @@ -244,12 +245,50 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + [[package]] name = "version_check" version = "0.9.5" diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 1d71d73d2e..f0805c9b5e 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -1170,8 +1170,9 @@ mod tests { #[test] fn an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte() { // `(Grüße)` in WinAnsi, then a word holding a capital "EI": both are everyday content, - // and the look-ahead used to read the non-ASCII bytes of the string as image data. - let data = b"BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \x00 EI BT /F1 12 Tf (Gr\xFC\xDFe) Tj (EINGANG) Tj ET"; + // and the look-ahead used to read the non-ASCII bytes of the string as image data. The + // image is filtered, so its length isn't known up front and the look-ahead decides. + let data = b"BI /W 1 /H 1 /BPC 8 /CS /DeviceGray /F /AHx ID 00> EI BT /F1 12 Tf (Gr\xFC\xDFe) Tj (EINGANG) Tj ET"; assert_eq!( operators(data), diff --git a/vendor/hayro-syntax/src/crypto/mod.rs b/vendor/hayro-syntax/src/crypto/mod.rs index 79d182c6ac..759d1b146b 100644 --- a/vendor/hayro-syntax/src/crypto/mod.rs +++ b/vendor/hayro-syntax/src/crypto/mod.rs @@ -114,6 +114,52 @@ pub(crate) fn get( dict: &Dict<'_>, id: &[u8], password: &[u8], +) -> Result { + match decryptor(dict, id, password) { + // PdfCraft patch (37): a document that encrypts only its embedded files, authorised when + // one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25), opens without a + // password: its strings and streams are in the clear, and nothing drawn reads an embedded + // file. + Err(DecryptionError::PasswordProtected) if password.is_empty() && efopen_only(dict) => { + Ok(Decryptor::None) + } + other => other, + } +} + +/// PdfCraft patch (37): whether `dict` has crypt filters (V4 or later) that encrypt, each +/// authorised only when an embedded file is opened (`/AuthEvent /EFOpen`), and neither `/StmF` nor +/// `/StrF` names one. The embedded files use them through `/EFF` or a `/Crypt` filter of their +/// own; earlier handlers (V1, V2) encrypt everything with RC4. +fn efopen_only(dict: &Dict<'_>) -> bool { + if dict.get::(V).is_none_or(|v| v < 4) { + return false; + } + let Some(cf) = dict.get::>(CF) else { + return false; + }; + let mut encrypting = Vec::new(); + for key in cf.keys() { + let Some(filter) = cf.get::>(key.as_ref()) else { + continue; + }; + let method = filter.get::>(CFM).and_then(|n| DecryptorTag::from_name(&n)); + if matches!(method, None | Some(DecryptorTag::None)) { + continue; + } + if filter.get::>(b"AuthEvent").is_none_or(|e| e.as_str() != "EFOpen") { + return false; + } + encrypting.push(key.as_str().to_string()); + } + let names = |key: &[u8]| dict.get::>(key).is_some_and(|n| encrypting.iter().any(|e| e == n.as_str())); + !encrypting.is_empty() && !names(STM_F) && !names(STR_F) +} + +fn decryptor( + dict: &Dict<'_>, + id: &[u8], + password: &[u8], ) -> Result { let filter = dict.get::>(FILTER).ok_or(InvalidEncryption)?; @@ -149,7 +195,9 @@ pub(crate) fn get( }; let byte_length = length / 8; - if byte_length == 0 { + // PdfCraft patch: revisions 2-4 derive an RC4 or AES-128 key from a 16-byte MD5 hash, so + // a longer /Length is malformed (and would slice past the end of the hash). + if byte_length == 0 || (revision <= 4 && byte_length > 16) { return Err(InvalidEncryption); } @@ -915,4 +963,16 @@ mod pdfcraft_password_tests { assert_eq!(unlock(&user, "cafe\u{301}"), ok, "R6"); assert_eq!(unlock(&user, "cafe"), wrong, "R6"); } + + #[test] + #[cfg(feature = "std")] + fn legacy_key_length_above_128_bits_is_refused_not_a_panic() { + use crate::reader::{Reader, ReaderContext, ReaderExt}; + let hex32 = "00".repeat(32); + for (length, expected) in [(128, DecryptionError::PasswordProtected), (256, InvalidEncryption)] { + let dict = format!("<< /Filter /Standard /V 2 /R 3 /Length {length} /P -4 /O <{hex32}> /U <{hex32}> >>"); + let dict = Reader::new(dict.as_bytes()).read_with_context::>(&ReaderContext::dummy()); + assert_eq!(get(&dict.unwrap(), b"", b"").err(), Some(expected), "/Length {length}"); + } + } } diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index 685d680d2f..eea1e2bc74 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -20,10 +20,14 @@ pub(crate) fn decode( // PdfCraft patch (19): fewer `/Rows` than the image's `/Height` doesn't cut the image short: a // plotter driver wrote `/Rows` equal to `/Columns`, and the rows after it were drawn black // (#3308). The decoder still stops where the data ends. - let rows = params - .get::(ROWS) - .filter(|&rows| rows != 0) - .map_or(image_params.height, |rows| rows.max(image_params.height)); + // PdfCraft patch (41): more `/Rows` than the image's `/Height` doesn't make the decoder go + // past it either: the image is `/Height` rows (ISO 32000-2 Table 87), and a `/Rows` of + // 1,084,975,104 on 2,592 columns had the size check refuse every page of a scan (PDFBOX-771). + // `/Rows` counts only for a stream decoded without an image's height. + let rows = match image_params.height { + 0 => params.get::(ROWS).unwrap_or(0), + height => height, + }; let end_of_block = params.get::(END_OF_BLOCK).unwrap_or(true); let settings = DecodeSettings { diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index 33a7bc4dd8..5c81b8b2b2 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -28,7 +28,9 @@ pub fn set_page_jbig2_work(work: u64) { } /// Pays for reading `bytes` of a stream and its globals out of what the page has left, or `None`. -/// An image that doesn't decode isn't kept, so a page drawing it again and again reads it again. +/// An image that doesn't decode isn't always kept (not past the page's allowance, nor drawn under a +/// transfer function: hayro-interpret patch 91), so a page drawing it again and again can read it +/// again. #[cfg(feature = "std")] fn charge_page_reading(bytes: usize) -> Option<()> { let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get); diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index 5a0027597b..1af93b2787 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; pub(crate) mod flate { use super::*; - use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + use crate::filter::lzw_flate::{PredictorParams, apply_predictor, untagged_png_rows}; pub(crate) use fallback::FlateState; @@ -17,9 +17,15 @@ pub(crate) mod flate { } use crate::object::Dict; - /// PdfCraft patch: `limit` is the most bytes inflated (see `filter::decode_limit`). + /// PdfCraft patch: `limit` is the most bytes inflated (see `filter::decode_limit`), and `rows` + /// the `/Height` of the image the data is the samples of, if it is (see [`untagged_png_rows`]). #[cfg(feature = "unsafe")] - pub(crate) fn decode(data: &[u8], params: &Dict<'_>, limit: usize) -> Option> { + pub(crate) fn decode( + data: &[u8], + params: &Dict<'_>, + limit: usize, + rows: Option, + ) -> Option> { use flate2::read::{DeflateDecoder, ZlibDecoder}; use std::io::Read; @@ -51,19 +57,39 @@ pub(crate) mod flate { if decoded.len() >= limit { warn!("flate stream stopped at its decode limit of {limit} bytes"); } - let params = PredictorParams::from_params(params); - apply_predictor(decoded, ¶ms) + undo_predictor(decoded, &PredictorParams::from_params(params), rows) } #[cfg(not(feature = "unsafe"))] - pub(crate) fn decode(data: &[u8], params: &Dict<'_>, limit: usize) -> Option> { + pub(crate) fn decode( + data: &[u8], + params: &Dict<'_>, + limit: usize, + rows: Option, + ) -> Option> { let decoded = fallback::decode(data, limit)?; // PdfCraft patch: output cut at the limit is reported, never dropped silently. if decoded.len() >= limit { warn!("flate stream stopped at its decode limit of {limit} bytes"); } - let params = PredictorParams::from_params(params); - apply_predictor(decoded, ¶ms) + undo_predictor(decoded, &PredictorParams::from_params(params), rows) + } + + /// PdfCraft patch: the predictor undone, unless the image's rows carry none + /// ([`untagged_png_rows`]): the data is then its samples as they are, which is reported. + fn undo_predictor( + decoded: Vec, + params: &PredictorParams, + rows: Option, + ) -> Option> { + if untagged_png_rows(params, decoded.len(), rows) { + warn!( + "flate image rows carry no PNG predictor tags, read as stored ({} rows)", + rows.unwrap_or(0) + ); + return Some(decoded); + } + apply_predictor(decoded, params) } /// Ported from @@ -975,6 +1001,21 @@ impl PredictorParams { } } +/// PdfCraft patch: whether `len` bytes of an image `rows` high (`None` for data that isn't an +/// image's samples) are its rows without the filter-type byte each row starts with under a PNG +/// predictor (ISO 32000-2 §7.4.4.4): exactly `rows` × the row's bytes, where the predicted data +/// would be `rows` × (the row's bytes + 1). Some writers declare `/Predictor 15` without applying +/// it; read with the predictor, every row's first byte was taken for a filter type, the image +/// shifted a byte a row and came out short, drawn in fragments. PDFium, MuPDF and Poppler read +/// such data as the samples it is. Any other length is decoded with the predictor, as before. +fn untagged_png_rows(params: &PredictorParams, len: usize, rows: Option) -> bool { + let row_len = params.row_length_in_bytes(); + params.predictor >= 10 + && matches!(params.bits_per_component, 1 | 2 | 4 | 8 | 16) + && row_len > 0 + && rows.filter(|&r| r > 0).and_then(|r| r.checked_mul(row_len)) == Some(len) +} + fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { match params.predictor { 1 => Some(data), @@ -1017,6 +1058,17 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { return Some(unfilter_png(&data, row_len, bpp)); } + if i == 2 && matches!(params.bits_per_component, 8 | 16) && !bit_level_only() { + // PdfCraft patch: whole-byte samples are undone a row at a time on byte slices + // instead of through the bit-level readers, which packed samples need. + let sample_len = usize::from(params.bits_per_component / 8); + let pixel_len = usize::from(params.colors).saturating_mul(sample_len); + return Some(undo_tiff_bytes(data, row_len, pixel_len, sample_len)); + } + + #[cfg(all(test, feature = "std"))] + BIT_LEVEL_TIFF_ROWS.with(|rows| rows.set(rows.get().saturating_add(num_rows))); + let (bit_size, chunk_len) = (params.bits_per_component, params.colors as usize); let zero_row = vec![0; row_len]; let mut prev_row = BitChunks::new(&zero_row, bit_size, chunk_len)?; @@ -1054,6 +1106,69 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { } } +/// PdfCraft patch: undoes TIFF predictor 2 (ISO 32000-2 §7.4.4.4, Table 10; TIFF 6.0 §14) +/// for 8- and 16-bit samples, in rows of `row_len` bytes holding pixels of `pixel_len` bytes, +/// each sample `sample_len` bytes (1 or 2, big-endian): every sample is added to the same +/// component of the pixel to its left, wrapping at the sample's width. Bytes after the last +/// complete row are dropped, as the bit-level path drops them. +fn undo_tiff_bytes( + mut data: Vec, + row_len: usize, + pixel_len: usize, + sample_len: usize, +) -> Vec { + let num_rows = data.len() / row_len.max(1); + data.truncate(num_rows.saturating_mul(row_len)); + if pixel_len == 0 { + return data; + } + + for row in data.chunks_exact_mut(row_len) { + let mut pixels = row.chunks_exact_mut(pixel_len); + let Some(mut left) = pixels.next() else { + continue; + }; + for px in pixels { + if sample_len == 2 { + for (c, l) in px.chunks_exact_mut(2).zip(left.chunks_exact(2)) { + if let ([c0, c1], [l0, l1]) = (c, l) { + let sum = u16::from_be_bytes([*c0, *c1]) + .wrapping_add(u16::from_be_bytes([*l0, *l1])); + [*c0, *c1] = sum.to_be_bytes(); + } + } + } else { + for (c, l) in px.iter_mut().zip(left.iter()) { + *c = c.wrapping_add(*l); + } + } + left = px; + } + } + + data +} + +#[cfg(all(test, feature = "std"))] +std::thread_local! { + /// PdfCraft patch tests: rows the bit-level TIFF predictor path has undone on this thread. + static BIT_LEVEL_TIFF_ROWS: core::cell::Cell = const { core::cell::Cell::new(0) }; + /// PdfCraft patch tests: send whole-byte TIFF samples through the bit-level path too. + static BIT_LEVEL_ONLY: core::cell::Cell = const { core::cell::Cell::new(false) }; +} + +/// PdfCraft patch: whether whole-byte TIFF samples must take the bit-level path (only when a +/// test compares the two paths). +#[cfg(all(test, feature = "std"))] +fn bit_level_only() -> bool { + BIT_LEVEL_ONLY.with(core::cell::Cell::get) +} + +#[cfg(not(all(test, feature = "std")))] +fn bit_level_only() -> bool { + false +} + /// PdfCraft patch: undoes PNG row filters (ISO 32000-2 §7.4.4.4, PNG specification §9) in /// rows of `row_len` bytes, each after its filter-type byte, with `bpp` bytes per complete pixel /// (at least 1). An unknown filter type copies the row as it is. Bytes after the last complete @@ -1215,7 +1330,7 @@ mod tests { 0x78, 0x9c, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x7, 0x0, 0x5, 0x8c, 0x1, 0xf5, ]; - let decoded = flate::decode(&input, &Dict::default(), usize::MAX).unwrap(); + let decoded = flate::decode(&input, &Dict::default(), usize::MAX, None).unwrap(); assert_eq!(decoded, b"Hello"); } @@ -1223,7 +1338,7 @@ mod tests { fn decode_flate() { let input = [0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x7, 0x0]; - let decoded = flate::decode(&input, &Dict::default(), usize::MAX).unwrap(); + let decoded = flate::decode(&input, &Dict::default(), usize::MAX, None).unwrap(); assert_eq!(decoded, b"Hello"); } @@ -1363,7 +1478,134 @@ mod tests { /// PdfCraft patch tests: PNG predictors are undone byte by byte (`unfilter_png`). #[cfg(test)] mod pdfcraft_png_predictor_tests { - use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + use crate::filter::lzw_flate::{PredictorParams, apply_predictor, flate, untagged_png_rows}; + use crate::object::{Dict, FromBytes}; + + /// `data` as one stored (uncompressed) deflate block, at most 65,535 bytes. + fn stored(data: &[u8]) -> Vec { + let len = u16::try_from(data.len()).unwrap(); + let mut out = vec![1]; + out.extend(len.to_le_bytes()); + out.extend((!len).to_le_bytes()); + out.extend(data); + out + } + + /// `data` inflated and its PNG predictor undone, as the last filter of an image `rows` high. + fn inflate(parms: &[u8], data: &[u8], rows: Option) -> Option> { + let parms = Dict::from_bytes(parms).unwrap(); + flate::decode(&stored(data), &parms, usize::MAX, rows) + } + + const RGB3: &[u8] = b"<< /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 3 >>"; + + #[test] + fn an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples() { + // Three RGB pixels a row, four rows: 36 bytes, where the predictor would need 40. Every + // row starts with a byte above 4, which the predictor took for a filter type, so the + // image came out three rows of shifted samples. + let samples: Vec = (0..36).map(|i| 200 - i as u8).collect(); + assert_eq!(inflate(RGB3, &samples, Some(4)).unwrap(), samples); + let params = PredictorParams { + predictor: 15, + colors: 3, + bits_per_component: 8, + columns: 3, + early_change: false, + }; + assert!( + untagged_png_rows(¶ms, 36, Some(4)), + "the repair is reported" + ); + // Not an image's samples (no height known): the predictor is undone, as before. + assert_eq!( + inflate(RGB3, &samples, None).unwrap(), + apply_predictor(samples.clone(), ¶ms).unwrap() + ); + assert!(!untagged_png_rows(¶ms, 36, None)); + } + + #[test] + fn an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone() { + // The same four rows, each filtered None, then Sub: 40 bytes. + let samples: Vec = (0..36).map(|i| 200 - i as u8).collect(); + let mut tagged = Vec::new(); + for row in samples.chunks(9) { + tagged.push(1); + let mut left = [0u8; 3]; + for px in row.chunks(3) { + tagged.extend(px.iter().zip(left).map(|(s, l)| s.wrapping_sub(l))); + left.copy_from_slice(px); + } + } + assert_eq!(tagged.len(), 40); + assert_eq!(inflate(RGB3, &tagged, Some(4)).unwrap(), samples); + } + + #[test] + fn data_of_neither_length_decodes_as_it_did() { + let params = PredictorParams { + predictor: 15, + colors: 3, + bits_per_component: 8, + columns: 3, + early_change: false, + }; + // Short of the untagged length, past it, or between it and the tagged one. + for len in [0, 1, 9, 27, 35, 37, 39, 41, 72] { + let data: Vec = (0..len).map(|i| (i * 7) as u8 % 5).collect(); + assert!(!untagged_png_rows(¶ms, len, Some(4)), "{len} bytes"); + assert_eq!( + inflate(RGB3, &data, Some(4)), + apply_predictor(data.clone(), ¶ms), + "{len} bytes" + ); + } + // Rows of the untagged length for another height. + assert!(!untagged_png_rows(¶ms, 36, Some(3))); + assert!(!untagged_png_rows(¶ms, 36, Some(0))); + // No PNG predictor, or one with an invalid depth. + for (predictor, bits_per_component) in [(1, 8), (2, 8), (15, 3), (15, 0)] { + let params = PredictorParams { + predictor, + bits_per_component, + ..params + }; + assert!(!untagged_png_rows(¶ms, 36, Some(4))); + } + } + + #[test] + fn rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic() { + for (colors, bits_per_component, columns) in [ + (0, 8, 3), + (3, 8, 0), + (255, 16, usize::MAX), + (1, 1, usize::MAX), + (3, 8, usize::MAX / 24 + 1), + ] { + let params = PredictorParams { + predictor: 15, + colors, + bits_per_component, + columns, + early_change: false, + }; + for rows in [None, Some(0), Some(1), Some(4), Some(usize::MAX)] { + for len in [0, 1, 36, usize::MAX] { + let _ = untagged_png_rows(¶ms, len, rows); + } + } + } + let zero = b"<< /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns 0 >>"; + let huge = + b"<< /Predictor 15 /Colors 255 /BitsPerComponent 16 /Columns 9223372036854775807 >>"; + for parms in [&zero[..], &huge[..]] { + for rows in [None, Some(0), Some(4), Some(usize::MAX)] { + let _ = inflate(parms, &[7; 36], rows); + } + } + } /// PNG unfiltering written out from the PNG specification (§9.2–9.4), one byte at a time. fn reference(data: &[u8], row_len: usize, bpp: usize) -> Vec { @@ -1491,3 +1733,116 @@ mod pdfcraft_png_predictor_tests { ); } } + +/// PdfCraft patch tests: TIFF predictor 2 on 8- and 16-bit samples is undone on byte rows +/// (`undo_tiff_bytes`), with the same output as the bit-level path. +#[cfg(all(test, feature = "std"))] +mod pdfcraft_tiff_predictor_tests { + use super::{BIT_LEVEL_ONLY, BIT_LEVEL_TIFF_ROWS}; + use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + + fn tiff(colors: u8, bits_per_component: u8, columns: usize) -> PredictorParams { + PredictorParams { + predictor: 2, + colors, + bits_per_component, + columns, + early_change: false, + } + } + + /// Both paths' output, the byte path's first; `None` from either is kept. + fn both(data: &[u8], params: &PredictorParams) -> (Option>, Option>) { + let fast = apply_predictor(data.to_vec(), params); + BIT_LEVEL_ONLY.with(|only| only.set(true)); + let bits = apply_predictor(data.to_vec(), params); + BIT_LEVEL_ONLY.with(|only| only.set(false)); + (fast, bits) + } + + #[test] + fn whole_byte_samples_match_the_bit_level_path_on_random_rows() { + let mut seed: u32 = 0x9e37_79b9; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for case in 0..600 { + let colors = [1u8, 2, 3, 4, 5, 7][(next() % 6) as usize]; + let bits_per_component = [8u8, 16][(next() % 2) as usize]; + let columns = 1 + (next() % 50) as usize; + let row_len = columns * usize::from(colors) * usize::from(bits_per_component) / 8; + let rows = (next() % 7) as usize; + // A partial row at the end, or none. + let tail = (next() % 4) as usize % row_len; + let data: Vec = (0..rows * row_len + tail).map(|_| next() as u8).collect(); + let params = tiff(colors, bits_per_component, columns); + let (fast, bits) = both(&data, ¶ms); + assert_eq!( + fast, bits, + "case {case}: {colors} colours at {bits_per_component} bits, {columns} columns, {rows} rows and {tail} bytes" + ); + } + } + + #[test] + fn whole_byte_samples_add_to_the_same_component_of_the_pixel_to_their_left() { + // Two 8-bit colours, three columns, two rows; sums wrap at 256. + let data = [10, 20, 1, 2, 3, 4, /* row 2 */ 5, 5, 250, 10, 1, 1]; + assert_eq!( + apply_predictor(data.to_vec(), &tiff(2, 8, 3)).unwrap(), + [10, 20, 11, 22, 14, 26, 5, 5, 255, 15, 0, 16] + ); + // One 16-bit colour: a carry crosses the byte boundary and the sum wraps at 65,536. + let data = [ + 0x01, 0x00, 0xff, 0xff, 0x00, 0x02, /* row 2 */ 0x00, 0xff, 0x00, 0x01, 0, 0, + ]; + assert_eq!( + apply_predictor(data.to_vec(), &tiff(1, 16, 3)).unwrap(), + [ + 0x01, 0x00, 0x00, 0xff, 0x01, 0x01, 0x00, 0xff, 0x01, 0x00, 0x01, 0x00 + ] + ); + } + + #[test] + fn whole_byte_samples_do_not_take_the_bit_level_path() { + let start = BIT_LEVEL_TIFF_ROWS.with(core::cell::Cell::get); + for bits_per_component in [8, 16] { + let data = vec![7; 3 * 4 * usize::from(bits_per_component) / 8 * 5]; + apply_predictor(data, &tiff(3, bits_per_component, 4)).unwrap(); + } + assert_eq!(BIT_LEVEL_TIFF_ROWS.with(core::cell::Cell::get), start); + + // Packed samples still do. + apply_predictor(vec![0x5f; 4], &tiff(1, 4, 2)).unwrap(); + assert_eq!(BIT_LEVEL_TIFF_ROWS.with(core::cell::Cell::get), start + 4); + } + + #[test] + fn rows_without_a_complete_row_of_data_are_dropped_on_both_paths() { + for (data, params, want) in [ + // Shorter than one row: nothing. + (vec![1, 2, 3], tiff(1, 8, 4), Some(vec![])), + (vec![1, 2, 3], tiff(1, 16, 2), Some(vec![])), + // A zero-width row is refused. + (vec![1, 2, 3], tiff(1, 8, 0), None), + (vec![], tiff(1, 16, 0), None), + // No data at all. + (vec![], tiff(3, 8, 2), Some(vec![])), + // The trailing partial row is dropped. + ( + vec![1, 1, 1, 1, 2, 2], + tiff(1, 8, 4), + Some(vec![1, 2, 3, 4]), + ), + (vec![0, 1, 0, 1, 9], tiff(1, 16, 2), Some(vec![0, 1, 0, 2])), + ] { + let (fast, bits) = both(&data, ¶ms); + assert_eq!(fast, want, "{data:?}"); + assert_eq!(bits, want, "{data:?}"); + } + } +} diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 89d0512693..ed2b0e5c82 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -87,13 +87,16 @@ impl Filter { } } - /// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). + /// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`), and + /// `rows` the `/Height` of the image whose samples it produces, when it is an image's last + /// filter. pub(crate) fn apply( &self, data: &[u8], params: &Dict<'_>, #[cfg_attr(not(feature = "images"), allow(unused))] image_params: &ImageDecodeParams, limit: usize, + rows: Option, ) -> Result, DecodeFailure> { let res = match self { Self::AsciiHexDecode => ascii_hex::decode(data) @@ -108,7 +111,7 @@ impl Filter { Self::LzwDecode => lzw_flate::lzw::decode(data, params, limit) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), - Self::FlateDecode => lzw_flate::flate::decode(data, params, limit) + Self::FlateDecode => lzw_flate::flate::decode(data, params, limit, rows) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), #[cfg(feature = "images")] diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index 4a0c08510f..cdc73763ff 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -273,11 +273,16 @@ impl<'a> Stream<'a> { } // PdfCraft patch: see `filter::decode_limit`. let limit = crate::filter::decode_limit(image_params, i + 1 == count).min(max); + // PdfCraft patch: the last filter of an image produces its rows. + let rows = (i + 1 == count && image_params.height > 0) + .then(|| usize::try_from(image_params.height).ok()) + .flatten(); let new = filter.apply( current.as_ref().map(|c| c.data.as_ref()).unwrap_or(&data), params, image_params, limit, + rows, )?; current = Some(new); } @@ -322,6 +327,10 @@ impl<'a> Readable<'a> for Stream<'a> { r.jump(offset); parse_fallback(r, &dict) }) + .or_else(|| { + r.jump(offset); + parse_declared(r, &dict) + }) .error_none("was unable to manually parse the stream") } } @@ -431,6 +440,23 @@ fn parse_fallback<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> Some(Stream::new(data, dict.clone())) } +/// PdfCraft patch (36): a stream with no `endstream` after it, as a producer that writes +/// `endstrea` leaves every 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), as other readers take it. Neither +/// parse above found it, so every font and page content of such a file was lost and its pages +/// drew blank. Called only after [`parse_fallback`] found no `endstream` in the rest of the data, +/// so it doesn't search again, and only a `/Length` the data holds is read. +fn parse_declared<'a>(r: &mut Reader<'a>, dict: &Dict<'a>) -> Option> { + let length = dict.get::(LENGTH)?; + + r.skip_white_spaces_and_comments(); + r.forward_tag(b"stream")?; + forward_stream_eol(r)?; + let data = r.read_bytes(length as usize)?; + + Some(Stream::new(data, dict.clone())) +} + fn trim_trailing_ascii_whitespace(data: &[u8]) -> usize { let mut end = data.len(); diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index bb0b4abc0e..272b283054 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -403,12 +403,36 @@ impl<'a> Page<'a> { return None; } - let media_box = dict.get::(MEDIA_BOX).or(ctx.media_box).unwrap_or(A4); + // PdfCraft patch (38): a box that encloses no area is no box. A `/MediaBox` without + // area (`[0 0 0 0]`) is read as a missing one, and a `/CropBox` that has none, or + // none in common with the media box, as a missing one (the media box, ISO 32000-2 + // §14.11.2, Table 33). Kept, the page's visible region had no area: nothing was drawn + // and no text was extracted, where other readers show the page. + // A page's own box without area gives way to one it inherits that has some. Each box + // set aside is logged, as other repairs of this reader are. + // PdfCraft patch (44): a page with no `/MediaBox`, its own or inherited, or only ones + // without area, is US Letter, not A4. The box is required (ISO 32000-2 §7.7.3.3, + // Table 31) and has no default; other readers use Letter, as do PdfCraft's page model + // and inspector. The page is then sized, placed and clipped by the same Letter region + // and its content shows. + let own_media = dict.get::(MEDIA_BOX); + let media_box = own_media + .filter(has_area) + .or(ctx.media_box.filter(has_area)) + .unwrap_or(LETTER); + if own_media.or(ctx.media_box).is_some_and(|m| !has_area(&m)) { + warn!("a /MediaBox without area was read as missing"); + } - let crop_box = dict - .get::(CROP_BOX) - .or(ctx.crop_box) + let shows = |c: &Rect| has_area(&c.intersect(media_box)); + let own_crop = dict.get::(CROP_BOX); + let crop_box = own_crop + .filter(shows) + .or(ctx.crop_box.filter(shows)) .unwrap_or(media_box); + if own_crop.or(ctx.crop_box).is_some_and(|c| !shows(&c)) { + warn!("a /CropBox with no area in the media box was read as missing"); + } let rotation = match dict .get::(ROTATE) @@ -556,7 +580,8 @@ impl<'a> Page<'a> { if (crop_box.width() as f32).is_nearly_zero() || (crop_box.height() as f32).is_nearly_zero() { - (A4.width() as f32, A4.height() as f32) + // PdfCraft patch (44): the same US Letter default as a missing `/MediaBox`. + (LETTER.width() as f32, LETTER.height() as f32) } else { // PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit. // The stored unit is finite and within 1..=75000. Round upward only if the @@ -798,6 +823,18 @@ impl<'a> Resources<'a> { const POINTS_PER_INCH: f64 = 72.0; const POINTS_PER_MM: f64 = 1.0 / (10.0 * 2.54) * POINTS_PER_INCH; +/// PdfCraft patch (38): whether `r` encloses some area: finite, and wider and taller than +/// nothing. +fn has_area(r: &Rect) -> bool { + let (w, h) = (r.width() as f32, r.height() as f32); + w.is_finite() + && h.is_finite() + && w > 0.0 + && h > 0.0 + && !w.is_nearly_zero() + && !h.is_nearly_zero() +} + /// The dimension of an A4 page. pub const A4: Rect = Rect { x0: 0.0, @@ -806,6 +843,15 @@ pub const A4: Rect = Rect { y1: 297.0 * POINTS_PER_MM, }; +/// PdfCraft patch (44): the dimension of a US Letter page (8.5 × 11 in), the size of a page +/// that has no `/MediaBox`. +pub const LETTER: Rect = Rect { + x0: 0.0, + y0: 0.0, + x1: 8.5 * POINTS_PER_INCH, + y1: 11.0 * POINTS_PER_INCH, +}; + pub(crate) mod cached { use crate::page::Pages; use crate::reader::ReaderContext; diff --git a/vendor/hayro-syntax/src/pdf.rs b/vendor/hayro-syntax/src/pdf.rs index c196cb14ac..cfc605b0f2 100644 --- a/vendor/hayro-syntax/src/pdf.rs +++ b/vendor/hayro-syntax/src/pdf.rs @@ -6,7 +6,7 @@ use crate::page::Pages; use crate::page::cached::CachedPages; use crate::reader::Reader; use crate::sync::Arc; -use crate::xref::{XRef, XRefError, fallback, root_xref}; +use crate::xref::{XRef, XRefError, cut_off_update_follows, fallback, root_xref}; pub use crate::crypto::DecryptionError; use crate::metadata::Metadata; @@ -17,6 +17,8 @@ pub struct Pdf { header_version: PdfVersion, pages: CachedPages, data: PdfData, + /// PdfCraft patch: (hayro-syntax (45)) see [`Pdf::rebuilt_unlinked_update`]. + rebuilt_unlinked_update: bool, } /// An error that occurred while loading a PDF file. @@ -47,8 +49,41 @@ impl Pdf { let data = data.into(); let password = password.as_bytes(); let version = find_version(data.as_ref()).unwrap_or(PdfVersion::Pdf10); - let xref = match root_xref(data.clone(), password) { - Ok(x) => x, + // PdfCraft patch: (hayro-syntax (43)) a last update cut off before its trailer is read by + // rebuilding the table from object headers, the latest definition of each object + // winning, as the document model reads such a file. Only when that finds no page is the + // older table the last `startxref` names read instead (a file cut short in the middle of + // the update, whose new page tree names pages that are not there). + // The table the last `startxref` names is read first either way: a wrong password is + // refused through it, and the rebuilt table must be encrypted exactly when it is, so a + // rebuild that missed the encryption dictionary is not taken. + let root = root_xref(data.clone(), password); + if let Err(XRefError::Encryption(e)) = root { + return Err(LoadPdfError::Decryption(e)); + } + let root_encrypted = root.as_ref().ok().map(|(x, _)| x.is_encrypted()); + // PdfCraft patch: (hayro-syntax (45)) said of the file whichever table is kept, as + // `pdfcraft-cos` says it under the same conditions. + let rebuilt_unlinked_update = root.as_ref().is_ok_and(|(_, rebuilt)| *rebuilt); + if cut_off_update_follows(data.as_ref()) + && let Some(xref) = fallback(data.clone(), password) + .filter(|x| root_encrypted.is_none_or(|e| e == x.is_encrypted())) + { + let xref = Arc::new(xref); + if let Some(pages) = CachedPages::new(xref.clone()) + && !pages.get().is_empty() + { + return Ok(Self { + xref, + header_version: version, + pages, + data, + rebuilt_unlinked_update, + }); + } + } + let xref = match root { + Ok((x, _)) => x, Err(e) => match e { XRefError::Unknown => { fallback(data.clone(), password).ok_or(LoadPdfError::Invalid)? @@ -65,9 +100,17 @@ impl Pdf { header_version: version, pages, data, + rebuilt_unlinked_update, }) } + /// PdfCraft patch: (hayro-syntax (45)) whether the newest cross-reference section has no + /// `/Prev`, so the page tree in the revisions before it could only be found by rebuilding the + /// table from the file's objects. + pub fn rebuilt_unlinked_update(&self) -> bool { + self.rebuilt_unlinked_update + } + /// Return the number of objects present in the PDF file. pub fn len(&self) -> usize { self.xref.len() diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index dd3ebb8b56..e8d752b148 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -50,19 +50,55 @@ pub(crate) enum XRefError { } /// Parse the "root" xref from the PDF. -pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result { +/// +/// PdfCraft patch: (hayro-syntax (45)) also whether the table was rebuilt because the newest +/// section cuts off the page tree (see below). +pub(crate) fn root_xref(data: PdfData, password: &[u8]) -> Result<(XRef, bool), XRefError> { let mut xref_map = FxHashMap::default(); let xref_pos = find_last_xref_pos(data.as_ref()).ok_or(XRefError::Unknown)?; let trailer = populate_xref_impl(data.as_ref(), xref_pos, &mut xref_map).ok_or(XRefError::Unknown)?; + // PdfCraft patch: (hayro-syntax (45)) whether the newest section names an earlier one. Its + // `/XRefStm` (a hybrid file's stream, §7.5.8.4) belongs to the same update and doesn't count, + // as `pdfcraft-cos` doesn't count it. + let linked = Reader::new(trailer) + .read_with_context::>(&ReaderContext::dummy()) + .is_some_and(|newest| prev_offset(data.as_ref(), &newest, &XrefMap::default()).is_some()); - XRef::new( + let xref = XRef::new( data.clone(), xref_map, XRefInput::TrailerDictData(trailer), false, password, - ) + )?; + // PdfCraft patch: (hayro-syntax (45)) an update whose section has no `/Prev` (ISO 32000-2 + // §7.5.6, §7.5.8.2) cuts off the revisions before it, and with them the page tree its catalog + // names (the signed files of PDFBOX-2720 and PDFBOX-2942). The table is rebuilt by scanning + // the file, as for a broken one, so the page tree is found rather than whatever page + // dictionaries `Pages::new_brute_force` turns up. Only when that section names no earlier + // one, the file holds another `startxref`, and the page tree can't be read: the conditions + // `pdfcraft-cos` rebuilds under, so the two agree on the pages. + let cut_off = !linked + && xref + .get::>(xref.trailer_data().pages_ref) + .is_none() + && more_than_one_startxref(data.as_ref()); + if cut_off { + warn!( + "the newest xref section has no Prev and the page tree is unreachable; rebuilding the xref table" + ); + xref.repair(); + } + Ok((xref, cut_off)) +} + +/// PdfCraft patch: (hayro-syntax (45)) whether `data` holds an earlier `startxref` than its last. +fn more_than_one_startxref(data: &[u8]) -> bool { + let needle = b"startxref"; + findr_needle(data, needle) + .and_then(|last| data.get(..last)) + .is_some_and(|before| crate::util::find_needle(before, needle).is_some()) } /// Try to manually parse the PDF to build an xref table and trailer dictionary. @@ -632,6 +668,15 @@ impl XRef { locked.xref_map = xref_map; } + /// PdfCraft patch: (hayro-syntax (43)) whether objects read through this table are + /// decrypted. + pub(crate) fn is_encrypted(&self) -> bool { + match &self.0 { + Inner::Dummy => false, + Inner::Some(r) => !matches!(r.decryptor.as_ref(), Decryptor::None), + } + } + #[inline] pub(crate) fn needs_decryption(&self, ctx: &ReaderContext<'_>) -> bool { match &self.0 { @@ -874,6 +919,11 @@ pub(crate) enum XRefInput<'a> { RootRef(ObjectIdentifier), } +/// PdfCraft patch: (hayro-syntax (43)) how far from the end of the file a `startxref` counts as +/// the file's own footer, as `pdfcraft-cos` judges it (ISO 32000-2 §7.5.5 puts it on the last lines +/// of the file). +const STARTXREF_TAIL: usize = 4096; + pub(crate) fn find_last_xref_pos(data: &[u8]) -> Option { let needle = b"startxref"; let pos = findr_needle(data, needle)?; @@ -884,6 +934,83 @@ pub(crate) fn find_last_xref_pos(data: &[u8]) -> Option { finder.read_without_context::()?.try_into().ok() } +/// PdfCraft patch: (hayro-syntax (43)) whether the last update of the file was cut off before its +/// own cross-reference section and trailer: the last `startxref` is not near the end of the file +/// and objects follow it. That section would supersede the older ones (ISO 32000-2 §7.5.6), so +/// the update's objects are the document; reading the older table the last `startxref` names +/// shows a revision the document model (`pdfcraft-cos`, which rebuilds such a file from its +/// object headers) does not have. A `startxref` near the end, or one followed only by other data +/// (trailing junk), is no cut-off update. +pub(crate) fn cut_off_update_follows(data: &[u8]) -> bool { + let needle = b"startxref"; + findr_needle(data, needle).is_some_and(|pos| { + pos < data.len().saturating_sub(STARTXREF_TAIL) + && data + .get(pos.saturating_add(needle.len())..) + .is_some_and(has_object_header) + }) +} + +/// PdfCraft patch: (hayro-syntax (43)) whether `data` holds an `N G obj` header: two runs of +/// digits and the keyword, separated by white space, at the start of the data or after white +/// space or `>`, the keyword ended by white space, a delimiter or the end of the data (ISO +/// 32000-2 §7.2.3, Table 2), as the document model's repair scan reads it: `1 0 obj_name` is +/// no header, and neither is one whose object number doesn't fit 32 bits (`4294967296 0 obj`), +/// which that scan skips too. One linear pass, no allocation. +fn has_object_header(data: &[u8]) -> bool { + let digits = |from: usize| { + data.get(from..).map_or(0, |rest| { + rest.iter().take_while(|b| b.is_ascii_digit()).count() + }) + }; + let spaces = |from: usize| { + data.get(from..).map_or(0, |rest| { + rest.iter() + .take_while(|&&b| is_white_space_character(b)) + .count() + }) + }; + let mut i = 0; + while i < data.len() { + let at_start = i + .checked_sub(1) + .and_then(|prev| data.get(prev)) + .is_none_or(|&b| is_white_space_character(b) || b == b'>'); + let n = if at_start { digits(i) } else { 0 }; + if n == 0 { + i += 1; + continue; + } + let mut at = i + n; + let s1 = spaces(at); + at += s1; + let g = digits(at); + at += g; + let s2 = spaces(at); + at += s2; + // The object number as the model reads it, a 32-bit unsigned integer. + let number_fits = data + .get(i..i + n) + .and_then(|d| core::str::from_utf8(d).ok()) + .is_some_and(|d| d.parse::().is_ok()); + if number_fits + && s1 > 0 + && g > 0 + && s2 > 0 + && data.get(at..).is_some_and(|rest| rest.starts_with(b"obj")) + && data.get(at + 3).is_none_or(|&b| { + is_white_space_character(b) + || matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') + }) + { + return true; + } + // Past the digits read: none of them starts a header. + i += n; + } + false +} + /// A type of xref entry. #[derive(Debug, PartialEq, Eq, Clone, Copy)] enum EntryType { diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 37d1a5b078..73730a07a7 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -65,6 +65,9 @@ pub use renderer::{image_resampling_size, tiling_cell_pixels, tiling_cell_scale, /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; +/// PdfCraft patch (30): exported for its regression tests. +#[doc(hidden)] +pub use renderer::{OffscreenDraws, offscreen_draws}; /// A cache used by the renderer. /// @@ -157,6 +160,10 @@ pub struct RenderSettings { /// PdfCraft patch: when set, solid text and line-art paint colours are remapped while /// rendering; `None` renders exactly as without the patch. pub color_replacement: Option, + /// PdfCraft patch (31): the most coverage values the rasterizer keeps for the page; paths + /// past it are left out, with `InterpreterWarning::ContentTruncated`. `None` keeps as many + /// as a strip's index reaches (2^31). + pub max_coverage: Option, } impl Default for RenderSettings { @@ -170,6 +177,7 @@ impl Default for RenderSettings { x_offset: 0.0, y_offset: 0.0, color_replacement: None, + max_coverage: None, } } } @@ -254,6 +262,10 @@ pub fn render_into<'a>( interpreter_settings.preview, ); + if let Some(max) = render_settings.max_coverage { + device.ctx.set_max_coverage(max); + } + // PdfCraft patch (18): the page renders as one transparency group (the page group). Its // objects composite with an initially transparent backdrop, and the group is then put on // the paper (ISO 32000-2, page group), so a blend mode only blends where something was @@ -284,6 +296,11 @@ pub fn render_into<'a>( } interpret_page(page, &mut state, &mut device); + // PdfCraft patch (31): what the rasterizer left out once its coverage was full (a page that + // paints the same content millions of times over) is content left out, said as such. + if device.ctx.coverage_full() { + (interpreter_settings.warning_sink)(hayro_interpret::InterpreterWarning::ContentTruncated); + } if !backdrop_inside { device.pop_transparency_group(); diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 1e46e3b79e..7a9462b63c 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -637,6 +637,47 @@ pub fn set_skip_offscreen_paths(skip: bool) { SKIP_OFFSCREEN_PATHS.with(|s| s.set(skip)); } +/// PdfCraft patch (30): what the renders on this thread have drawn offscreen, for tests that check +/// a page's cost by counting rather than by timing it (see [`offscreen_draws`]). +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OffscreenDraws { + /// Tiling pattern cells drawn; a cell taken from `tiling_cells` isn't drawn again. + pub tiling_cells: u64, + /// The pixels of every pixmap drawn offscreen: tiling cells, masks, groups and the like. + pub pixels: u64, +} + +thread_local! { + /// PdfCraft patch (30): see [`OffscreenDraws`]. + static OFFSCREEN_DRAWS: std::cell::Cell = + const { std::cell::Cell::new(OffscreenDraws { tiling_cells: 0, pixels: 0 }) }; +} + +/// PdfCraft patch (30): exported for its regression tests (see [`OffscreenDraws`]). The counts +/// only grow; a test takes the difference across a render. +#[doc(hidden)] +pub fn offscreen_draws() -> OffscreenDraws { + OFFSCREEN_DRAWS + .try_with(std::cell::Cell::get) + .unwrap_or_default() +} + +/// PdfCraft patch (30): a `width` × `height` pixmap to draw offscreen, counted in +/// [`OffscreenDraws`]. Every pixmap the renderer renders into, besides the page, is made here. +fn offscreen_pixmap(width: u16, height: u16, tiling_cell: bool) -> Pixmap { + // Gone only while the thread exits, when there is nothing left to count for. + let _ = OFFSCREEN_DRAWS.try_with(|draws| { + let mut counts = draws.get(); + counts.pixels = counts + .pixels + .saturating_add(u64::from(width) * u64::from(height)); + counts.tiling_cells = counts.tiling_cells.saturating_add(u64::from(tiling_cell)); + draws.set(counts); + }); + Pixmap::new(width, height) +} + /// PdfCraft patch: whether a device-space box can paint a pixel of a `width` × `height` /// canvas. A box whose bounds miss the canvas is skipped without changing a pixel. /// Bounds that aren't finite are drawn as before. @@ -802,6 +843,9 @@ pub(crate) struct Renderer { /// render, every nested renderer starting empty) and so to one `preview` setting: a mask /// drawn for a preview, which may leave out tiny images, never reaches a final render. pub(crate) soft_mask_cache: HashMap, + /// PdfCraft patch (30): coloured tiling cells drawn during this render (see [`TilingCells`]). + /// Like `soft_mask_cache` it belongs to one renderer, so to one `preview` setting. + tiling_cells: TilingCells, pub(crate) outline_cache: Rc>>>, pub(crate) cur_mask: Option, pub(crate) in_type3_glyph: bool, @@ -815,6 +859,40 @@ pub(crate) struct Renderer { ink: InkState, } +/// PdfCraft patch (30): the cell pixmaps of coloured tiling patterns, keyed by the setting of the +/// pattern (`TilingPattern::instance`) and the cell's size and scale. A page that fills many +/// shapes with one pattern (pdf.js issue 8078 paints 73,047 stencil masks with a pattern whose +/// cell is 3.5 MB) drew and kept a cell per fill, which ran out of memory; the fills now share +/// one. A handful of entries covers a run of fills with one pattern, and bounds what the cache +/// keeps besides what the scene holds anyway. +#[derive(Default)] +struct TilingCells(Vec<(TilingCellKey, Arc)>); + +#[derive(Clone, Copy, PartialEq, Eq)] +struct TilingCellKey { + instance: u64, + /// Whether this is the bordered cell of a pattern whose step shows one cell (patch (20)). + one_cell: bool, + is_stroke: bool, + pixels: (u16, u16), + scale: (u64, u64), +} + +impl TilingCells { + const MAX_ENTRIES: usize = 8; + + fn get(&self, key: &TilingCellKey) -> Option> { + self.0.iter().find(|(k, _)| k == key).map(|(_, cell)| cell.clone()) + } + + fn insert(&mut self, key: TilingCellKey, cell: Arc) { + if self.0.len() >= Self::MAX_ENTRIES { + self.0.remove(0); + } + self.0.push((key, cell)); + } +} + /// PdfCraft patch (27): which inks a pass of a `DeviceCMYK` group draws, and whether the /// group asked for something a pass can't draw. /// @@ -913,6 +991,7 @@ impl Renderer { erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: cache.outline_cache.clone(), cur_mask: None, in_type3_glyph: false, @@ -1019,6 +1098,7 @@ impl Renderer { erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: self.outline_cache.clone(), cur_mask: None, in_type3_glyph: false, @@ -1027,7 +1107,7 @@ impl Renderer { image_transparency_stack: Vec::new(), ink: InkState::default(), }; - let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height()); + let mut mask_pix = offscreen_pixmap(self.ctx.width(), self.ctx.height(), false); let rgb_data = ImageData::Rgb(RgbData { data: dummy_rgb, width: alpha_data.width, @@ -1540,6 +1620,52 @@ impl Renderer { self.draw_pixmap(Arc::new(pixmap), quality, draw_transform); } + /// PdfCraft patch (30): the cell pixmap of tiling pattern `t`, `size` pixels drawn at + /// `initial_transform`; a cell with a `key` is drawn once and then taken from `tiling_cells`. + fn tiling_cell( + &mut self, + t: &hayro_interpret::pattern::TilingPattern<'_>, + key: Option, + size: (u16, u16), + initial_transform: Affine, + is_stroke: bool, + ) -> Arc { + if let Some(cell) = key.and_then(|key| self.tiling_cells.get(&key)) { + return cell; + } + let mut renderer = Self { + ctx: RenderContext::new_with(size.0, size.1, derive_settings(self.ctx.render_settings())), + // PdfCraft patch: a pattern cell is painted as one artwork, so its content is never + // remapped by the reading-colours setting. + color_replacement: None, + in_annotations: false, + knockout: Vec::new(), + erasing: false, + knockout_passes: Vec::new(), + erase_groups: 0, + cur_mask: None, + inside_pattern: true, + soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), + outline_cache: self.outline_cache.clone(), + in_type3_glyph: false, + scaler: self.scaler, + preview: self.preview, + image_transparency_stack: Vec::new(), + ink: InkState::default(), + }; + t.interpret(&mut renderer, initial_transform, is_stroke); + let mut pix = offscreen_pixmap(size.0, size.1, true); + renderer.ctx.flush(); + let mut resources = vello_cpu::Resources::default(); + renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + let pix = Arc::new(pix); + if let Some(key) = key { + self.tiling_cells.insert(key, pix.clone()); + } + pix + } + fn push_clip_path_inner(&mut self, clip_path: &BezPath, fill: FillRule) { let old_transform = *self.ctx.transform(); @@ -1585,6 +1711,7 @@ impl Renderer { in_annotations: self.in_annotations, inside_pattern: self.inside_pattern, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: self.outline_cache.clone(), cur_mask: None, in_type3_glyph: false, @@ -1609,7 +1736,7 @@ impl Renderer { if renderer.ink.unsupported { return None; } - let mut pix = Pixmap::new(width, height); + let mut pix = offscreen_pixmap(width, height, false); renderer.ctx.flush(); let mut resources = vello_cpu::Resources::default(); renderer.ctx.render_to_pixmap(&mut resources, &mut pix); @@ -1741,7 +1868,9 @@ impl Renderer { AlphaColor::from_rgba8(c[0], c[1], c[2], c[3]).into() } Paint::Pattern(p) => { - let path_transform = self.ctx.transform(); + // PdfCraft patch (30): a copy, so a tiling cell can be drawn and kept on `self`. + let current_transform = *self.ctx.transform(); + let path_transform = ¤t_transform; match *p { Pattern::Shading(s) => { @@ -1848,39 +1977,20 @@ impl Renderer { const BORDER: f64 = 1.0; let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2); let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2); - let mut renderer = Self { - ctx: RenderContext::new_with( - pix_width, - pix_height, - derive_settings(self.ctx.render_settings()), - ), - color_replacement: None, - in_annotations: false, - knockout: Vec::new(), - erasing: false, - knockout_passes: Vec::new(), - erase_groups: 0, - cur_mask: None, - inside_pattern: true, - soft_mask_cache: HashMap::default(), - outline_cache: self.outline_cache.clone(), - in_type3_glyph: false, - scaler: self.scaler, - preview: self.preview, - image_transparency_stack: Vec::new(), - ink: InkState::default(), - }; let initial_transform = Affine::translate((BORDER, BORDER)) * Affine::scale_non_uniform(xs as f64, ys as f64) * Affine::translate((-bbox.x0, -bbox.y0)); - t.interpret(&mut renderer, initial_transform, is_stroke); - let mut pix = Pixmap::new(pix_width, pix_height); - renderer.ctx.flush(); - let mut resources = vello_cpu::Resources::default(); - renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + let key = t.is_coloured().then(|| TilingCellKey { + instance: t.instance(), + one_cell: true, + is_stroke, + pixels: (pix_width, pix_height), + scale: (u64::from(xs.to_bits()), u64::from(ys.to_bits())), + }); + let pix = self.tiling_cell(&t, key, (pix_width, pix_height), initial_transform, is_stroke); paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse(); let image = Image { - image: ImageSource::Pixmap(Arc::new(pix)), + image: ImageSource::Pixmap(pix), sampler: ImageSampler { x_extend: peniko::Extend::Pad, y_extend: peniko::Extend::Pad, @@ -1910,37 +2020,17 @@ impl Renderer { let scaled_width = bbox.width() * xs; let scaled_height = bbox.height() * ys; - let mut renderer = Self { - ctx: RenderContext::new_with( - pix_width, - pix_height, - derive_settings(self.ctx.render_settings()), - ), - // PdfCraft patch: a pattern cell is painted as one artwork, so its - // content is never remapped by the reading-colours setting. - color_replacement: None, - in_annotations: false, - knockout: Vec::new(), - erasing: false, - knockout_passes: Vec::new(), - erase_groups: 0, - cur_mask: None, - inside_pattern: true, - soft_mask_cache: HashMap::default(), - outline_cache: self.outline_cache.clone(), - in_type3_glyph: false, - scaler: self.scaler, - preview: self.preview, - image_transparency_stack: Vec::new(), - ink: InkState::default(), - }; let mut initial_transform = Affine::scale_non_uniform(xs, ys) * Affine::translate((-bbox.x0, -bbox.y0)); - t.interpret(&mut renderer, initial_transform, is_stroke); - let mut pix = Pixmap::new(pix_width, pix_height); - renderer.ctx.flush(); - let mut resources = vello_cpu::Resources::default(); - renderer.ctx.render_to_pixmap(&mut resources, &mut pix); + // PdfCraft patch (30): a coloured cell is drawn once per setting and scale. + let key = t.is_coloured().then(|| TilingCellKey { + instance: t.instance(), + one_cell: false, + is_stroke, + pixels: (pix_width, pix_height), + scale: (xs.to_bits(), ys.to_bits()), + }); + let pix = self.tiling_cell(&t, key, (pix_width, pix_height), initial_transform, is_stroke); // TODO: Fix these if t.x_step < 0.0 { @@ -1957,7 +2047,7 @@ impl Renderer { path_transform.inverse() * t.matrix * initial_transform.inverse(); let image = Image { - image: ImageSource::Pixmap(Arc::new(pix)), + image: ImageSource::Pixmap(pix), sampler: ImageSampler { x_extend: peniko::Extend::Repeat, y_extend: peniko::Extend::Repeat, @@ -2195,69 +2285,57 @@ impl<'a> Device<'a> for Renderer { self.ctx.set_fill_rule(old_rule); } Paint::Pattern(_) => { - let (width, height) = (self.ctx.width(), self.ctx.height()); let stencil_rect = Rect::new( 0.0, 0.0, stencil.width as f64, stencil.height as f64, ); - let mask_pix = { - let rgb_bytes = ImageData::Rgb(RgbData { - data: vec![ - 255; - stencil.width as usize - * stencil.height as usize - * 3 - ], - width: stencil.width, - height: stencil.height, - interpolate: stencil.interpolate, - scale_factors: stencil.scale_factors, - }); - let mut sub_renderer = Self { - ctx: RenderContext::new_with( - width, - height, - derive_settings(self.ctx.render_settings()), - ), - // PdfCraft patch: a stencil mask must keep its original - // coverage values, so its content is never remapped by - // the reading-colours setting. - color_replacement: None, - in_annotations: false, - knockout: Vec::new(), - erasing: false, - knockout_passes: Vec::new(), - erase_groups: 0, - inside_pattern: false, - soft_mask_cache: HashMap::default(), - outline_cache: self.outline_cache.clone(), - cur_mask: None, - in_type3_glyph: false, - scaler: self.scaler, - preview: self.preview, - image_transparency_stack: Vec::new(), - ink: InkState::default(), - }; - let mut sub_pix = Pixmap::new(width, height); - sub_renderer.ctx.set_transform(transform); - sub_renderer.draw_image(rgb_bytes, Some(stencil)); - sub_renderer.ctx.flush(); - let mut resources = vello_cpu::Resources::default(); - sub_renderer - .ctx - .render_to_pixmap(&mut resources, &mut sub_pix); - sub_pix + let Some(len) = source_byte_len( + stencil.width, + stencil.height, + 3, + MAX_IMAGE_PIXELS, + ) else { + log::warn!( + "Stencil mask dimensions exceeded the buffer limit; image skipped" + ); + return; }; - - self.ctx.push_layer( - None, - Some(self.ctx.blend_mode()), - None, - Some(Mask::new_luminance(&mask_pix)), - None, - ); + let Some(mut white) = image_buffer(len) else { + log::warn!( + "Stencil mask buffer could not be allocated; image skipped" + ); + return; + }; + white.resize(len, 255); + // PdfCraft patch (30): under a clip of the stencil's device + // rectangle, the pattern is painted into a layer of its own and + // cut to the stencil by a layer holding the stencil, composited + // with `Compose::DestIn`; a layer is only as large as the clip, + // so a draw costs what its rectangle covers. A luminance mask + // (which vello_cpu only takes at canvas size) cost a + // canvas-sized render, mask and composite per draw: a page of + // 73,047 patterned 16 × 16 stencils took minutes. The clip is + // padded past the rectangle so that a stencil snapped to whole + // device pixels lies inside it; the cut clears the padding. + let device_rect = (transform * stencil_rect.to_path(0.1)) + .bounding_box() + .inflate(2.0, 2.0); + if ![device_rect.x0, device_rect.y0, device_rect.x1, device_rect.y1] + .iter() + .all(|v| v.is_finite()) + { + log::warn!("Stencil mask has an invalid transform; image skipped"); + return; + } + let layer_clip = device_rect.to_path(0.1); + let blend_mode = self.ctx.blend_mode(); + self.ctx.set_transform(Affine::IDENTITY); + self.ctx.push_clip_layer(&layer_clip); + // Unclipped, so isolated: a layer with only a clip draws + // straight into what is under it, which the cut would clear. + self.ctx.push_layer(None, Some(blend_mode), None, None, None); self.ctx.set_transform(transform); // PdfCraft patch: a stencil mask is painted with the fill @@ -2275,6 +2353,37 @@ impl<'a> Device<'a> for Renderer { } } + // The stencil, as white ink under its own coverage, keeps the + // pattern where it paints and clears the rest of the clip. + let paint_blend = self.ctx.blend_mode(); + self.ctx.set_blend_mode(peniko::BlendMode::default()); + // The pattern left its own paint transform, which would place + // the stencil's pixels. + self.ctx.set_paint_transform(Affine::IDENTITY); + self.ctx.push_blend_layer(peniko::BlendMode::new( + Mix::Normal, + Compose::DestIn, + )); + self.ctx.set_transform(transform); + // The coverage is drawn as a stencil outside a Type 3 glyph is: + // a glyph's image rules (nearest shrink, padded frame) are for + // the glyph's own images. + let in_type3_glyph = std::mem::replace(&mut self.in_type3_glyph, false); + self.draw_image( + ImageData::Rgb(RgbData { + data: white, + width: stencil.width, + height: stencil.height, + interpolate: stencil.interpolate, + scale_factors: stencil.scale_factors, + }), + Some(stencil), + ); + self.in_type3_glyph = in_type3_glyph; + self.ctx.pop_layer(); + self.ctx.set_blend_mode(paint_blend); + + self.ctx.pop_layer(); self.ctx.pop_layer(); } }; @@ -2366,7 +2475,7 @@ impl<'a> Device<'a> for Renderer { return false; } - let mut result = Pixmap::new(width, height); + let mut result = offscreen_pixmap(width, height, false); for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { let a = u32::from(coverage.a); let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; @@ -2750,6 +2859,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), + tiling_cells: TilingCells::default(), outline_cache: Rc::new(std::cell::RefCell::new(HashMap::new())), in_type3_glyph: false, scaler: Scaler::new(ResamplingFunction::CatmullRom), @@ -2777,7 +2887,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei renderer.ctx.pop_layer(); } - let mut pix = Pixmap::new(width, height); + let mut pix = offscreen_pixmap(width, height, false); renderer.ctx.flush(); let mut resources = vello_cpu::Resources::default(); renderer.ctx.render_to_pixmap(&mut resources, &mut pix); diff --git a/vendor/vello_common/src/flatten.rs b/vendor/vello_common/src/flatten.rs index afacf7ecbc..25697c7032 100644 --- a/vendor/vello_common/src/flatten.rs +++ b/vendor/vello_common/src/flatten.rs @@ -240,8 +240,11 @@ pub fn stroke( // PdfCraft patch: kurbo's stroker can put NaN into the outline of a curve that is nearly a // line (all control points within a rounding error of the chord), and `fill` then drops the // whole outline. Stroke it again with such curves as lines. A finite outline is unchanged. + // PdfCraft patch (4): it can also put NaN into the offset of a cubic whose second control + // point is its end point (as `y` builds), so that control point is moved off it in the same + // pass. if !stroke_ctx.output().is_finite() { - let path = near_line_curves_as_lines(path, tolerance); + let path = robust_for_stroking(path, tolerance); expand_stroke(&path, style, tolerance, stroke_ctx); } fill( @@ -256,8 +259,10 @@ pub fn stroke( // PdfCraft patch. /// `path` with each quadratic or cubic whose control points all lie within -/// `tolerance` of its chord replaced by a line to its end point. One element out per element in. -fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: f64) -> BezPath { +/// `tolerance` of its chord replaced by a line to its end point, and (patch (4)) each other +/// cubic whose second control point is its end point given a short end handle instead +/// ([`open_zero_end_handle`]). One element out per element in. +fn robust_for_stroking(path: impl IntoIterator, tolerance: f64) -> BezPath { let mut out = BezPath::new(); let mut start = kurbo::Point::ZERO; let mut last = start; @@ -270,6 +275,9 @@ fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: { PathEl::LineTo(p3) } + PathEl::CurveTo(p1, p2, p3) => { + PathEl::CurveTo(p1, open_zero_end_handle(last, p1, p2, p3, tolerance), p3) + } el => el, }; match el { @@ -285,6 +293,33 @@ fn near_line_curves_as_lines(path: impl IntoIterator, tolerance: out } +// PdfCraft patch (4). +/// The second control point of the cubic `p0 p1 p2 p3`, moved `tolerance` towards `p1` (or `p0` +/// when `p1` is that close too) when it sits on the end point `p3`, as a `y` curve's does. +/// +/// There the curve's derivative vanishes at its end. kurbo finds the cusps of a wide stroke's +/// offset curve with a root search on the curvature, which grows without bound towards such an +/// end; at some widths the search returns the end point itself, and the unit tangent kurbo takes +/// there is `0 / 0`. Only an exact zero does this: a handle even 1e-9 long strokes finitely, as +/// does a first control point on the start point (`v`). The moved control point moves the curve +/// by at most `4 / 9 * tolerance`. +fn open_zero_end_handle( + p0: kurbo::Point, + p1: kurbo::Point, + p2: kurbo::Point, + p3: kurbo::Point, + tolerance: f64, +) -> kurbo::Point { + if p2 != p3 { + return p2; + } + [p1, p0] + .into_iter() + .map(|q| q - p3) + .find(|v| v.hypot() > tolerance) + .map_or(p2, |v| p3 + v * (tolerance / v.hypot())) +} + /// Whether `p` is within `tolerance` of the segment from `a` to `b`. False for non-finite input. fn near_segment(p: kurbo::Point, a: kurbo::Point, b: kurbo::Point, tolerance: f64) -> bool { let ab = b - a; @@ -390,6 +425,63 @@ mod tests { assert!(lines.is_empty()); } + /// The outline of `path` stroked with `style`, drawn translated by (80, -136) so that the + /// test paths below land inside the 200 × 200 cull box, and whether kurbo's outline was finite. + fn stroke_style_lines(path: &BezPath, style: &Stroke) -> (Vec, bool) { + let mut lines = Vec::new(); + let mut ctx = StrokeCtx::default(); + stroke( + Level::baseline(), + path, + style, + Affine::translate((80.0, -136.0)), + &mut lines, + &mut FlattenCtx::default(), + &mut ctx, + RectU16::new(0, 0, 200, 200), + ); + let finite = ctx.output().is_finite(); + (lines, finite) + } + + // A file in the PDFBox issue tracker strokes "XX" outlines at 32 pt whose subpaths include + // `y` curves (the second control point on the end point, so the end tangent vanishes), and at + // 16, 32 and 63 pt kurbo's offset of one of them came back with NaN, so the whole outline was + // dropped. These exact coordinates are the trigger. `v` (the first control point on the start + // point) is the mirrored case, the same two curves run backwards; kurbo strokes it finitely, + // and it is checked as a guard. + #[test] + fn a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn() { + use crate::kurbo::{Cap, Join}; + let a = Point::new(21.4166, 234.5928); + let b = Point::new(18.9478, 236.3428); + let e = Point::new(20.8697, 238.4209); + let mut y = BezPath::new(); + y.move_to(a); + y.curve_to(Point::new(19.9009, 236.0303), b, b); + y.curve_to(Point::new(19.2603, 238.4209), e, e); + let mut v = BezPath::new(); + v.move_to(e); + v.curve_to(e, Point::new(19.2603, 238.4209), b); + v.curve_to(b, Point::new(19.9009, 236.0303), a); + + let mut failures = Vec::new(); + for (name, path) in [("y", &y), ("v", &v)] { + for width in 1..=64 { + for join in [Join::Miter, Join::Round, Join::Bevel] { + for cap in [Cap::Butt, Cap::Round, Cap::Square] { + let style = Stroke::new(f64::from(width)).with_join(join).with_caps(cap); + let (lines, finite) = stroke_style_lines(path, &style); + if lines.is_empty() || !finite { + failures.push((name, width, join, cap)); + } + } + } + } + } + assert!(failures.is_empty(), "strokes dropped: {failures:?}"); + } + // A regression guard: it passes with or without the patch. #[test] fn a_finite_outline_is_stroked_as_before() { diff --git a/vendor/vello_common/src/strip_generator.rs b/vendor/vello_common/src/strip_generator.rs index dcf9d4515a..255d000cb5 100644 --- a/vendor/vello_common/src/strip_generator.rs +++ b/vendor/vello_common/src/strip_generator.rs @@ -69,6 +69,21 @@ impl StripStorage { self.strips.is_empty() && self.alphas.is_empty() } + /// PdfCraft patch (2): whether these strips have room for the coverage of one more path on a + /// `width` x `height` surface, which covers at most the whole surface (twice, to be safe). A + /// strip packs its index into the alphas in 31 bits and [`Strip::new`] asserts that it fits: + /// a scene that painted past it (a form drawn 2^22 times over) panicked. + pub fn room_for_path(&self, width: u16, height: u16) -> bool { + self.room_for_path_within(width, height, MAX_ALPHAS) + } + + /// [`room_for_path`](Self::room_for_path) with a scene kept to `max_alphas` coverage values + /// (never more than a strip's index can reach). + pub fn room_for_path_within(&self, width: u16, height: u16, max_alphas: usize) -> bool { + let surface = usize::from(width) * usize::from(height); + self.alphas.len().saturating_add(surface.saturating_mul(2)) < max_alphas.min(MAX_ALPHAS) + } + /// Extend the current strip storage with the data from another storage. pub fn extend(&mut self, other: &Self) { self.strips.extend(&other.strips); @@ -76,6 +91,10 @@ impl StripStorage { } } +/// PdfCraft patch (2): the most coverage (alpha) values a strip index can reach (31 bits; the +/// top bit of [`Strip`]'s packed index is its fill-gap flag). +const MAX_ALPHAS: usize = 1 << 31; + /// An object for easily generating strips for a filled/stroked path. #[derive(Debug)] pub struct StripGenerator { @@ -439,3 +458,39 @@ mod tests { assert_rect_fast_eq_path(Rect::new(18.0, 18.0, 2.0, 2.0), "inverted_both_axes"); } } + +#[cfg(test)] +mod pdfcraft_tests { + use super::*; + + /// PdfCraft patch (2): room runs out a whole surface's coverage before the limit, so the path + /// that would cross it is never generated; at the real limit, an index of `MAX_ALPHAS` would + /// not fit a strip. + #[test] + fn strips_have_room_until_a_surface_short_of_the_limit() { + let mut storage = StripStorage::default(); + assert!(storage.room_for_path_within(10, 10, 1_000)); + storage.alphas.resize(799, 0); + assert!( + storage.room_for_path_within(10, 10, 1_000), + "799 + 200 < 1000" + ); + storage.alphas.push(0); + assert!( + !storage.room_for_path_within(10, 10, 1_000), + "800 + 200 reaches the limit" + ); + assert!( + storage.room_for_path_within(8192, 8192, usize::MAX), + "a large page's surface fits, a larger limit held to a strip's index" + ); + assert!( + !storage.room_for_path_within(u16::MAX, u16::MAX, usize::MAX), + "the largest surface is counted without overflow, and cannot be promised room" + ); + assert!( + Strip::new(0, 0, (MAX_ALPHAS - 1) as u32, true).fill_gap(), + "the last index below the limit fits" + ); + } +} diff --git a/vendor/vello_cpu/src/dispatch/mod.rs b/vendor/vello_cpu/src/dispatch/mod.rs index f9830f3621..93f02cff94 100644 --- a/vendor/vello_cpu/src/dispatch/mod.rs +++ b/vendor/vello_cpu/src/dispatch/mod.rs @@ -70,6 +70,14 @@ pub(crate) trait Dispatcher: Debug + Send + Sync { fn pop_layer(&mut self); fn reset(&mut self); fn flush(&mut self, encoded_paints: &[EncodedPaint]); + /// PdfCraft patch (1): whether a path was left out of this scene because its coverage was + /// full (only the single-threaded dispatcher keeps count). + fn coverage_full(&self) -> bool { + false + } + /// PdfCraft patch (1): keep the scene to `max` coverage values (never more than a strip's + /// index reaches); the single-threaded dispatcher leaves later paths out past it. + fn set_max_coverage(&mut self, _max: usize) {} fn rasterize( &self, buffer: &mut [u8], diff --git a/vendor/vello_cpu/src/dispatch/single_threaded.rs b/vendor/vello_cpu/src/dispatch/single_threaded.rs index 2a08d4b1b2..dc954b35af 100644 --- a/vendor/vello_cpu/src/dispatch/single_threaded.rs +++ b/vendor/vello_cpu/src/dispatch/single_threaded.rs @@ -42,6 +42,11 @@ pub(crate) struct SingleThreadedDispatcher { layer_id_next: u32, /// Dependency graph tracking layer relationships and filter effects. render_graph: RenderGraph, + /// PdfCraft patch (1): a path was left out because the scene's coverage was full. + coverage_full: bool, + /// PdfCraft patch (1): the most coverage values the scene keeps + /// ([`Dispatcher::set_max_coverage`]). + max_coverage: usize, } impl SingleThreadedDispatcher { @@ -79,9 +84,25 @@ impl SingleThreadedDispatcher { level, layer_id_next: 0, render_graph, + coverage_full: false, + max_coverage: usize::MAX, } } + /// PdfCraft patch (1): whether the scene has room for the coverage of one more path + /// ([`StripStorage::room_for_path`]). Once it hasn't, the path and every later one are left + /// out, and [`Dispatcher::coverage_full`] says so. + fn room_for_path(&mut self) -> bool { + let (width, height) = (self.strip_generator.width(), self.strip_generator.height()); + let room = self + .strip_storage + .room_for_path_within(width, height, self.max_coverage); + if !room { + self.coverage_full = true; + } + room + } + /// Rasterizes the scene using f32 precision (high quality). /// /// This dispatches to the appropriate SIMD implementation based on the @@ -539,6 +560,9 @@ impl Dispatcher for SingleThreadedDispatcher { mask: Option, encoded_paints: &[EncodedPaint], ) { + if !self.room_for_path() { + return; + } let wide = &mut self.wide; // Convert path to coverage strips. @@ -573,6 +597,9 @@ impl Dispatcher for SingleThreadedDispatcher { mask: Option, encoded_paints: &[EncodedPaint], ) { + if !self.room_for_path() { + return; + } let wide = &mut self.wide; // Convert stroked path to coverage strips. @@ -604,6 +631,9 @@ impl Dispatcher for SingleThreadedDispatcher { mask: Option, encoded_paints: &[EncodedPaint], ) { + if !self.room_for_path() { + return; + } let wide = &mut self.wide; // Generate strips directly for the rectangle (bypasses path processing). @@ -638,8 +668,9 @@ impl Dispatcher for SingleThreadedDispatcher { // Allocate a new unique layer ID. self.layer_id_next += 1; - // Generate clip coverage if a clip path is provided. - let clip = if let Some(c) = clip_path { + // Generate clip coverage if a clip path is provided. PdfCraft patch (1): without room for + // it the layer goes unclipped, as nothing can be drawn into it any more either. + let clip = if let Some(c) = clip_path.filter(|_| self.room_for_path()) { self.strip_generator.generate_filled_path( c, fill_rule, @@ -681,6 +712,7 @@ impl Dispatcher for SingleThreadedDispatcher { self.strip_storage.clear(); self.render_graph.clear(); self.layer_id_next = 0; + self.coverage_full = false; // Recreate root node as node 0 (required for proper execution order). let root_node = self.render_graph.add_node(RenderNodeKind::RootLayer { @@ -697,6 +729,14 @@ impl Dispatcher for SingleThreadedDispatcher { // No-op for single-threaded dispatcher (no work queue to flush). } + fn coverage_full(&self) -> bool { + self.coverage_full + } + + fn set_max_coverage(&mut self, max: usize) { + self.max_coverage = max; + } + fn rasterize( &self, buffer: &mut [u8], diff --git a/vendor/vello_cpu/src/render.rs b/vendor/vello_cpu/src/render.rs index fe4d803108..cdf817665c 100644 --- a/vendor/vello_cpu/src/render.rs +++ b/vendor/vello_cpu/src/render.rs @@ -583,6 +583,20 @@ impl RenderContext { self.filter = None; } + /// PdfCraft patch (1): whether paths were left out of the scene because its coverage was + /// full (a page that paints the same content millions of times over). Cleared by + /// [`reset`](Self::reset). + pub fn coverage_full(&self) -> bool { + self.dispatcher.coverage_full() + } + + /// PdfCraft patch (1): keep the scene to `max` coverage (alpha) values, fewer than the + /// 2^31 a strip's index reaches, after which [`coverage_full`](Self::coverage_full) says + /// paths were left out. Kept across [`reset`](Self::reset). + pub fn set_max_coverage(&mut self, max: usize) { + self.dispatcher.set_max_coverage(max); + } + /// Reset the render context. pub fn reset(&mut self) { self.dispatcher.reset(); diff --git a/xtask/src/demo_pdf/pdf.rs b/xtask/src/demo_pdf/pdf.rs index b51ea1a643..dcdc156683 100644 --- a/xtask/src/demo_pdf/pdf.rs +++ b/xtask/src/demo_pdf/pdf.rs @@ -77,29 +77,9 @@ impl Stamp { pub fn from_unix(secs: i64) -> Self { let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60); - let (y, m, d) = Self::civil_from_days(days); + let (y, m, d) = pdfcraft_cos::civil_from_days(days); // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. - Stamp { unix: secs, pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") } - } - - /// Days since 1970-01-01 → proleptic Gregorian (year, month, day). - /// - /// Past `NaiveDate`'s ±262143-year range the old hand-rolled converter still produced a date, - /// which both stamps formatted directly; a `from_timestamp` epoch fallback would change it. - /// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day - /// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't - /// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163. - fn civil_from_days(days: i64) -> (i64, u32, u32) { - use chrono::Datelike; - let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); - // `rest + 719_163` is 719_163..865_259, always a valid CE day. - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - None => (1970, 1, 1), // unreachable - } + Stamp { unix: secs, pdf: pdfcraft_cos::pdf_date(secs), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") } } /// The same moment shifted by `minutes` (used to order threaded replies). diff --git a/xtask/src/fuzz.rs b/xtask/src/fuzz.rs index 96cb976c83..a8f2fb8231 100644 --- a/xtask/src/fuzz.rs +++ b/xtask/src/fuzz.rs @@ -144,24 +144,194 @@ fn find(hay: &[u8], needle: &[u8]) -> Option { hay.windows(needle.len()).position(|w| w == needle) } -/// Synthetic seeds, so fuzzing works without the corpus: plain, object-stream and encrypted -/// shapes, nested page trees, annotations, forms and inline images. +/// A classic-xref PDF whose objects are numbered 1.. in order. ASCII object text only, so the +/// byte offsets are the string offsets. +fn build(objs: &[&str], trailer: &str) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// An uncompressed stream object body (`Filter`-free, so the seed stays plain text). +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// Every feature the engine reads on a page: fonts (Type 1 and Type 3), form fields with +/// actions, the annotation kinds, page labels, names, optional content, a structure tree, +/// shading, a form XObject, an image with a soft mask, a colour space and a graphics state. +fn feature_rich_seed() -> Vec { + let content = stream( + "", + "q /G1 gs BT /F1 18 Tf 10 250 Td (Hello) Tj /T3 12 Tf 0 -20 Td (aaa) Tj ET Q /Sh1 sh \ + q 50 0 0 50 100 20 cm /Fm1 Do Q q 40 0 0 40 20 20 cm /Im1 Do Q \ + /OC /OC1 BDC 1 0 0 rg 0 0 20 20 re f EMC /CS1 cs 1 sc 150 10 30 30 re f", + ); + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 50 50]", "0 1 0 rg 0 0 50 50 re f"); + let image = stream( + "/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 24 0 R", + "\u{0}\u{40}\u{7f}\u{20}", + ); + let charproc = stream("", "10 0 0 0 10 10 d1 0 0 10 10 re f"); + let appearance = stream("/Type /XObject /Subtype /Form /BBox [0 0 1 1]", "0 0 1 1 re f"); + let mask = stream("/Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{7f}\u{7f}\u{20}\u{0}"); + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Lang (en) /MarkInfo << /Marked true >> /StructTreeRoot 13 0 R \ + /AcroForm << /Fields [8 0 R 9 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> \ + /PageLabels << /Nums [0 << /S /D /St 1 >>] >> /Names << /JavaScript << /Names [(init) 11 0 R] >> >> \ + /OCProperties << /OCGs [12 0 R] /D << /Order [12 0 R] /ON [12 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /StructParents 0 /Annots [8 0 R 9 0 R 10 0 R 17 0 R 18 0 R 19 0 R 20 0 R] \ + /Resources << /Font << /F1 5 0 R /T3 7 0 R >> /Shading << /Sh1 14 0 R >> /XObject << /Fm1 15 0 R /Im1 16 0 R >> \ + /ExtGState << /G1 << /ca 0.5 /BM /Multiply >> >> /Properties << /OC1 12 0 R >> \ + /ColorSpace << /CS1 [/Indexed /DeviceRGB 1 ] >> >> >>", + &content, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /sq 21 0 R >> \ + /Encoding << /Type /Encoding /Differences [97 /sq] >> /FirstChar 97 /LastChar 97 /Widths [10] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (t) /V (v) /Rect [10 10 90 30] /P 3 0 R \ + /AA << /K << /S /JavaScript /JS (AFNumber_Keystroke\\(2,0,0,0,\"\",true\\);) >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (c) /V /Yes /AS /Yes /Rect [10 40 30 60] /P 3 0 R \ + /AP << /N << /Yes 22 0 R /Off 22 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (d) /Opt [(a) (b)] /V (a) /Rect [10 70 90 90] /P 3 0 R >>", + "<< /S /JavaScript /JS (var x = 1;) >>", + "<< /Type /OCG /Name (Layer) >>", + "<< /Type /StructTreeRoot /K 23 0 R /ParentTree << /Nums [0 [23 0 R]] >> >>", + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >>", + &form, + &image, + "<< /Type /Annot /Subtype /Link /Rect [10 120 90 140] /Border [0 0 0] /A << /S /URI /URI (http://example.com) >> >>", + "<< /Type /Annot /Subtype /Highlight /Rect [10 80 90 100] /QuadPoints [10 100 90 100 10 80 90 80] /C [1 1 0] \ + /Contents (note) /Popup 25 0 R >>", + "<< /Type /Annot /Subtype /Ink /Rect [10 10 40 30] /InkList [[10 10 20 30 40 20]] /C [1 0 0] >>", + "<< /Type /Annot /Subtype /FreeText /Rect [100 200 190 240] /DA (/Helv 12 Tf 0 g) /Contents (text) >>", + &charproc, + &appearance, + "<< /Type /StructElem /S /P /P 13 0 R /Pg 3 0 R /K 0 >>", + &mask, + "<< /Type /Annot /Subtype /Popup /Rect [100 100 150 150] /Parent 18 0 R >>", + ], + "/Root 1 0 R /Info << /Title (Rich) >>", + ) +} + +/// The same small document stored the modern way: an uncompressed object stream and a +/// cross-reference stream, so the tolerant and the strict readers both walk the compressed-object +/// and `/Index` paths. +fn object_stream_seed() -> Vec { + let bodies = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", + ]; + let nums = [1u32, 2, 3, 5]; + let mut header = String::new(); + let mut body = String::new(); + for (n, b) in nums.iter().zip(bodies) { + header.push_str(&format!("{n} {} ", body.len())); + body.push_str(b); + body.push('\n'); + } + let objstm_data = format!("{header}{body}"); + let mut out = b"%PDF-1.7\n".to_vec(); + let content_off = out.len(); + out.extend_from_slice(format!("4 0 obj\n{}\nendobj\n", stream("", "BT /F1 14 Tf 10 100 Td (Object stream) Tj ET")).as_bytes()); + let objstm_off = out.len(); + let dict = format!("/Type /ObjStm /N {} /First {}", nums.len(), header.len()); + out.extend_from_slice(format!("6 0 obj\n{}\nendobj\n", stream(&dict, &objstm_data)).as_bytes()); + let xref_off = out.len(); + // /W [1 3 1]: type, offset (or object-stream number), generation (or index in the stream). + let entry = |t: u8, f: usize, g: u8| [t, (f >> 16) as u8, (f >> 8) as u8, f as u8, g]; + let rows = [ + entry(0, 0, 255), + entry(2, 6, 0), + entry(2, 6, 1), + entry(2, 6, 2), + entry(1, content_off, 0), + entry(2, 6, 3), + entry(1, objstm_off, 0), + entry(1, xref_off, 0), + ]; + out.extend_from_slice( + format!("7 0 obj\n<< /Type /XRef /Size 8 /W [1 3 1] /Index [0 8] /Root 1 0 R /Length {} >>\nstream\n", rows.len() * 5).as_bytes(), + ); + for r in rows { + out.extend_from_slice(&r); + } + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_off}\n%%EOF\n").as_bytes()); + out +} + +/// A tiny document plus an incremental update (a new annotation and a rewritten page) chained +/// by `/Prev`, so mutations reach the revision walker and the incremental save path. +fn incremental_update_seed() -> Vec { + let mut out = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R >>", + ], + "/Root 1 0 R", + ); + let prev = out.windows(10).rposition(|w| w == b"startxref\n").and_then(|i| { + let tail = String::from_utf8_lossy(&out[i + 10..]).into_owned(); + tail.lines().next().and_then(|l| l.trim().parse::().ok()) + }); + let prev = prev.unwrap_or(0); + let page_off = out.len(); + out.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>\nendobj\n"); + let annot_off = out.len(); + out.extend_from_slice(b"4 0 obj\n<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [0 0 1] /Contents (update) >>\nendobj\n"); + let x = out.len(); + out.extend_from_slice( + format!( + "xref\n3 2\n{page_off:010} 00000 n \n{annot_off:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Prev {prev} >>\nstartxref\n{x}\n%%EOF\n" + ) + .as_bytes(), + ); + out +} + +/// A file that opens, whose graphs loop: a page whose annotation array holds the page, a form +/// XObject that draws itself and an outline item that is its own sibling. Every walker needs a +/// depth limit or a seen-set; these are what they meet. The page tree stays sound on purpose: a +/// kid that lists its own parent makes the whole document refuse to open ("no pages"), and a seed +/// refused at open only reaches the error path. +fn cyclic_seed() -> Vec { + let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm1 4 0 R >> >>", "/Fm1 Do 0 0 10 10 re f"); + build( + &[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Fm1 4 0 R >> >> /Annots [3 0 R] >>", + &form, + "<< /Type /Outlines /First 6 0 R /Last 6 0 R /Count 1 >>", + "<< /Title (Loop) /Parent 5 0 R /Next 6 0 R /Prev 6 0 R /Dest [3 0 R /Fit] >>", + ], + "/Root 1 0 R", + ) +} + +/// Synthetic seeds, so fuzzing works without the corpus: a small mixed document, a minimal one, +/// a feature-rich page, an object-stream file with a cross-reference stream, an incremental +/// update and looping object graphs. There is no encrypted seed yet: `xtask` does not depend on +/// the crypt crate, and a hand-rolled encryptor here would be a second implementation to keep +/// right. fn synthetic_seeds() -> Vec> { - let build = |objs: &[&str], trailer: &str| { - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); - out - }; let content = "BT /F1 24 Tf 20 150 Td (Hello) Tj ET 0 0 1 rg 10 10 50 50 re f q 20 0 0 20 100 100 cm BI /W 2 /H 2 /BPC 8 /CS /G ID \u{0}\u{ff}\u{ff}\u{0} EI Q"; let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); vec![ @@ -188,6 +358,10 @@ fn synthetic_seeds() -> Vec> { ], "/Root 1 0 R", ), + feature_rich_seed(), + object_stream_seed(), + incremental_update_seed(), + cyclic_seed(), ] } @@ -425,3 +599,58 @@ pub fn run(args: &[String]) -> Result<()> { fn fxhash(b: &[u8]) -> u64 { b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, x| (h ^ u64::from(*x)).wrapping_mul(0x100_0000_01b3)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The offset after the last `startxref`, if the file has a well-formed one. + fn startxref(pdf: &[u8]) -> Option { + let i = pdf.windows(10).rposition(|w| w == b"startxref\n")?; + String::from_utf8_lossy(pdf.get(i + 10..)?).lines().next()?.trim().parse().ok() + } + + /// A seed whose offsets are wrong would only exercise the reconstruction path, which hides the + /// readers the seed is there to reach. + #[test] + fn every_synthetic_seed_points_startxref_at_its_cross_reference() { + let seeds = synthetic_seeds(); + assert_eq!(seeds.len(), 6, "update this count and the doc comment together with the seeds"); + for (n, pdf) in seeds.iter().enumerate() { + assert!(pdf.starts_with(b"%PDF-"), "seed {n}: header"); + assert!(pdf.ends_with(b"%%EOF\n"), "seed {n}: trailer"); + let at = startxref(pdf).unwrap_or_else(|| panic!("seed {n}: no startxref")); + let target = pdf.get(at..).unwrap_or_default(); + let table = target.starts_with(b"xref\n"); + let xref_stream = find(target.get(..40).unwrap_or(target), b"0 obj").is_some() && find(target, b"/Type /XRef").is_some(); + assert!(table || xref_stream, "seed {n}: startxref {at} does not reach a cross-reference"); + } + } + + /// The incremental-update seed must chain to the first section, or it is not an update. + #[test] + fn the_incremental_seed_chains_to_the_first_cross_reference() { + let pdf = incremental_update_seed(); + let at = startxref(&pdf).unwrap_or(0); + let tail = String::from_utf8_lossy(pdf.get(at..).unwrap_or_default()).into_owned(); + let prev: usize = tail.split("/Prev ").nth(1).and_then(|s| s.split_whitespace().next()).and_then(|s| s.parse().ok()).unwrap_or(0); + assert!(prev > 0 && prev < at, "/Prev {prev} must point at the earlier section (startxref {at})"); + assert!(pdf.get(prev..).unwrap_or_default().starts_with(b"xref\n")); + } + + /// Every cross-reference entry of the classic seeds points at its own object. + #[test] + fn classic_seed_entries_point_at_their_objects() { + for (n, pdf) in [synthetic_seeds().remove(0), feature_rich_seed(), cyclic_seed()].iter().enumerate() { + let at = startxref(pdf).unwrap_or(0); + let text = String::from_utf8_lossy(pdf.get(at..).unwrap_or_default()).into_owned(); + for (num, line) in text.lines().skip(2).take_while(|l| !l.starts_with("trailer")).enumerate() { + if line.ends_with(" n ") { + let off: usize = line.get(..10).and_then(|o| o.parse().ok()).unwrap_or(usize::MAX); + let want = format!("{num} 0 obj"); + assert!(pdf.get(off..).unwrap_or_default().starts_with(want.as_bytes()), "seed {n}: entry {num} does not reach `{want}`"); + } + } + } + } +}
{words}Show Bar