diff --git a/AGENTS.md b/AGENTS.md index 4792a3ef9b..066cad3597 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ An **asset** is any non-code material that the project includes, ships, embeds, ### 1.2 Only open, provable licences Every asset must be one of: -- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera. +- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`). - **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0. - **Public domain.** - **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`). diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index a8de85e07a..cfc16f78f4 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -267,7 +267,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -## Compiled in through dependencies (6) +## Compiled in through dependencies (7) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| @@ -277,6 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | | `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | +| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one | ## Downloaded at build time (22) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 608bb601a5..67b588f25e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2263,6 +2263,18 @@ adobe_data = true usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1" sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41" +[[bundled]] +crate = "icu_segmenter_data" +version = "2.3.0" +path = "data/segmenter_dictionary_extended_v1.rs.data" +title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)" +author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)" +source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)" +licence = "Unicode-3.0" +kind = "data" +usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one" +sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d" + [[fetched]] file = "PlayfairDisplay[wght].ttf" url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf" diff --git a/Cargo.lock b/Cargo.lock index 318972b728..f97a14968f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3133,6 +3133,28 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + [[package]] name = "idna" version = "1.1.0" @@ -4400,6 +4422,7 @@ name = "pdfcraft-edit" version = "0.18.1" dependencies = [ "hayro-syntax", + "icu_segmenter", "kurbo 0.13.1", "pdfcraft-annot", "pdfcraft-content", @@ -4569,6 +4592,7 @@ dependencies = [ "pdfcraft-render", "proptest", "thiserror 2.0.21", + "thread-priority", "zune-jpeg", ] @@ -6934,6 +6958,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/LICENSES.toml b/LICENSES.toml index 0617ca5d57..ac54d9f702 100644 --- a/LICENSES.toml +++ b/LICENSES.toml @@ -16,7 +16,7 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352" [[document]] source = "NOTICE" destination = "NOTICE" -sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314" +sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb" [[document]] source = "assets/app-icon/LICENSE-document.txt" @@ -60,6 +60,13 @@ destination = "licenses/CMap-LICENSE.txt" sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29" origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt" +[[document]] +source = "licenses/ICU4X-LICENSE.txt" +licences = ["Unicode-3.0"] +destination = "licenses/ICU4X-LICENSE.txt" +sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2" +origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE" + [[document]] source = "licenses/egui-Hack-Regular.txt" licences = ["MIT", "Bitstream-Vera"] @@ -164,3 +171,9 @@ crate = "hayro-interpret" path = "src/font/generated/metrics.rs" licence = "MIT OR Apache-2.0" documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"] + +[[bundled]] +crate = "icu_segmenter_data" +path = "data/segmenter_dictionary_extended_v1.rs.data" +licence = "Unicode-3.0" +documents = ["licenses/ICU4X-LICENSE.txt"] diff --git a/NOTICE b/NOTICE index e8f31ade2f..eed2d654b0 100644 --- a/NOTICE +++ b/NOTICE @@ -36,6 +36,9 @@ list (author, source, license) is in ATTRIBUTION.md. - egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0), Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT). - Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram). +- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer + and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in + licenses/ICU4X-LICENSE.txt. Required notices: @@ -48,6 +51,32 @@ Required notices: (https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of Adobe nor the names of its contributors are used to endorse or promote this product. + ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets): + Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J: + ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + + Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any + associated documentation (the "Data Files") or software and any associated documentation (the + "Software") to deal in the Data Files or Software without restriction, including without + limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the + Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to + do so, provided that either (a) this copyright and permission notice appear with all copies of + the Data Files or Software, or (b) this copyright and permission notice appear in associated + Documentation. + + THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. + + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, + OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF + USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE. + + Except as contained in this notice, the name of a copyright holder shall not be used in + advertising or otherwise to promote the sale, use or other dealings in these Data Files or + Software without prior written authorization of the copyright holder. + Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the "Downloaded at build time" table in ATTRIBUTION.md. diff --git a/ROADMAP.md b/ROADMAP.md index 9ca3d4378f..993d3175f8 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 @@ -249,12 +250,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged. +- **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. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 3118c901b4..c64f9e1c0f 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(()) 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/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 999baec20a..6ab3736ed2 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -6,13 +6,19 @@ 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 ISO 32000-2 Table 178 places them, the caption in black Helvetica 10. Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with - line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't + line breaking by Helvetica's standard-14 metrics. Thai in a text box, callout or Fill & Sign + text is shaped and drawn from the craft-fonts Thai face with Add text's writer + (`pdfcraft_fonts::shaped_text`: Type 3 glyphs with a ToUnicode map), the box sized to the ink + of its stacked marks and descenders; a build without a Thai face refuses it + (`AnnotError::Undrawable`) instead of drawing `?`. Other text it can't draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and @@ -43,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; @@ -59,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 fcb3cb22b5..7996567f04 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -7,12 +7,39 @@ //! endings), so callers never replace an appearance with a worse one. //! //! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard -//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of -//! Helvetica's proportions by character class (no font program or metrics file is bundled). +//! Helvetica font with WinAnsi encoding, and draw Thai from the craft-fonts Thai face as Type 3 +//! glyphs (`pdfcraft_fonts::shaped_text`, the writer Add text uses); line breaking uses +//! [`text_width`], the standard-14 metrics and the Thai face's shaped advances. use pdfcraft_cos::{Dict, Object, PdfString, Stream}; -use pdfcraft_fonts::{first_undrawable, literal, win_ansi}; -pub use pdfcraft_fonts::{helvetica_width as text_width, wrap}; +use pdfcraft_fonts::shaped_text; +use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, win_ansi, wrap_with}; + +/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai +/// face that text boxes draw it with. +pub fn text_width(text: &str, size: f64) -> f64 { + shaped_text::thai_width(text, size, helvetica_width) +} + +/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter. +pub fn wrap(text: &str, size: f64, width: f64) -> Vec { + wrap_with(text, size, width, text_width) +} + +/// How far a text box's `lines` reach, in em: above the first baseline (0.9 of the 1.2 em line) +/// and below the last (0.3), or further where Thai stacked marks or descenders reach, so the box +/// is sized to their ink and doesn't clip them. +fn reach(lines: &[String]) -> [f64; 2] { + let ink = |line: Option<&String>| line.and_then(|l| shaped_text::thai_reach(l)); + [ink(lines.first()).map_or(0.9, |r| r[1].max(0.9)), ink(lines.last()).map_or(0.3, |r| (-r[0]).max(0.3))] +} + +/// The height a text box's `lines` take at `size`, inside its padding: 1.2 em a line, and more +/// for Thai that reaches further ([`reach`]). +pub fn text_lines_height(lines: &[String], size: f64) -> f64 { + let [up, down] = reach(lines); + lines.len().max(1) as f64 * size * 1.2 + (up - 0.9 + down - 0.3) * size +} use crate::{Document, NOTE_SIZE, Rgb, n}; @@ -178,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, @@ -392,13 +432,15 @@ pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> { /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The -/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a +/// text of a text box, callout or Fill & Sign text (whose Thai the Thai face draws, where this +/// build has one), a line's caption, a measurement's label, a /// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of /// it can be drawn. pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); let (what, text) = match d.name(b"Subtype")? { - b"FreeText" => ("the comment's text", text(b"Contents")?), + // A text box draws Thai too. + b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)), b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), @@ -411,6 +453,11 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { /// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. pub(crate) fn helvetica_resource() -> Object { + Object::Dict(helvetica_fonts()) +} + +/// [`helvetica_resource`]'s dictionary. +fn helvetica_fonts() -> Dict { let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type1")); @@ -418,7 +465,7 @@ pub(crate) fn helvetica_resource() -> Object { font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); let mut fonts = Dict::new(); fonts.set(b"Helv".to_vec(), Object::Dict(font)); - Object::Dict(fonts) + fonts } /// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for @@ -1102,27 +1149,31 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin )); let mut out = c.into_bytes(); let lines = wrap(&text, size, width); - let mut y = rect[3] - pad - size * 0.9; + let mut y = rect[3] - pad - size * reach(&lines)[0]; if round { - y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0); + y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0); } - for line in lines { + // Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose + // streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as + // `?`, which `undrawable_text` reports), in Helvetica. + let mut fonts = helvetica_fonts(); + let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts); + let mut current = None; + for pieces in &pieces { if y < rect[1] - size { break; } - let lw = text_width(&line, size); + let lw = shaped_text::pieces_width(pieces, size, 0.0, helvetica_width); let x = match q { 1 => rect[0] + pad + (width - lw) / 2.0, 2 => rect[2] - pad - lw, _ => rect[0] + pad, }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); + shaped.show_line(&mut out, pieces, x, y, size, "Helv", win_ansi, |s| helvetica_width(s, size), &mut current); y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + res.set(b"Font".to_vec(), Object::Dict(fonts)); let mut stream = form(full, &out, res); if let Some(m) = matrix { stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 6376655ce9..6dbb7842e6 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,6 +1,6 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; -use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box}; +use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, }; @@ -398,6 +398,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R if matches!(turn, 90 | 180 | 270) { d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); } + let rect = fit_thai_ink(rect, turn, &new.contents, size, 2.0 + appearance::border_width(&d)); // An oval or a circle goes around the text box, its text wrapped as displayed: // after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page. shape_text_box(&mut d, rect, style.border_shape, style.width); @@ -417,6 +418,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R { return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); } + refuse_undrawable_thai(d.name(b"Subtype") == Some(b"FreeText"), &new.contents)?; let r = doc.add(Object::Dict(d.clone())); draw_appearance(doc, r)?; let mut list = annots(doc, page); @@ -427,6 +429,30 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R Ok(index) } +/// Text box `tb` (user space; its text upright on a page turned by `turn`), grown down as displayed +/// until the ink of `text`'s Thai (stacked marks above its first line, descenders below its last) +/// fits inside the padding `pad`, as the appearance lays it out at `size`. Other text, and a box +/// tall enough already, keep the box they were given. +fn fit_thai_ink(tb: [f64; 4], turn: i64, text: &str, size: f64, pad: f64) -> [f64; 4] { + if !text.chars().any(pdfcraft_fonts::shaped_text::is_thai) { + return tb; + } + let [x0, y0, x1, y1] = normalize(tb); + let (w, h) = if matches!(turn, 90 | 270) { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) }; + let need = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size) + 2.0 * pad + 2.0; + let by = need - h; + if !(by.is_finite() && by > 0.0) { + return tb; + } + // Displayed down is user -y unturned, +x on a page turned 90°, +y at 180° and -x at 270°. + match turn { + 90 => [x0, y0, x1 + by, y1], + 180 => [x0, y0, x1, y1 + by], + 270 => [x0 - by, y0, x1, y1], + _ => [x0, y0 - by, x1, y1], + } +} + fn validate_style(style: &Style) -> Result<(), AnnotError> { if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() { return Err(AnnotError::Invalid("invalid style".into())); diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index eac1f09fea..5c9701ce81 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -57,9 +57,11 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option { } /// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, mut stream: Stream) -> Result<(), AnnotError> { // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. let missing = appearance::undrawable_text(d).map(|(_, c)| c); + // A text box's Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict); let ap = doc.add(Object::Stream(stream)); // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover // appearances would show the previous look on press or hover, so they go with the old /N. 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/modify.rs b/crates/annot/src/modify.rs index 70abf987aa..6fbb0f35c3 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -25,6 +25,15 @@ pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotE Ok(()) } +/// Thai typed into a text box (`free_text`) that this build can't draw (it has no Thai face): refused +/// with what to do, rather than drawn as `?`. +pub(crate) fn refuse_undrawable_thai(free_text: bool, text: &str) -> Result<(), AnnotError> { + match free_text.then(|| pdfcraft_fonts::shaped_text::thai_refusal(text)).flatten() { + Some(why) => Err(AnnotError::Undrawable(why)), + None => Ok(()), + } +} + /// Format a number for content streams and DA strings. pub(crate) fn n(v: f64) -> String { let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); @@ -85,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)) { @@ -106,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 @@ -165,6 +187,7 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m let (_, r) = annot_ref(doc, page, index)?; let d = annot_dict(doc, r); let free_text = d.name(b"Subtype") == Some(b"FreeText"); + refuse_undrawable_thai(free_text, text)?; let snug = free_text && is_snug(&d); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); @@ -224,8 +247,8 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) }; - let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; - Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) + let height = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size); + Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top]) } /// Reply to a comment; returns the reply's index in the page's `/Annots`. @@ -459,7 +482,7 @@ fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] { let (_, size) = appearance::parse_da(d); let lines = appearance::wrap(&text, size, wrap.max(1.0)); let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); - [widest, lines.len().max(1) as f64 * size * 1.2] + [widest, appearance::text_lines_height(&lines, size)] } /// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by @@ -703,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(); @@ -984,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 @@ -1006,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]]; @@ -1021,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 e14838aac8..7561938676 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1264,22 +1264,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 +1312,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 +2849,134 @@ 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() +} + +/// Thai typed into Fill & Sign text or a text box is shaped and drawn from the Thai face, as Add +/// text draws it: Type 3 glyphs (their streams objects of their own) with a ToUnicode map, never +/// `?`. The box grows to hold the ink of its stacked marks and descenders, which reach past the +/// 1.2 em lines boxes are sized by. A build without a Thai face refuses the text, saying why. +#[test] +fn thai_in_fill_sign_text_and_text_boxes_is_drawn_from_the_thai_face() { + let mut doc = fixture(); + // Each line has the face's highest stack (น้ำ) and deepest descender (ฎู). + let thai = "น้ำฎู บุญ\nปั้นน้ำฎู"; + let item = |shape: Shape| NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: thai.into(), author: "Ada".into() }; + let shapes = [ + Shape::Typewriter { rect: [100.0, 700.0, 300.0, 716.0], font_size: 24.0 }, + Shape::TextBox { rect: [100.0, 400.0, 400.0, 430.0], font_size: 30.0 }, + ]; + if pdfcraft_fonts::document_thai_font().is_none() { + for shape in shapes { + let err = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap_err(); + assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}"); + } + assert_eq!(list(&doc, 0).len(), 1, "nothing was added"); + let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 }), &meta("b")).unwrap(); + assert!(matches!(set_contents(&mut doc, 0, t, thai, &meta("")), Err(AnnotError::Undrawable(_)))); + assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "hello"); + return; + } + let reach = pdfcraft_fonts::shaped_text::thai_reach; + let (lo, hi) = (reach("ฎู").unwrap()[0], reach("น้ำ").unwrap()[1]); + for (shape, size) in shapes.into_iter().zip([24.0, 30.0]) { + let t = add_annotation(&mut doc, &item(shape), &meta("t")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[t]; + assert_eq!((text(d, b"Contents").as_str(), pdfcraft_fonts::undrawn_mark(d)), (thai, None)); + let content = ap_content(&doc, d); + assert!(!content.contains('?') && content.contains("/PCTh1 "), "{content}"); + // The glyphs and the text they stand for are objects of the saved file. + let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).unwrap(); + let ap = doc.get(n); + let fonts = ap.as_dict().and_then(|s| s.get(b"Resources")).and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()); + let font = fonts.and_then(|f| f.get(b"PCTh1")).and_then(|f| f.as_dict()).unwrap().clone(); + assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..])); + let cmap = doc.get(font.reference(b"ToUnicode").unwrap()); + let Object::Stream(cmap) = &*cmap else { panic!("ToUnicode is a stream object") }; + let cmap = String::from_utf8_lossy(&cmap.decoded().unwrap()).into_owned(); + assert!(cmap.contains("<0E190E490E33>"), "น้ำ copies as typed: {cmap}"); + let procs = font.get(b"CharProcs").and_then(|p| p.as_dict()).unwrap(); + assert!(procs.iter().all(|(_, g)| matches!(g, Object::Ref(_))), "glyph programs are stream objects"); + // Every line's ink lies inside the box: the appearance's BBox and its clip. + let (r, ys) = (rect(d), baselines(&content)); + let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min)); + assert!(top + hi * size <= r[3] + 1e-3 && bottom + lo * size >= r[1] - 1e-3, "{ys:?} at {size} in {r:?}"); + } + // New text refits the box to its ink too: the stack on its first line, the descender on its last. + set_contents(&mut doc, 0, 1, "น้ำ\nฎู", &meta("")).unwrap(); + let d = &list(&doc, 0)[1]; + let (r, ys) = (rect(d), baselines(&ap_content(&doc, d))); + assert!(ys[0] + hi * 24.0 <= r[3] + 1e-3 && ys[ys.len() - 1] + lo * 24.0 >= r[1] - 1e-3, "{ys:?} in {r:?}"); +} + const QUAD: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]; fn reals(v: &[f64]) -> Vec { 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 937dfecb7d..9da911c932 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -146,7 +146,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root `object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}` moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes -and fonts. A refusal changes nothing; success is one undo step. See +and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the +page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every +resource they use and `object_paste {doc, page}` pastes them as page content that draws the same, +into the same or another open document, stepped as the app steps a paste. See [the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples. ## Accessibility review record @@ -285,7 +288,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/bates.rs b/crates/automation/src/bates.rs index 5267716e85..6493df9968 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,100 @@ 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()); + + assert!(matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied)); + 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/content.rs b/crates/automation/src/content.rs index 1f26dcc26a..a77ef98b8b 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -354,14 +354,32 @@ impl Automation { Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items})) } - pub(crate) fn object_move(&mut self, a: &Args) -> Result { + /// The references `objects` names (from object_list), or with `all` every object on `page`, + /// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's. + fn object_targets(&self, a: &Args, page: usize) -> Result> { use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; - let page = self.page(a)?; + let doc = self.doc(a)?; + if let Some(g) = a.get("generation") { + let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?; + if g != doc.edit_generation() { + return Err(failed("the document changed; call object_list again")); + } + } + if a.opt_bool("all")?.unwrap_or(false) { + if a.get("objects").is_some() { + return Err(bad("give objects or all, not both")); + } + let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect(); + if all.is_empty() || all.len() > MAX_MOVE_OBJECTS { + return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len()))); + } + return Ok(all); + } let rows = a .get("objects") .and_then(Value::as_array) .filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS) - .ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?; + .ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?; let mut targets = Vec::with_capacity(rows.len()); for row in rows { let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?; @@ -382,6 +400,12 @@ impl Automation { .ok_or_else(|| bad("object index must be a positive integer from object_list"))?; targets.push(ObjectTarget { kind, index }); } + Ok(targets) + } + + pub(crate) fn object_move(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let targets = self.object_targets(a, page)?; let delta = a .get("offset") .and_then(Value::as_array) @@ -389,17 +413,46 @@ impl Automation { .and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?])) .filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9)) .ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?; - let doc = self.doc(a)?; - if let Some(g) = a.get("generation") { - let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?; - if g != doc.edit_generation() { - return Err(failed("the document changed; call object_list again before moving")); - } - } - let offset = doc.object_move_offset(page, delta).map_err(failed)?; + let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?; let count = targets.len(); let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?; result["moved"] = json!(count); Ok(result) } + + /// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into + /// any open document. A cut then deletes them as one undo step. + pub(crate) fn object_copy(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let targets = self.object_targets(a, page)?; + let cut = a.opt_bool("cut")?.unwrap_or(false); + let doc = self.doc(a)?; + if cut && !doc.allows_modification() { + return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes"))); + } + let clip = doc.copy_objects(page, &targets).map_err(failed)?; + let out = json!({ "copied": clip.objects().len(), "text": clip.text() }); + self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip)); + if !cut { + return Ok(out); + } + let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?; + let mut result = self.apply(a, delete)?; + result["copied"] = out["copied"].clone(); + result["text"] = out["text"].clone(); + Ok(result) + } + + /// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the + /// page they came from (and on each further paste onto a page) 18 points down and right. + pub(crate) fn object_paste(&mut self, a: &Args) -> Result { + let page = self.page(a)?; + let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?; + let count = clipboard.clip().objects().len(); + let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed)); + self.objects_clipboard = Some(clipboard); + let mut result = self.apply(a, edit?)?; + result["pasted"] = json!(count); + Ok(result) + } } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index a838241705..642c4b5b11 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -738,6 +738,8 @@ impl Automation { "links_remove" => self.links_remove(&a)?, "object_list" => self.object_list(&a)?, "object_move" => self.object_move(&a)?, + "object_copy" => self.object_copy(&a)?, + "object_paste" => self.object_paste(&a)?, "content_list" => self.content_list(&a)?, "page_add_text" => self.page_add_text(&a)?, "page_add_image" => self.page_add_image(&a)?, 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/lib.rs b/crates/automation/src/lib.rs index 7e4443d244..28cbbfdca4 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; @@ -101,6 +100,8 @@ pub struct Automation { /// Review sessions held for `ocr_words` (a guard and the pages read), by document. ocr_reviews: HashMap, next_ocr_review: u64, + /// What object_copy copied, for object_paste into any open document. + objects_clipboard: Option, } impl Default for Automation { @@ -121,6 +122,7 @@ impl Automation { evicted: HashSet::new(), ocr_reviews: HashMap::new(), next_ocr_review: 0, + objects_clipboard: None, } } 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/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..acfa204b4b 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -219,6 +219,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 +243,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 4a30693bb5..adb2eec723 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,21 +195,40 @@ 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"])), - t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") + t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list (or with all: true every object on the page) by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.") .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, + "all":{"type":"boolean","description":"Every object on the page, instead of objects."}, "offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}, - "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])), + "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","offset"])), + t("object_copy", "Copy or cut objects together", "Edit ▸ Copy (cut: Cut) of 1–1000 mixed references from object_list, or with all: true every object on the page, as ⌘A then ⌘C select and copy them in Edit a PDF. The copy keeps their content operators and every resource they use (fonts, images, ExtGStates, colour spaces, patterns, shadings) and is kept for object_paste into any open document, also after this one changes or closes. cut then deletes them as one undo step. Refused when the document doesn't allow copying (cut: or changes), or when text used as a clipping path is drawn before them. Returns the count copied and their text.") + .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}, + "objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{ + "type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}}, + "all":{"type":"boolean","description":"Every object on the page, instead of objects."}, + "cut":{"type":"boolean","description":"Delete them after copying (one undo step)."}, + "generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page"])), + t("object_paste", "Paste copied objects", "Edit ▸ Paste of what object_copy copied, onto page of doc as page content that draws the same: where it was, or on the page it came from 18 points down and to the right, and 18 more for each further paste onto the same page; centred when that would leave it off the page. Resources join the page's under names that don't clash; into another document the fonts, images and other objects they use are copied (each once, sharing what is there; refused past 20,000 objects or 256 MB). Pasted content is untagged and in no layer. One undo step.") + .with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])), t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.") .ro() .cmd("file.word_count") @@ -483,7 +502,7 @@ pub fn tools() -> Vec { t( "form_fill", "Fill in form fields", - "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", ) .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") @@ -688,7 +707,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!({ @@ -699,6 +718,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" }, @@ -854,7 +875,7 @@ pub fn tools() -> Vec { "Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \ note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \ rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \ - polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.", + polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Thai in a text box or callout is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Other text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.", ) .with(schema( json!({ @@ -1125,7 +1146,7 @@ pub fn tools() -> Vec { t( "doc_header_footer", "Add header and footer", - "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.", + "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier; Thai is drawn from PdfCraft's built-in Thai face beside it (a build without one refuses it). replace: true swaps out existing ones (Update). Undoable.", ) .cmd("edit.header_footer") .with(schema( @@ -1144,7 +1165,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.") + t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given (Thai drawn from PdfCraft's built-in Thai face; a build without one refuses it), on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.") .cmd("edit.watermark") .with(schema( json!({ @@ -1479,7 +1500,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed Thai is drawn from PdfCraft's built-in Thai face (a build without one refuses it); other typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e41f93c2c4..94ce35f539 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2969,6 +2969,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); @@ -2987,15 +2996,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}; @@ -6850,11 +6868,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(); @@ -8588,6 +8621,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). @@ -8836,6 +8982,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"); @@ -9512,6 +9701,50 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() { assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ")); } +/// Thai typed into Fill & Sign text, a text box, a form field, a footer and a watermark (#2354) is +/// drawn from PdfCraft's Thai face, as Add text draws it: no tool warns of `?`, and after a save +/// each one's text is found as typed and the page renders. A build without a Thai face refuses +/// the text in each, saying what to do, rather than writing `?????`. +#[test] +fn thai_in_fill_sign_text_comments_fields_and_page_marks_is_drawn_and_found() { + let dir = workdir("thai-everywhere"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + let thai = "บุญฤทธิ์ น้ำ ปั้น"; + let field = ok(&mut a, "form_add_field", json!({"doc": doc, "page": 1, "type": "text", "rect": [20, 120, 220, 142]}))["field"] + .as_str() + .unwrap() + .to_string(); + let calls = [ + ("fill_sign_add", json!({"doc": doc, "page": 1, "type": "text", "at": [20, 40], "text": thai, "font_size": 14})), + ("comment_add", json!({"doc": doc, "page": 1, "type": "textbox", "rect": [20, 160, 200, 190], "contents": thai})), + ("form_fill", json!({"doc": doc, "values": {field.as_str(): thai}})), + ("doc_header_footer", json!({"doc": doc, "pages": [1], "footer_center": format!("{thai} <<1>>")})), + ("doc_watermark", json!({"doc": doc, "pages": [1], "text": thai})), + ]; + if pdfcraft_fonts::document_thai_font().is_none() { + for (tool, args) in calls { + let err = a.call(tool, &args).unwrap_err().to_string(); + assert!(err.contains("Thai") && err.contains("CRAFT_FONTS_DIR"), "{tool}: {err}"); + } + assert_eq!(ok(&mut a, "comment_list", json!({"doc": doc}))["comments"].as_array().map(Vec::len), Some(0)); + return; + } + for (tool, args) in calls { + let r = ok(&mut a, tool, args); + assert!(r.get("warnings").is_none(), "{tool}: {r}"); + } + ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai-marks.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path": "thai-marks.pdf"}))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "บุญฤทธิ์"})); + assert_eq!(found["count"], 5, "{found}"); + let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 72})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, .. } if !data.is_empty()))); + // Flattened, every one of them is page text, as typed. + ok(&mut a, "doc_flatten", json!({"doc": reopened})); + assert_eq!(page_text(&mut a, reopened)[0].matches(thai).count(), 5, "{:?}", page_text(&mut a, reopened)); +} + #[test] fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { let dir = workdir("hebrew-content"); @@ -9570,6 +9803,75 @@ fn mixed_objects_move_through_tools_with_generation_and_one_undo() { assert!(page_text(&mut a, reopened)[0].contains("Added label")); } +/// Edit a PDF's ⌘A, ⌘C / ⌘X and ⌘V headlessly: every object of a page copied, pasted into +/// another document and onto its own page (stepped down and right), and cut as one undo step. +#[test] +fn objects_copy_cut_and_paste_through_tools_within_and_across_documents() { + let dir = workdir("object-copy-paste"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let other = ok(&mut a, "doc_open", json!({"path":"b.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Added label","rect":[20,20,130,45]})); + assert!(matches!(a.call("object_paste", &json!({"doc":doc,"page":1})), Err(ToolError::Failed(_))), "nothing copied yet"); + let listed = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + assert_eq!(listed["count"], 2); + let copied = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]})); + assert_eq!(copied["copied"], 2); + let text = copied["text"].as_str().unwrap(); + assert!(text.contains("Added label") && text.contains("Page 1"), "{text}"); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), listed, "a copy changes nothing"); + + // Into another document: the same objects, where they were, as its own page text. + let before = ok(&mut a, "object_list", json!({"doc":other,"page":2})); + assert_eq!(ok(&mut a, "object_paste", json!({"doc":other,"page":2}))["pasted"], 2); + let pasted = ok(&mut a, "object_list", json!({"doc":other,"page":2})); + assert_eq!(pasted["count"], 3); + let rect_of = + |list: &Value, text: &str| list["objects"].as_array().unwrap().iter().find(|o| o["text"] == text).map(|o| o["rect"].clone()).unwrap(); + assert_eq!(rect_of(&pasted, "Page 1"), rect_of(&listed, "Page 1")); + // The added text box comes as the page's own text, drawn inside the box it had. + let (label, box_) = (rect_of(&pasted, "Added label"), rect_of(&listed, "Added label")); + assert!((0..2).all(|i| label[i].as_f64() >= box_[i].as_f64()) && (2..4).all(|i| label[i].as_f64() <= box_[i].as_f64()), "{label} in {box_}"); + assert!(page_text(&mut a, other)[1].contains("Added label")); + ok(&mut a, "doc_save", json!({"doc":other,"path":"pasted.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"pasted.pdf"}))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, reopened)[1].contains("Page 1")); + ok(&mut a, "edit_undo", json!({"doc":other})); + assert_eq!(ok(&mut a, "object_list", json!({"doc":other,"page":2}))["objects"], before["objects"], "one undo step"); + + // Onto its own page: 18 points down and to the right. + ok(&mut a, "object_paste", json!({"doc":doc,"page":1})); + let both = ok(&mut a, "object_list", json!({"doc":doc,"page":1})); + assert_eq!(both["count"], 4); + let original = rect_of(&listed, "Page 1"); + let moved = + both["objects"].as_array().unwrap().iter().filter(|o| o["text"] == "Page 1").map(|o| o["rect"].clone()).find(|r| *r != original).unwrap(); + for i in 0..4 { + assert!((moved[i].as_f64().unwrap() - original[i].as_f64().unwrap() - 18.0).abs() < 0.02, "{moved} {original}"); + } + + // Cut: copied, then deleted as one undo step. + let cut = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"cut":true})); + assert_eq!(cut["copied"], 1); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["count"], 3); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], both["objects"]); + + for args in [ + json!({"doc":doc,"page":1,"all":true,"objects":[{"kind":"text","index":1}]}), + json!({"doc":doc,"page":1}), + json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":99}]}), + json!({"doc":doc,"page":9,"all":true}), + ] { + assert!(a.call("object_copy", &args).is_err(), "{args}"); + } + assert!( + matches!(a.call("object_copy", &json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]})), Err(ToolError::Failed(_))), + "stale" + ); + assert!(a.call("object_paste", &json!({"doc":doc,"page":9})).is_err()); +} + #[test] fn object_move_rejects_invalid_nested_references_without_any_change() { let dir = workdir("object-move-refusals"); @@ -11930,6 +12232,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/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..5271e591b7 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 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..5ca4e7e4eb 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); + } } } @@ -594,6 +665,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/writer.rs b/crates/cos/src/writer.rs index 75d49dbd76..3037669aea 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -919,14 +919,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 +1110,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/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..be9def0704 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -302,20 +302,33 @@ 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 { 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/Cargo.toml b/crates/edit/Cargo.toml index 2cd33a275d..c5eae81e40 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -21,6 +21,9 @@ kurbo = { workspace = true } # Display order of right-to-left lines in added text, and reading and writing order in Edit text; # already in the tree via the interface. unicode-bidi = "0.3" +# Thai word boundaries for rewrapping and Add text (dictionary data compiled in, no LSTM model); +# the ICU4X 2.3 crates it builds on are already in the tree. +icu_segmenter = { version = "2.3", default-features = false, features = ["compiled_data"] } [dev-dependencies] hayro-syntax = { workspace = true } diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 71a70334c8..98b428ed92 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -8,12 +8,8 @@ //! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the //! `/UserUnit` it was written for. -use std::collections::{HashMap, HashSet}; - use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{ - GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi, -}; +use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi}; use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; @@ -30,7 +26,11 @@ use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped}; use layout::Piece; #[cfg(test)] pub(crate) use layout::WRAP_CALLS; +#[cfg(test)] +pub(crate) use layout::shaped_width; pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width}; +#[cfg(test)] +use pdfcraft_fonts::ShapedCluster; pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content}; pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, Ink, MarkerScan}; diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 90c9e6877b..8e0ec75791 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -3,17 +3,17 @@ use super::layout::{ }; use super::types::{AddedText, Align, Family, Ink}; use super::{ - Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, - align_code, literal, matrix_number, n, shaped_glyph, win_ansi, + Content, Dict, Document, EditError, GlyphError, GlyphOutline, Object, PdfString, ShapedScript, align_code, literal, matrix_number, n, + shaped_glyph, win_ansi, }; +use pdfcraft_fonts::shaped_text::{self, ShapedFonts}; fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) } -/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font -/// per 240 distinct glyphs it needs. -pub(super) const GLYPHS_PER_FONT: usize = 240; +#[cfg(test)] +pub(super) use pdfcraft_fonts::shaped_text::GLYPHS_PER_FONT; /// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left, /// its paragraph, its pieces in drawing order and its width at the item's size. @@ -61,50 +61,15 @@ pub(super) fn draw_laid_shaped( ) -> Result<(), EditError> { let r = text_rect(t); let latin = font_name(t.family.base_font(t.bold, t.italic)); - // Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of - // one of the fonts, so copy and search get each character exactly once. - let mut keys: Vec<&ShapedCluster> = Vec::new(); - let mut index: HashMap = HashMap::new(); - for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p { - Piece::Shaped(clusters) => clusters.as_slice(), - Piece::Latin(_) => &[], - }) { - index.entry(cluster_key(c)).or_insert_with(|| { - keys.push(c); - keys.len() - 1 - }); - } - - // Every outline is read before anything is written, so a failure leaves the document as it was. - let mut outlines = HashMap::new(); - for c in &keys { - for (id, _) in &c.glyphs { - if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { - entry.insert(outline(c.script, *id).map_err(|e| match c.script { - ShapedScript::Arabic => arabic_error(e, &c.text), - ShapedScript::Hebrew => hebrew_error(e, &c.text), - ShapedScript::Thai => thai_error(e, &c.text), - })?); - } - } - } - // The names the page and this item already use: a new font never takes one of them. - let mut taken_names: HashSet> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect(); - let mut names = Vec::new(); - for chunk in keys.chunks(GLYPHS_PER_FONT) { - let font = type3_shaped(doc, chunk, &outlines); - // A name no font on the page has, so items never replace each other's fonts (object - // numbers alone don't do: a full save renumbers objects but keeps resource names). - let mut name = format!("PCAr{}", font.num); - let mut suffix = 0usize; - while taken_names.contains(name.as_bytes()) { - suffix = suffix.saturating_add(1); - name = format!("PCAr{}_{suffix}", font.num); - } - taken_names.insert(name.clone().into_bytes()); - fonts.set(name.clone().into_bytes(), Object::Ref(font)); - names.push(name); - } + let shaped = + ShapedFonts::new(shaped_text::clusters(laid.iter().map(|(_, _, _, pieces, _)| pieces.as_slice())), outline, taken, fonts, "PCAr", &mut |o| { + Object::Ref(doc.add(o)) + }) + .map_err(|(e, c)| match c.script { + ShapedScript::Arabic => arabic_error(e, &c.text), + ShapedScript::Hebrew => hebrew_error(e, &c.text), + ShapedScript::Thai => thai_error(e, &c.text), + })?; out.extend(format!("BT {}\n", t.fill_op()).bytes()); if t.char_spacing != 0.0 { out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); @@ -119,32 +84,17 @@ pub(super) fn draw_laid_shaped( let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl); let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; let mut pen = x; - let mut current: Option<&str> = None; + let mut current = None; for piece in pieces { match piece { Piece::Latin(s) => { out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes()); out.extend(literal(&win_ansi(s))); out.extend_from_slice(b" Tj\n"); - current = Some(latin.as_str()); + current = None; pen += latin_width(t, s) + tw * s.matches(' ').count() as f64; } - Piece::Shaped(clusters) => { - for c in clusters { - let Some(&k) = index.get(&cluster_key(c)) else { continue }; - let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue }; - // Codes start at 1; k % 240 + 1 is at most 240. - let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); - if current != Some(name.as_str()) { - out.extend(format!("/{name} {} Tf ", n(t.size)).bytes()); - current = Some(name.as_str()); - } - out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); - out.extend(literal(&[code])); - out.extend_from_slice(b" Tj\n"); - pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 }; - } - } + Piece::Shaped(clusters) => pen = shaped.show(out, clusters, pen, y, t.size, t.char_spacing, tw, &mut current), } } out.extend_from_slice(b"EMC\n"); @@ -153,104 +103,6 @@ pub(super) fn draw_laid_shaped( Ok(()) } -/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; -/// equal clusters come from the same font units). -type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64); - -fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> { - (c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) -} - -/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`. -fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef { - let mut charprocs = Dict::new(); - let mut differences = vec![Object::Int(1)]; - let mut widths = Vec::with_capacity(clusters.len()); - let mut bbox = [0.0f64; 4]; - let mut to_unicode = Vec::new(); - for (i, c) in clusters.iter().enumerate() { - let code = i + 1; - // Glyph space is 1000 units per em. - let mut paths = Vec::new(); - let mut ink: Option<[f64; 4]> = None; - for (id, [dx, dy]) in &c.glyphs { - let Some(g) = outlines.get(&(c.script, *id)) else { continue }; - for contour in &g.contours { - for (j, p) in contour.iter().enumerate() { - paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); - } - paths.extend_from_slice(b"h\n"); - let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; - ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); - } - } - // Rounded outwards so no ink is clipped. - let [x0, y0, x1, y1] = - ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); - bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; - let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); - if !paths.is_empty() { - proc.extend(paths); - proc.extend_from_slice(b"f\n"); - } - let glyph = format!("g{code:02X}"); - charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc))))); - differences.push(Object::name(&glyph)); - widths.push(Object::Real((c.advance * 1000.0).round())); - if !c.text.is_empty() { - let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); - to_unicode.push(format!("<{code:02X}> <{hex}>\n")); - } - } - let mut cmap = String::from( - "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", - ); - // A CMap block holds at most 100 entries. - for block in to_unicode.chunks(100) { - cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); - } - cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); - let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); - // The face's family when every glyph comes from one script; without the face, the script's - // name (as Arabic items were always named). - let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) { - Some(script) => script.font().map_or( - match script { - ShapedScript::Arabic => "Arabic", - ShapedScript::Hebrew => "Hebrew", - ShapedScript::Thai => "Thai", - }, - |f| f.family, - ), - None => "PdfCraft Shaped Text", - }; - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); - descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); - descriptor.set(b"Flags".to_vec(), Object::Int(4)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let mut encoding = Dict::new(); - encoding.set(b"Type".to_vec(), Object::name("Encoding")); - encoding.set(b"Differences".to_vec(), Object::Array(differences)); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type3")); - font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); - font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); - font.set( - b"FontMatrix".to_vec(), - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ); - font.set(b"FirstChar".to_vec(), Object::Int(1)); - font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); - font.set(b"Widths".to_vec(), Object::Array(widths)); - font.set(b"Encoding".to_vec(), Object::Dict(encoding)); - font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); - font.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); - doc.add(Object::Dict(font)) -} - /// Content and resources for an item; `view` maps display space to user space. `taken` holds the /// page's font names. `existing`: the item is being replaced, so a build without the Arabic or /// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to @@ -401,9 +253,9 @@ pub(crate) fn thai_glyph_resources( if shaped.glyphs.len() > 65_534 { return Err(EditError::Invalid("shaped row exceeds its geometry budget".into())); } - let mut outlines = HashMap::new(); + let mut outlines = std::collections::HashMap::new(); let mut clusters = Vec::new(); - let mut index = HashMap::new(); + let mut index = std::collections::HashMap::new(); let mut indices = Vec::new(); for glyph in &shaped.glyphs { let id = glyph.id.to_u32(); @@ -414,7 +266,12 @@ pub(crate) fn thai_glyph_resources( let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| { let at = clusters.len(); let width = outlines.get(&key).map_or(0.0, |g| g.width); - clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() }); + clusters.push(pdfcraft_fonts::ShapedCluster { + script: ShapedScript::Thai, + glyphs: vec![(id, [0.0, 0.0])], + advance: width, + text: glyph.text.clone(), + }); at }); indices.push(at); @@ -422,9 +279,9 @@ pub(crate) fn thai_glyph_resources( // Validate all outlines before adding resources. Type 3 character codes are single bytes; // shard long rows and return the appropriate resource/code for every positioned glyph. let mut names = Vec::new(); - for chunk in clusters.chunks(GLYPHS_PER_FONT) { + for chunk in clusters.chunks(shaped_text::GLYPHS_PER_FONT) { let refs: Vec<_> = chunk.iter().collect(); - let font = type3_shaped(doc, &refs, &outlines); + let font = shaped_text::add_type3_font(doc, &refs, &outlines); doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?; let mut name = format!("PCThai{}", font.num); while fonts.contains(name.as_bytes()) { @@ -436,8 +293,8 @@ pub(crate) fn thai_glyph_resources( indices .into_iter() .map(|at| { - let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; - let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; + let name = names.get(at / shaped_text::GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?; + let code = u8::try_from(at % shaped_text::GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?; Ok((name.clone(), vec![code])) }) .collect() diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 31a4dc409f..a6a55224d9 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -1,7 +1,6 @@ use super::types::{AddedImage, AddedText}; use super::{ - Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, - shape_thai, + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedScript, shape_arabic, shape_hebrew, shape_thai, }; pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -55,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; } } @@ -73,8 +77,11 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { out } -/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base. -/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation. +/// Thai wrapping: a row ends before a word that would overflow it, the words found by the +/// dictionary ([`crate::linebreak`]) inside runs without spaces and at the spaces between them. The +/// spaces where a row ends are dropped, as in the Latin wrapper. Only a word wider than the row is +/// broken inside, at shaped cluster boundaries, which never detach a tone or vowel mark from its +/// base. fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, EditError> { let (pieces, _) = shaped_layout(t, para, false)?; let mut atoms = Vec::new(); @@ -88,24 +95,88 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit })), } } - let mut lines = Vec::new(); - let (mut line, mut used) = (String::new(), 0.0); - for (text, advance) in atoms { - if !line.is_empty() && used + advance > width { - lines.push(std::mem::take(&mut line)); - used = 0.0; - } - line.push_str(&text); - used += advance; - } - lines.push(line); - Ok(lines) + Ok(wrap_words(thai_words(atoms), width)) } -pub(super) fn is_thai(c: char) -> bool { - matches!(u32::from(c), 0x0E00..=0x0E7F) +/// `words` (each a list of text and advance, with the spaces that follow it) laid into rows no +/// wider than `width`: a word that doesn't fit what is left of a row starts the next one, and only +/// a word wider than a whole row is split, between its clusters. +fn wrap_words(words: Vec>, width: f64) -> Vec { + let blank = |s: &str| s.chars().all(char::is_whitespace); + let mut lines: Vec = Vec::new(); + let (mut line, mut used) = (String::new(), 0.0); + let end_row = |lines: &mut Vec, line: &mut String| { + let done = std::mem::take(line); + lines.push(done.trim_end().to_string()); + }; + for word in words { + // The word's own width: the spaces after it may hang past the row's end. + let ink: f64 = word.iter().filter(|(s, _)| !blank(s)).map(|(_, w)| w).sum(); + // A row holding only the spaces that open the paragraph has nothing to end yet. + if !blank(&line) && used + ink > width { + end_row(&mut lines, &mut line); + used = 0.0; + } + // Too wide for what is left of the row, counting the spaces that open the paragraph. + let too_wide = blank(&line) && used + ink > width; + for (text, advance) in word { + if too_wide && !blank(&line) && !blank(&text) && used + advance > width { + end_row(&mut lines, &mut line); + used = 0.0; + } + // A row after a wrap does not begin with the spaces where the last one ended. + if line.is_empty() && !lines.is_empty() && blank(&text) { + continue; + } + line.push_str(&text); + used += advance; + } + } + lines.push(line); + lines } +/// The shaped clusters `atoms` (text and advance) grouped into words: a word starts after spaces, +/// or where [`crate::linebreak::breaks`] lets a row end inside a run without spaces, and keeps the +/// spaces that follow it. A break that falls inside a cluster is not one. +fn thai_words(atoms: Vec<(String, f64)>) -> Vec> { + let text: String = atoms.iter().map(|(s, _)| s.as_str()).collect(); + let mut cuts = Vec::new(); + let mut run_start: Option = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + match (c.is_whitespace(), run_start) { + (false, None) => { + cuts.push(at); + run_start = Some(at); + } + (true, Some(start)) => { + let run = text.get(start..at).unwrap_or_default(); + cuts.extend(crate::linebreak::breaks(run).into_iter().map(|b| start.saturating_add(b))); + run_start = None; + } + _ => {} + } + } + // `cuts` is in order: each run's start, then the breaks inside it, then the next run's start. + let mut words: Vec> = Vec::new(); + let mut cuts = cuts.into_iter().peekable(); + let mut offset = 0usize; + for atom in atoms { + while cuts.peek().is_some_and(|&c| c < offset) { + cuts.next(); + } + let starts_word = cuts.peek() == Some(&offset); + offset = offset.saturating_add(atom.0.len()); + match words.last_mut() { + Some(word) if !starts_word => word.push(atom), + _ => words.push(vec![atom]), + } + } + words +} + +pub(super) use pdfcraft_fonts::shaped_text::{Piece, is_thai}; + /// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the /// craft-fonts Arabic face. pub(super) fn is_arabic(c: char) -> bool { @@ -184,13 +255,6 @@ fn mirrored(c: char) -> char { } } -/// A piece of a line with shaped text, in drawing order. -pub(super) enum Piece { - /// Shown in the item's own standard font. - Latin(String), - Shaped(Vec), -} - /// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and /// its width at the item's size. `rtl` is its paragraph's direction. pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f64), EditError> { @@ -244,20 +308,13 @@ pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec }); } } - let width = pieces - .iter() - .map(|p| match p { - Piece::Latin(s) => latin_width(t, s), - // Each cluster is shown on its own, so the spacing follows each once. - Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::(), - }) - .sum(); + let width = pdfcraft_fonts::shaped_text::pieces_width(&pieces, t.size, t.char_spacing, |s, _| latin_width(t, s)); Ok((pieces, width)) } /// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing /// then reports why). -fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { +pub(crate) fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s)) } @@ -282,13 +339,7 @@ pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError { } pub(super) fn thai_error(e: GlyphError, text: &str) -> EditError { - EditError::Invalid(match e { - GlyphError::NoFont => { - "Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into() - } - GlyphError::Missing => format!("the Thai font can't show \"{text}\""), - GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(), - }) + EditError::Invalid(pdfcraft_fonts::shaped_text::thai_message(e, text)) } /// The box a text item occupies (height from its lines). @@ -357,3 +408,40 @@ fn copy_key(c: &Content) -> Content { pub fn text_width(t: &AddedText) -> f64 { t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) } + +#[cfg(test)] +mod thai_wrap_tests { + use super::{thai_words, wrap_words}; + + /// One atom per character, each `advance` wide: what shaping hands over, without a Thai face. + fn atoms(text: &str, advance: f64) -> Vec<(String, f64)> { + text.chars().map(|c| (c.to_string(), advance)).collect() + } + + fn texts(words: &[Vec<(String, f64)>]) -> Vec { + words.iter().map(|w| w.iter().map(|(s, _)| s.as_str()).collect()).collect() + } + + #[test] + fn thai_atoms_group_into_dictionary_words_with_their_spaces() { + assert_eq!(texts(&thai_words(atoms("น้ำประปาสะอาด", 1.0))), ["น้ำ", "ประปา", "สะอาด"]); + assert_eq!(texts(&thai_words(atoms("สวัสดี ครับ", 1.0))), ["สวัสดี ", "ครับ"]); + // A break the dictionary finds inside one shaped cluster is not a place to end a row. + assert_eq!(texts(&thai_words(vec![("น้ำประปา".into(), 8.0)])), ["น้ำประปา"]); + assert!(thai_words(Vec::new()).is_empty()); + } + + #[test] + fn words_wrap_whole_and_only_an_overlong_word_is_split() { + let words = || vec![vec![("ab".into(), 2.0), (" ".into(), 1.0)], vec![("cd".into(), 2.0)]]; + assert_eq!(wrap_words(words(), 10.0), ["ab cd"]); + // The space that ends a row hangs past it, and the next row doesn't begin with it. + assert_eq!(wrap_words(words(), 3.0), ["ab", "cd"]); + let long = vec![vec![("a".into(), 2.0), ("b".into(), 2.0), ("c".into(), 2.0)]]; + assert_eq!(wrap_words(long, 3.0), ["a", "b", "c"]); + // Spaces that open a paragraph stay, and a word too wide for the rest of their row is split. + let opening = vec![vec![(" ".into(), 1.0)], vec![("a".into(), 2.0), ("b".into(), 2.0)]]; + assert_eq!(wrap_words(opening, 3.0), [" a", "b"]); + assert_eq!(wrap_words(Vec::new(), 3.0), [""]); + } +} 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/apply.rs b/crates/edit/src/apply.rs index 0d0213904b..8b52c81c76 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -1,9 +1,12 @@ +use pdfcraft_fonts::shaped_text::{is_thai, thai_refusal, thai_width}; + use super::marks::{ - Background, Context, HeaderFooter, MarkKind, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, shrink_matrix, + Background, Context, HeaderFooter, MarkKind, MarkText, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, + shrink_matrix, }; use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, - place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, + place_inside_wrapper, stream_tag, undrawable, wrapper_role, }; use super::{Document, EditError, Object, added, backdrop, helvetica_width}; @@ -31,6 +34,9 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo if hf.text.iter().all(|t| t.trim().is_empty()) { return Err(EditError::Invalid("type the header or footer text first".into())); } + if let Some(why) = hf.text.iter().find_map(|t| thai_refusal(t)) { + return Err(EditError::Invalid(why)); + } if let Some(c) = hf.first_undrawable() { return Err(undrawable(c)); } @@ -73,12 +79,23 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo } let all = page_list(doc); let count = all.len(); + let number_of = |k: usize| { + u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into())) + }; + let bates_of = |k: usize| bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into())); + // Every Thai line drawn on any page (a Bates prefix may join a template's Thai), for its fonts. + let mut thai_lines = std::collections::BTreeSet::new(); + if hf.text.iter().any(|t| t.chars().any(is_thai)) { + for k in 0..pages.len() { + let (number, bates) = (number_of(k)?, bates_of(k)?); + thai_lines.extend(hf.text.iter().flat_map(|t| expand(t, number, count, bates, cx).lines().map(str::to_owned).collect::>())); + } + } + let thai = MarkText::new(doc, &all, pages, thai_lines.iter().map(String::as_str))?; for (k, &i) in pages.iter().enumerate() { let page = &all[i]; let (w, h) = page.display_size(doc); - let number = - u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; - let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; + let (number, bates) = (number_of(k)?, bates_of(k)?); let size = hf.font_size; let [top, bottom, left, right] = hf.margins; // The page resource the face is added under: the usual name, unless the page already @@ -91,6 +108,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo let base = hf.font.base_font(false, false); let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base); let mut body: Vec = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); + let mut current = None; let mut underlines = String::new(); for (slot, template) in hf.text.iter().enumerate() { if template.trim().is_empty() { @@ -101,7 +119,8 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo let line_h = size * 1.2; for (li, line) in lines.iter().enumerate() { // Centred and right-aligned text is placed by its width in the chosen face. - let tw = hf.font.width(line, size, false, false); + let face = |s: &str, size: f64| hf.font.width(s, size, false, false); + let tw = thai_width(line, size, face); let x = match slot % 3 { 0 => left, 1 => (w - tw) / 2.0, @@ -109,7 +128,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo }; // Headers hang below the top margin; footers sit on the bottom margin. let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h }; - body.extend(text_op(x, y, line)); + thai.show(&mut body, line, x, y, size, &resource, |s| face(s, size), &mut current); if hf.underline { underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4)))); } @@ -126,6 +145,7 @@ fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFo if !page_fonts(doc, page).contains(resource.as_bytes()) { add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?; } + thai.add_to(doc, &page_list(doc)[i])?; let page = &page_list(doc)[i]; place(doc, page, MarkKind::HeaderFooter, content, false)?; if let Some(Some(m)) = shrinks.get(k) { @@ -150,6 +170,9 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r if text.is_empty() && wm.source.is_none() { return Err(EditError::Invalid("type the watermark text first".into())); } + if let Some(why) = wm.source.is_none().then(|| thai_refusal(text)).flatten() { + return Err(EditError::Invalid(why)); + } if let Some(c) = wm.first_undrawable() { return Err(undrawable(c)); } @@ -171,6 +194,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r } let opacity = wm.opacity.clamp(0.0, 1.0); let lines: Vec<&str> = text.lines().collect(); + let thai = if wm.source.is_none() { MarkText::new(doc, &page_list(doc), pages, lines.iter().copied())? } else { MarkText::default() }; for &i in pages { let page = page_list(doc)[i].clone(); let (w, h) = page.display_size(doc); @@ -185,7 +209,7 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?; continue; } - let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01); + let widest = lines.iter().map(|l| thai_width(l, 1.0, helvetica_width)).fold(0.0, f64::max).max(0.01); let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) }; let (s, c) = wm.rotation.to_radians().sin_cos(); let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]); @@ -207,13 +231,24 @@ fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, r ); let line_h = size * 1.15; let total = line_h * (lines.len() as f64 - 1.0); + let mut current = None; for (li, line) in lines.iter().enumerate() { - let tw = helvetica_width(line, size); - content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line)); + let tw = thai_width(line, size, helvetica_width); + thai.show( + &mut content, + line, + -tw / 2.0, + total / 2.0 - li as f64 * line_h - size * 0.35, + size, + "PCHelv", + |s| helvetica_width(s, size), + &mut current, + ); } content.extend_from_slice(b"ET\n"); content.extend_from_slice(END.as_bytes()); add_resources(doc, &page, Some(opacity), None)?; + thai.add_to(doc, &page_list(doc)[i])?; let page = &page_list(doc)[i]; place(doc, page, MarkKind::Watermark, content, wm.behind)?; } diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index ef86d6c67a..c54b062af6 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -183,6 +183,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())?; @@ -322,10 +335,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result doc.set(r, Object::Array(kept)), None => doc.update_dict(page.obj, |d| { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 8ac77c9deb..c4a39bf689 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1884,6 +1884,24 @@ fn crop_image_impl( Ok(()) } +/// The `Do` of each image `indexes` (from [`page_images`]) on `page`, as an index among the +/// page's joined operators: what Copy takes of them. +pub(crate) fn image_ops(doc: &Document, page: usize, indexes: &[usize]) -> Result, EditError> { + if indexes.is_empty() { + return Ok(Vec::new()); + } + let images = page_images(doc, page)?; + indexes + .iter() + .map(|i| { + images + .get(*i) + .map(|image| image.op) + .ok_or_else(|| EditError::Invalid("a selected image no longer exists; select the objects again".into())) + }) + .collect() +} + /// Plan all transforms before changing a stream; indexes refer to the original inventory. /// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index da51839047..dda38c7c08 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -23,7 +23,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; -use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; +use pdfcraft_fonts::helvetica_width; #[derive(Debug, thiserror::Error, PartialEq)] pub enum EditError { @@ -46,7 +46,9 @@ pub use background::page_background; mod rules; pub use rules::page_rules; pub mod objects; -pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; +pub use objects::{ + CopiedObjects, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, copy_objects, editable_objects, move_objects, paste_objects, +}; mod flatten; pub use flatten::{flatten, flatten_fill_sign}; diff --git a/crates/edit/src/linebreak.rs b/crates/edit/src/linebreak.rs index a4682679f8..cf044d9e5c 100644 --- a/crates/edit/src/linebreak.rs +++ b/crates/edit/src/linebreak.rs @@ -6,8 +6,56 @@ //! and quotes, currency signs before an amount): the line-start and line-end prohibitions of //! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR. //! -//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose -//! words need a dictionary to find. +//! Thai is also written without spaces between words, but a row may not end between any two of its +//! characters: its words need a dictionary to find. They come from ICU4X's dictionary line +//! segmenter (UAX #14 with dictionary breaking for class SA), kept only where a Thai character is on +//! either side. A row never begins with a following vowel, a tone or other mark, Mai Yamok (ๆ) or +//! Paiyannoi (ฯ), nor ends with a leading vowel (เ แ โ ใ ไ), whatever the dictionary says. +//! +//! Korean is written with spaces between words and keeps breaking only there. + +use std::sync::LazyLock; + +use icu_segmenter::options::LineBreakOptions; +use icu_segmenter::{LineSegmenter, LineSegmenterBorrowed}; + +/// The dictionary segmenter, built once: its data is compiled in, so building it cannot fail. +static SEGMENTER: LazyLock> = LazyLock::new(|| LineSegmenter::new_dictionary(LineBreakOptions::default())); + +/// Letters, vowels, marks, digits and signs of the Thai block. +fn thai(c: char) -> bool { + matches!(c, '\u{0e01}'..='\u{0e5b}') +} + +/// Thai that belongs to the character before it: Paiyannoi, the following vowels, Lakkhangyao, +/// Mai Yamok and the tone and other marks. +fn thai_no_start(c: char) -> bool { + matches!(c, '\u{0e2f}' | '\u{0e30}'..='\u{0e3a}' | '\u{0e45}'..='\u{0e4e}') +} + +/// The leading vowels, written before the consonant they follow in speech. +fn thai_no_end(c: char) -> bool { + matches!(c, '\u{0e40}'..='\u{0e44}') +} + +/// Byte offsets where the dictionary lets a row of Thai end inside `word`. +fn thai_breaks(word: &str) -> Vec { + if !word.chars().any(thai) { + return Vec::new(); + } + SEGMENTER + .segment_str(word) + .filter(|&at| { + let before = word.get(..at).and_then(|s| s.chars().next_back()); + let after = word.get(at..).and_then(|s| s.chars().next()); + match (before, after) { + (Some(b), Some(a)) => (thai(b) || thai(a)) && !thai_no_start(a) && !thai_no_end(b) && !no_start(a) && !no_end(b), + // The start and end of the word are not breaks inside it. + _ => false, + } + }) + .collect() +} /// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID). fn ideographic(c: char) -> bool { @@ -77,29 +125,49 @@ fn no_end(c: char) -> bool { ) } -/// Whether a row may end between `before` and `after`, neither of them a space. -pub(crate) fn between(before: char, after: char) -> bool { - (ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) +/// Whether a row may end between `before` and `after` in Chinese or Japanese, neither of them a +/// space. Thai needs the whole word ([`breaks`]). +fn between(before: char, after: char) -> bool { + (ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before) && !thai_no_start(after) && !thai_no_end(before) +} + +/// Byte offsets inside `word` (text without spaces) where a row may end, in order: never 0 or the +/// word's length, always on a character boundary. +pub(crate) fn breaks(word: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut prev: Option = None; + for (at, c) in word.char_indices() { + if let Some(before) = prev + && between(before, c) + { + out.push(at); + } + prev = Some(c); + } + out.extend(thai_breaks(word)); + out.sort_unstable(); + out.dedup(); + out } /// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing. pub(crate) fn pieces(word: &str) -> Vec<&str> { let mut out = Vec::new(); let mut start = 0; - let mut prev: Option = None; - for (at, c) in word.char_indices() { - if let Some(before) = prev - && between(before, c) - { - out.extend(word.get(start..at)); - start = at; - } - prev = Some(c); + for at in breaks(word) { + out.extend(word.get(start..at)); + start = at; } out.extend(word.get(start..).filter(|rest| !rest.is_empty())); out } +/// For each character of `word` (text without spaces), whether a row may end just before it. +pub(crate) fn breaks_before_chars(word: &str) -> Vec { + let cuts = breaks(word); + word.char_indices().map(|(at, _)| cuts.binary_search(&at).is_ok()).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -121,4 +189,64 @@ mod tests { assert_eq!(pieces(""), Vec::<&str>::new()); assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字')); } + + #[test] + fn thai_breaks_between_dictionary_words() { + assert_eq!(pieces("ภาษาไทยไม่มีช่องว่างระหว่างคำ"), ["ภาษา", "ไทย", "ไม่มี", "ช่อง", "ว่าง", "ระหว่าง", "คำ"]); + assert_eq!(pieces("น้ำประปาสะอาด"), ["น้ำ", "ประปา", "สะอาด"]); + assert_eq!(pieces("การประปาส่วนภูมิภาค"), ["การ", "ประปา", "ส่วน", "ภูมิภาค"]); + } + + #[test] + fn thai_vowels_marks_and_repetition_stay_with_the_word_before_them() { + // The dictionary alone would start a row with Mai Yamok, with the Sara Aa of a decomposed + // Sara Am, or with a closing bracket. + assert_eq!(pieces("เด็กๆชอบกินขนม"), ["เด็กๆ", "ชอบ", "กิน", "ขนม"]); + assert_eq!(pieces("ท\u{0e4d}\u{0e32}งาน"), ["ท\u{0e4d}\u{0e32}", "งาน"]); + assert_eq!(pieces("กรุงเทพฯเป็นเมืองหลวง"), ["กรุงเทพฯ", "เป็น", "เมือง", "หลวง"]); + assert_eq!(pieces("สวัสดีครับ(ทดสอบ)"), ["สวัสดี", "ครับ", "(ทดสอบ)"]); + } + + #[test] + fn thai_keeps_numbers_and_latin_whole() { + assert_eq!(pieces("ราคา100บาทต่อเดือน"), ["ราคา", "100บาท", "ต่อ", "เดือน"]); + assert_eq!(pieces("พ.ศ.2569"), ["พ.ศ.2569"]); + assert_eq!(pieces("PDF"), ["PDF"]); + assert_eq!(breaks("ไทย"), Vec::::new()); + } + + #[test] + fn breaks_before_chars_marks_the_first_character_of_each_later_piece() { + let flags = breaks_before_chars("น้ำประปา"); + let starts: Vec = flags.iter().enumerate().filter_map(|(i, &b)| b.then_some(i)).collect(); + // น ้ ำ | ป ร ะ ป า + assert_eq!(starts, [3]); + assert_eq!(breaks_before_chars(""), Vec::::new()); + } + + #[test] + fn pieces_of_arbitrary_thai_rejoin_to_the_input_and_never_start_with_a_mark() { + // A deterministic generator over Thai, Latin, digits and CJK, including mark-only runs and + // unusual orders that no dictionary word has. + let alphabet: Vec = ('\u{0e01}'..='\u{0e5b}').chain(['a', '1', '(', ')', '字', 'の', '.']).collect(); + let mut state: u64 = 0x2545_f491_4f6c_dd1d; + for _ in 0..2000 { + let mut word = String::new(); + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + let len = (state >> 59) as usize + 1; + for _ in 0..len { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + let pick = (state >> 33) as usize % alphabet.len(); + word.extend(alphabet.get(pick)); + } + let parts = pieces(&word); + assert_eq!(parts.concat(), word); + assert!(parts.iter().all(|p| !p.is_empty()), "{word:?} gave {parts:?}"); + for part in parts.iter().skip(1) { + let first = part.chars().next().unwrap_or(' '); + assert!(!thai_no_start(first) && !no_start(first), "{word:?} gave {parts:?}"); + } + assert_eq!(breaks_before_chars(&word).len(), word.chars().count()); + } + } } diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 344c76250f..6c88fb2bf0 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -1,4 +1,75 @@ -use super::{Document, EditError, Object, Rgb, added}; +use pdfcraft_fonts::shaped_text::{Piece, ShapedFonts, clusters, first_undrawable_in_line, is_thai, thai_message, thai_pieces}; + +use super::{Dict, Document, EditError, Object, Rgb, added}; + +/// The Thai of a header, footer or text watermark, drawn from the Thai face as Add text draws it: +/// Type 3 fonts added to the document once and to each page the mark goes on, beside the page +/// font that draws the rest. +#[derive(Default)] +pub(crate) struct MarkText { + shaped: ShapedFonts, + /// The fonts, by the resource names the marked pages give them. + fonts: Dict, +} + +impl MarkText { + /// The fonts for the Thai in `lines` (every line the mark draws, on any page), named so that + /// none of `pages` (of `all`) has the name already. Thai this build can't draw (it has no Thai + /// face, or the face lacks a character) is refused, saying what to do. + pub(crate) fn new<'a>( + doc: &mut Document, + all: &[pdfcraft_model::Page], + pages: &[usize], + lines: impl IntoIterator, + ) -> Result { + let thai = |line: &str| thai_pieces(line).map_err(|e| EditError::Invalid(thai_message(e, line))); + let pieces = lines.into_iter().filter(|l| l.chars().any(is_thai)).map(thai).collect::, _>>()?; + if pieces.is_empty() { + return Ok(Self::default()); + } + let mut taken = Dict::new(); + for page in pages.iter().filter_map(|&i| all.get(i)) { + for (name, font) in crate::place::page_fonts(doc, page).iter() { + taken.set(name.clone(), font.clone()); + } + } + let mut fonts = Dict::new(); + let shaped = + ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), pdfcraft_fonts::shaped_glyph, &taken, &mut fonts, "PCTh", &mut |o| { + Object::Ref(doc.add(o)) + }) + .map_err(|(e, c)| EditError::Invalid(thai_message(e, &c.text)))?; + Ok(Self { shaped, fonts }) + } + + /// Show `line` inside `BT … ET` from `x` on baseline `y` at `size`: its Thai from the fonts, + /// the rest in the page font `latin` (selected when `current` is `None`), `width` wide. + #[allow(clippy::too_many_arguments)] + pub(crate) fn show( + &self, + out: &mut Vec, + line: &str, + x: f64, + y: f64, + size: f64, + latin: &str, + width: impl Fn(&str) -> f64, + current: &mut Option, + ) { + let plain = || vec![Piece::Latin(line.into())]; + // `new` shaped every line drawn, so this shapes. + let pieces = if self.fonts.is_empty() || !line.chars().any(is_thai) { plain() } else { thai_pieces(line).unwrap_or_else(|_| plain()) }; + self.shaped.show_line(out, &pieces, x, y, size, latin, pdfcraft_fonts::win_ansi, width, current); + } + + /// Add the fonts to `page`'s own resources. + pub(crate) fn add_to(&self, doc: &mut Document, page: &pdfcraft_model::Page) -> Result<(), EditError> { + if self.fonts.is_empty() { + return Ok(()); + } + crate::place::add_page_fonts(doc, page, &self.fonts) + } +} /// The kinds of page marks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -61,13 +132,13 @@ impl Default for HeaderFooter { } impl HeaderFooter { - /// The first character [`add_header_footer`] would refuse, because the standard fonts can't - /// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the - /// template), so checking the templates, split as `expand`'s output is, covers every page - /// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview - /// never offers text that applying refuses (#2013). + /// The first character [`add_header_footer`] would refuse, because neither the standard + /// fonts nor (for Thai) the Thai face can draw it (#125). Tokens only add ASCII (Bates + /// prefixes and suffixes are part of the template), so checking the templates, split as + /// `expand`'s output is, covers every page (and a `\r` that would be drawn is refused too). + /// The dialog asks this too, so its preview never offers text that applying refuses (#2013). pub fn first_undrawable(&self) -> Option { - self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi) + self.text.iter().flat_map(|t| t.lines()).find_map(first_undrawable_in_line) } /// The room the header and the footer take at the top and the bottom of a page, in points: @@ -139,12 +210,13 @@ pub struct Watermark { } impl Watermark { - /// The first character of a text watermark [`add_watermark`] would refuse, because the - /// standard fonts can't draw it (#125). A picture watermark draws no text. + /// The first character of a text watermark [`add_watermark`] would refuse, because neither + /// the standard fonts nor (for Thai) the Thai face can draw it (#125). A picture watermark + /// draws no text. pub fn first_undrawable(&self) -> Option { match self.source { Some(_) => None, - None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi), + None => self.text.trim().lines().find_map(first_undrawable_in_line), } } } diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs index 6a64e3cfce..5b1a36caef 100644 --- a/crates/edit/src/objects.rs +++ b/crates/edit/src/objects.rs @@ -9,6 +9,9 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::text::ContentStream; use crate::{Content, EditError}; +mod clip; +pub use clip::{CopiedObjects, copy_objects, paste_objects}; + pub const MAX_MOVE_OBJECTS: usize = 1_000; const MAX_PAGE_OBJECTS: usize = 10_000; const MAX_COORDINATE: f64 = 1e9; diff --git a/crates/edit/src/objects/clip.rs b/crates/edit/src/objects/clip.rs new file mode 100644 index 0000000000..4402667b76 --- /dev/null +++ b/crates/edit/src/objects/clip.rs @@ -0,0 +1,283 @@ +//! Edit a PDF ▸ Copy, Cut and Paste of objects selected together (#5989): paragraphs, images, +//! Form artwork and added content, pasted as page content that draws the same. +//! +//! A copy is a content stream of its own that draws the objects and nothing else, in the page's +//! user space, with the resources it names. It replays every state operator of the page up to +//! the last object (transformations, clips, colours, ExtGStates, text state), so each object is +//! drawn in the state it had, and leaves out what paints anything else: other text, images, +//! shadings and paths (a path that clips is kept, without its paint). Each copied text-showing +//! operator is placed by its own text matrix, which is where its glyphs start, so the text +//! positioning between operators is left out. Marked content is left out too: a paste is +//! untagged and in no layer. +//! +//! A paste draws that stream on top of the page through Ungroup's merge ([`ungroup_ops`]): the +//! copy is read as Form artwork whose resources join the page's under names that don't clash, +//! moved by the offset, with the patterns it paints moved along. Its resources must already be +//! the target document's: the engine copies them over first when the copy came from another +//! document. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use pdfcraft_content::{Matrix, Op, serialize_ops}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use super::{EditableObject, MAX_COORDINATE, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, checked_streams, editable_objects, valid_rect}; +use crate::EditError; +use crate::ungroup::{CATEGORIES, DEFAULT_SPACES, MAX_FORM_BYTES, Ungrouped, page_blend_space, resource_operand, ungroup_ops}; + +/// How deep the copied graphics state may nest (`q`), as the page's own may. +const MAX_NESTING: usize = 1024; + +/// Objects copied from one page: what [`paste_objects`] draws again. +#[derive(Clone, Debug, PartialEq)] +pub struct CopiedObjects { + /// A content stream that draws the objects alone, in the source page's user space: its `q` + /// and `Q` balance and its text objects close. + pub content: Vec, + /// The resources `content` names, as the source page has them: references are into the + /// document the copy came from. + pub resources: Dict, + /// The union of the objects' boxes (user space of the source page). + pub rect: [f64; 4], + /// The objects as [`editable_objects`] listed them, in inventory order. + pub objects: Vec, +} + +impl CopiedObjects { + /// The text of the copied paragraphs and text boxes, one per line: what the system clipboard + /// gets for other applications. + pub fn text(&self) -> String { + self.objects.iter().filter_map(|o| o.text.as_deref()).filter(|t| !t.is_empty()).collect::>().join("\n") + } +} + +/// Copy `targets` (references from one [`editable_objects`] inventory of `page`). The document +/// is not changed. +pub fn copy_objects(doc: &Document, page: usize, targets: &[ObjectTarget]) -> Result { + if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS { + return Err(EditError::Invalid("select between 1 and 1000 objects to copy".into())); + } + let mut inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect(); + let mut objects = Vec::with_capacity(targets.len()); + for target in targets.iter().collect::>() { + objects + .push(inventory.remove(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?); + } + if objects.len() != targets.len() { + return Err(EditError::Invalid("an object was selected more than once".into())); + } + let indexes = |kind| objects.iter().filter(|o| o.target.kind == kind).map(|o| o.target.index).collect::>(); + let streams = checked_streams(doc, page)?; + let pieces = crate::text::joined(&streams); + let ops = pieces.parse().ops; + let mut keep = crate::text::paragraph_ops(doc, page, &indexes(ObjectKind::Text), false)?; + keep.extend(crate::images::image_ops(doc, page, &indexes(ObjectKind::Image))?); + // Added content owns its stream: everything that stream paints is the item. + let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect(); + let owned = indexes(ObjectKind::Added) + .into_iter() + .map(|i| { + added.get(i).map(|a| a.obj).ok_or_else(|| EditError::Invalid("a selected added item no longer exists; select the objects again".into())) + }) + .collect::, _>>()?; + if !owned.is_empty() { + for (i, op) in ops.iter().enumerate() { + let stream = streams.get(pieces.pieces_of(op).0).and_then(|s| s.entry.as_ref()); + if paints(op) && stream.is_some_and(|r| owned.contains(&r)) { + keep.insert(i); + } + } + } + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let shows = crate::text::show_starts(doc, &ops, &res)?; + let drawn = replay(&ops, &keep, &shows)?; + let rect = objects.iter().map(|o| o.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap_or_default(); + Ok(CopiedObjects { resources: used_resources(doc, &res, &drawn), content: serialize_ops(&drawn), rect, objects }) +} + +/// Whether `op` paints (rather than sets state or builds a path). +fn paints(op: &Op) -> bool { + matches!(op.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI") +} + +/// The page's operators up to the last of `keep`, painting only `keep` (see the module docs). +/// `shows`: where each text-showing operator starts ([`crate::text::show_starts`]). +fn replay(ops: &[Op], keep: &BTreeSet, shows: &HashMap) -> Result, EditError> { + let Some(&last) = keep.last() else { return Err(EditError::Invalid("the selected objects draw nothing to copy".into())) }; + let mut out = Vec::new(); + let (mut depth, mut in_text) = (0usize, false); + let (mut path, mut clips) = (Vec::new(), false); + for (i, op) in ops.iter().enumerate().take(last.saturating_add(1)) { + let kept = keep.contains(&i); + match op.op.as_slice() { + b"q" => { + if depth >= MAX_NESTING { + return Err(EditError::Invalid("the page graphics state is nested too deeply".into())); + } + depth += 1; + out.push(op.clone()); + } + // An unmatched `Q` restores nothing; the renderer ignores it too. + b"Q" if depth > 0 => { + depth -= 1; + out.push(op.clone()); + } + b"BT" if !in_text => { + in_text = true; + out.push(op.clone()); + } + b"ET" if in_text => { + in_text = false; + out.push(op.clone()); + } + b"Q" | b"BT" | b"ET" => {} + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path.push(op.clone()), + b"W" | b"W*" => { + clips = true; + path.push(op.clone()); + } + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if kept || clips { + out.append(&mut path); + out.push(if kept { op.clone() } else { Op::new("n", Vec::new()) }); + } + path.clear(); + clips = false; + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + // `"` sets the spacing for what follows, shown or not. + if op.is("\"") { + for (name, value) in ["Tw", "Tc"].into_iter().zip(op.operands.iter().take(2)) { + if value.as_f64().is_some() { + out.push(Op::new(name, vec![value.clone()])); + } + } + } + match shows.get(&i) { + Some((tm, _)) if kept => { + out.push(Op::new("Tm", tm.0.map(Object::Real).to_vec())); + out.push(if op.is("TJ") { op.clone() } else { Op::new("Tj", op.operands.last().cloned().into_iter().collect()) }); + } + // Its glyphs clip what follows, which leaving them out would not. + Some((_, mode)) if *mode >= 4 => { + return Err(EditError::Invalid( + "text used as a clipping path is drawn before the selected objects, so they can't be copied".into(), + )); + } + _ => {} + } + } + // Each copied show sets its own text matrix; `TD` also sets the leading. + b"TD" => { + if let Some([_, y]) = op.nums::<2>() { + out.push(Op::new("TL", vec![Object::Real(-y)])); + } + } + b"Td" | b"Tm" | b"T*" | b"BDC" | b"BMC" | b"EMC" | b"MP" | b"DP" => {} + b"Do" | b"sh" | b"BI" => { + if kept { + out.push(op.clone()); + } + } + _ => out.push(op.clone()), + } + } + if in_text { + out.push(Op::new("ET", Vec::new())); + } + out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth)); + Ok(out) +} + +/// The entries of the page's resources `res` that `ops` name, and the default colour spaces, +/// which remap the device colours they paint (ISO 32000-2 §8.6.5.6). +fn used_resources(doc: &Document, res: &Dict, ops: &[Op]) -> Dict { + let mut used: BTreeMap<&[u8], BTreeSet<&[u8]>> = BTreeMap::new(); + for op in ops { + if let Ok(Some((cat, i))) = resource_operand(op) + && let Some(Object::Name(name)) = op.operands.get(i) + { + used.entry(cat).or_default().insert(name.as_slice()); + } + } + let mut out = Dict::new(); + for cat in CATEGORIES { + let Some(all) = res.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) else { continue }; + let mut mine = Dict::new(); + for (key, value) in all.iter() { + if used.get(cat).is_some_and(|u| u.contains(key.as_slice())) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&key.as_slice())) { + mine.set(key.clone(), value.clone()); + } + } + if !mine.is_empty() { + out.set(cat.to_vec(), Object::Dict(mine)); + } + } + out +} + +/// Paste `copied` on top of `page`, moved by `offset` (user space), as one change: its content +/// and the page's resources merged with its own. Its resources must be this document's. +pub fn paste_objects(doc: &mut Document, page: usize, copied: &CopiedObjects, offset: [f64; 2]) -> Result<(), EditError> { + let r = copied.rect; + if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE) + || !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]) + { + return Err(EditError::Invalid("the paste position is out of range".into())); + } + let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?; + crate::check_on_top(doc, &p)?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let name = (1..=xobjects.len().saturating_add(1)) + .map(|n| format!("PCPaste{n}").into_bytes()) + .find(|n| xobjects.get(n).is_none()) + .ok_or_else(|| EditError::Invalid("the page has too many resources".into()))?; + // The copy as Form artwork, to draw inline as Ungroup does: its resources merge into the + // page's, and patterns move with it. The form itself is never added to the document. + let number = |v: f64| Object::Real(v); + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + // Nothing of the copy is clipped: its glyphs and strokes may reach past its objects' boxes. + form.set(b"BBox".to_vec(), Object::Array([-MAX_COORDINATE, -MAX_COORDINATE, MAX_COORDINATE, MAX_COORDINATE].map(number).to_vec())); + form.set(b"Matrix".to_vec(), Object::Array(Matrix::translate(offset[0], offset[1]).0.map(number).to_vec())); + form.set(b"Resources".to_vec(), Object::Dict(copied.resources.clone())); + xobjects.set(name.clone(), Object::Stream(Stream::from_raw(form, copied.content.clone()))); + let mut with_copy = res; + with_copy.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let space = page_blend_space(doc, &p.dict); + // Drawn by a `Do` of its own: not translucent, and in no path or text object. + let draw = [Op::new("Do", vec![Object::Name(name.clone())])]; + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_FORM_BYTES, 0); + let Ungrouped { ops, resources: mut merged, patterns, .. } = + ungroup_ops(doc, &with_copy, &draw, 0, space.as_ref(), Matrix::translate(offset[0], offset[1]), &mut budget).map_err(|r| match r.error { + EditError::Invalid(why) => { + EditError::Invalid(why.replacen("this artwork can't be ungrouped", "the copied objects can't be pasted here", 1)) + } + other => other, + })?; + let mut work = doc.clone(); + if let Some(mut x) = merged.get(b"XObject").and_then(Object::as_dict).cloned() { + x.remove(&name); + merged.set(b"XObject".to_vec(), Object::Dict(x)); + } + if !patterns.is_empty() { + let mut table = merged.get(b"Pattern").map(|x| work.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + for (key, pattern) in patterns { + table.set(key, Object::Ref(work.add(pattern))); + } + merged.set(b"Pattern".to_vec(), Object::Dict(table)); + } + work.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(merged)))?; + crate::place_tagged(&mut work, &p, "Pasted", serialize_ops(&ops), false)?; + // Keep the page's objects selectable together, as a move does. + editable_objects(&work, page)?; + *doc = work; + Ok(()) +} + +#[cfg(test)] +#[path = "clip_tests.rs"] +mod tests; diff --git a/crates/edit/src/objects/clip_tests.rs b/crates/edit/src/objects/clip_tests.rs new file mode 100644 index 0000000000..980bb9ba4c --- /dev/null +++ b/crates/edit/src/objects/clip_tests.rs @@ -0,0 +1,228 @@ +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions, write_incremental}; + +use super::*; + +/// A document of `objs` (object 1 the catalog), numbered in order. +fn pdf(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() { + 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 {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// Two 300 × 300 pages. The first draws `source` with Helvetica as /F1, a 4 × 2 RGB image /Im0, +/// a half-opaque ExtGState /GS0 and Courier as /F2; the second draws `target` with Courier +/// under the same name /F1, so a paste from the first must rename the font it brings. +fn two_pages(source: &str, target: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R /F2 8 0 R >> /XObject << /Im0 9 0 R >> /ExtGState << /GS0 << /ca 0.5 /CA 0.5 >> >> >> >>" + .into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(), + stream(source), + stream(target), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 24 >>\nstream\n\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{7f}@@@\u{0}\u{0}\u{0}\nendstream".into(), + ]) +} + +/// What the page state, the copied paragraph and picture and the objects left out are drawn with. +const SOURCE: &str = "q 1 0 0 1 10 10 cm 0 0 270 270 re W n 0 0 1 rg /GS0 gs \ + BT /F2 10 Tf 200 20 Td (Skip) Tj ET \ + BT /F1 24 Tf 1.5 Tc 20 170 Td (Copied) Tj 0 -30 Td (Too) Tj ET \ + q 1 0 0 rg 150 230 100 40 re f Q \ + q 80 0 0 40 20 60 cm /Im0 Do Q Q \ + BT /F1 12 Tf 200 250 Td (After) Tj ET"; + +fn target(doc: &Document, page: usize, want: impl Fn(&EditableObject) -> bool) -> ObjectTarget { + editable_objects(doc, page).unwrap().into_iter().find(|o| want(o)).unwrap().target +} + +fn render(doc: &Document, page: usize) -> pdfcraft_render::RenderedPage { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + renderer.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }) +} + +/// The pixels of `page` in user-space box `r` (a page 300 high at scale 1), shifted by `by`. +fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4], by: [f64; 2]) -> Vec { + let (x0, x1) = ((r[0] + by[0]) as u32, (r[2] + by[0]) as u32); + let (y0, y1) = ((300.0 - r[3] - by[1]) as u32, (300.0 - r[1] - by[1]) as u32); + let mut out = Vec::new(); + for y in y0..y1 { + for x in x0..x1 { + let i = ((y * page.width + x) * 4) as usize; + out.extend_from_slice(&page.rgba[i..i + 4]); + } + } + out +} + +/// The paragraph "Copied" / "Too" (one or two of them, as the lines group) and the picture. +fn copied_paragraph_and_picture(doc: &Document) -> CopiedObjects { + let targets: Vec<_> = editable_objects(doc, 0) + .unwrap() + .into_iter() + .filter(|o| o.target.kind == ObjectKind::Image || o.text.as_deref().is_some_and(|t| t.contains("Copied") || t.contains("Too"))) + .map(|o| o.target) + .collect(); + copy_objects(doc, 0, &targets).unwrap() +} + +#[test] +fn a_copy_takes_the_selected_objects_their_state_and_only_the_resources_they_use() { + let doc = two_pages(SOURCE, ""); + let copied = copied_paragraph_and_picture(&doc); + let content = String::from_utf8_lossy(&copied.content).into_owned(); + assert!(content.contains("(Copied)") && content.contains("(Too)") && content.contains("/Im0 Do"), "{content}"); + // The page's clip and its colour and transparency still apply; nothing else is painted. + assert!(content.contains("W\nn") && content.contains("/GS0 gs") && content.contains("rg"), "{content}"); + assert!(!content.contains("Skip") && !content.contains("After") && !content.contains("\nf\n"), "{content}"); + let names = |cat: &[u8]| copied.resources.get(cat).and_then(Object::as_dict).map(|d| d.iter().map(|(k, _)| k.clone()).collect::>()); + assert_eq!(names(b"Font"), Some(vec![b"F1".to_vec(), b"F2".to_vec()]), "the font of the skipped text stays selected for what follows"); + assert_eq!(names(b"XObject"), Some(vec![b"Im0".to_vec()])); + assert_eq!(names(b"ExtGState"), Some(vec![b"GS0".to_vec()])); + assert!(copied.text().contains("Copied") && copied.text().contains("Too") && !copied.text().contains("Skip"), "{}", copied.text()); + assert_eq!(pdfcraft_content::parse(&copied.content).ops.iter().filter(|o| o.is("q")).count(), 3, "balanced: {content}"); +} + +#[test] +fn pasted_objects_draw_the_same_on_another_page_under_renamed_resources() { + let mut doc = two_pages(SOURCE, "BT /F1 10 Tf 5 5 Td (x) Tj ET"); + let copied = copied_paragraph_and_picture(&doc); + paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap(); + let (source, pasted) = (render(&doc, 0), render(&doc, 1)); + let area = [copied.rect[0] - 4.0, copied.rect[1] - 4.0, copied.rect[2] + 4.0, copied.rect[3] + 4.0]; + let (before, after) = (pixels(&source, area, [0.0, 0.0]), pixels(&pasted, area, [0.0, 0.0])); + assert!(before.chunks(4).any(|p| p != [255, 255, 255, 255]), "the copied objects are drawn"); + assert_eq!(before, after, "the paste draws the same pixels"); + let red = pixels(&pasted, [160.0, 240.0, 260.0, 280.0], [0.0, 0.0]); + assert!(red.chunks(4).all(|p| p == [255, 255, 255, 255]), "what wasn't selected isn't pasted"); + // Its own /F1 (Courier) is left alone; the copy's Helvetica comes under another name. + let fonts = + pdfcraft_model::pages(&doc)[1].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap().as_dict().unwrap().get(b"Font").cloned().unwrap(); + let fonts = doc.resolve(&fonts).as_dict().cloned().unwrap(); + assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))); + assert!(fonts.iter().any(|(k, v)| k != b"F1" && *v == Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))), "{fonts:?}"); + let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect(); + assert!(texts.iter().any(|t| t.contains("Copied")) && !texts.iter().any(|t| t.contains("Skip")), "{texts:?}"); + assert_eq!(crate::images::page_images(&doc, 1).unwrap().len(), 1); +} + +#[test] +fn a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same() { + let mut doc = two_pages(SOURCE, ""); + let copied = copied_paragraph_and_picture(&doc); + let before = render(&doc, 0); + paste_objects(&mut doc, 0, &copied, [150.0, 0.0]).unwrap(); + let after = render(&doc, 0); + let area = [copied.rect[0] - 2.0, copied.rect[1] - 2.0, copied.rect[2] + 2.0, copied.rect[3] + 2.0]; + assert!(area[2] + 150.0 < 300.0, "{area:?}"); + // The copy (and the clip it is drawn in) lands 150 pt to the right, the original stays. + // Antialiasing elsewhere on the page may round an edge pixel one step differently. + let (moved, original) = (pixels(&after, area, [150.0, 0.0]), pixels(&before, area, [0.0, 0.0])); + assert_eq!(moved.len(), original.len()); + assert!(original.iter().any(|v| *v != 255), "the copied objects are drawn"); + assert!(moved.iter().zip(&original).all(|(a, b)| a.abs_diff(*b) <= 2), "the paste draws the same pixels, moved"); + assert_eq!(pixels(&after, area, [0.0, 0.0]), original); + // Within the document and the page, the resources are the page's own, under their names. + let res = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap(); + let fonts = res.as_dict().unwrap().get(b"Font").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(fonts.len(), 2, "{fonts:?}"); + assert_eq!(editable_objects(&doc, 0).unwrap().iter().filter(|o| o.target.kind == ObjectKind::Image).count(), 2); +} + +/// Artwork without resources of its own takes the names of the page drawing it. Pasted onto a +/// page whose names differ, artwork that names nothing still draws the same; artwork that names +/// a font would find another one, so the paste is refused. +#[test] +fn artwork_inheriting_resources_pastes_only_when_it_names_none() { + for (art, pastes) in [("0 0 1 rg 0 0 60 30 re f", true), ("BT /F1 9 Tf (n) Tj ET", false)] { + let mut doc = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Art 8 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 250 Td (Title) Tj ET q 1 0 0 1 20 100 cm /Art Do Q"), + stream(""), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 60 30] /Length {} >>\nstream\n{art}\nendstream", art.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(), + ]); + let all: Vec<_> = editable_objects(&doc, 0).unwrap().into_iter().map(|o| o.target).collect(); + let copied = copy_objects(&doc, 0, &all).unwrap(); + let pasted = paste_objects(&mut doc, 1, &copied, [0.0, 0.0]); + assert_eq!(pasted.is_ok(), pastes, "{art}: {pasted:?}"); + if let Err(EditError::Invalid(why)) = pasted { + assert!(why.starts_with("the copied objects can't be pasted here") && why.contains("uses its resources"), "{why}"); + } + } +} + +#[test] +fn added_content_is_copied_with_everything_its_stream_paints() { + let mut doc = two_pages(SOURCE, ""); + let item = crate::Content::Text(crate::AddedText { + rect: [20.0, 20.0, 200.0, 60.0], + text: "Added box".into(), + size: 12.0, + ..crate::AddedText::default() + }); + crate::add_content(&mut doc, 0, &item).unwrap(); + let added = target(&doc, 0, |o| o.target.kind == ObjectKind::Added); + let copied = copy_objects(&doc, 0, &[added]).unwrap(); + assert_eq!(copied.text(), "Added box"); + paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap(); + let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect(); + assert_eq!(texts, ["Added box"], "pasted as the page's own text"); + assert!(crate::list_added(&doc).iter().all(|a| a.page == 0)); +} + +#[test] +fn copying_and_pasting_refuse_what_they_cannot_do_and_change_nothing() { + let doc = two_pages(SOURCE, ""); + let text = target(&doc, 0, |o| o.text.as_deref().is_some_and(|t| t.contains("Copied"))); + assert!(copy_objects(&doc, 0, &[]).is_err()); + assert!(copy_objects(&doc, 0, &[text, text]).is_err(), "selected twice"); + assert!(copy_objects(&doc, 0, &[ObjectTarget { kind: ObjectKind::Image, index: 9 }]).is_err(), "stale"); + assert!(copy_objects(&doc, 5, &[text]).is_err(), "no such page"); + let copied = copy_objects(&doc, 0, &[text]).unwrap(); + for offset in [[f64::NAN, 0.0], [f64::INFINITY, 0.0], [2e9, 0.0]] { + let mut work = doc.clone(); + assert!(paste_objects(&mut work, 1, &copied, offset).is_err(), "{offset:?}"); + assert!(!work.is_modified(), "a refused paste changes nothing"); + } + assert!(paste_objects(&mut doc.clone(), 7, &copied, [0.0, 0.0]).is_err()); + + // Text drawn as a clipping path before the selection would clip it: refused, not dropped. + let clipped = two_pages("BT /F1 20 Tf 7 Tr 20 20 Td (Mask) Tj ET 0 Tr BT /F1 20 Tf 20 200 Td (Shown) Tj ET", ""); + let shown = target(&clipped, 0, |o| o.text.as_deref() == Some("Shown")); + assert!(copy_objects(&clipped, 0, &[shown]).is_err()); + + // Stray `Q`, `ET` and an unfinished path or text object: the copy is still balanced. + let stray = two_pages("Q Q ET 10 10 m 20 20 l BT /F1 20 Tf 20 200 Td (Stray) Tj", ""); + let shown = target(&stray, 0, |o| o.text.as_deref() == Some("Stray")); + let copied = copy_objects(&stray, 0, &[shown]).unwrap(); + let ops = pdfcraft_content::parse(&copied.content).ops; + assert_eq!(ops.iter().filter(|o| o.is("q")).count(), ops.iter().filter(|o| o.is("Q")).count()); + assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), ops.iter().filter(|o| o.is("ET")).count()); + let mut work = stray.clone(); + paste_objects(&mut work, 1, &copied, [0.0, 0.0]).unwrap(); +} diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 6460aab066..ae2faf8386 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -1,5 +1,5 @@ -use super::marks::{MarkKind, matrix_number, n}; -use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; +use super::marks::{MarkKind, matrix_number}; +use super::{Dict, Document, EditError, ObjRef, Object, Stream}; pub(crate) fn page_list(doc: &Document) -> Vec { pdfcraft_model::pages(doc) @@ -68,6 +68,19 @@ pub(crate) fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base (1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string()) } +/// Add `fonts` (resource name, font) to the page's own resources. +pub(crate) fn add_page_fonts(doc: &mut Document, page: &pdfcraft_model::Page, fonts: &Dict) -> Result<(), EditError> { + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut all = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + for (name, font) in fonts.iter() { + all.set(name.clone(), font.clone()); + } + res.set(b"Font".to_vec(), Object::Dict(all)); + // The page gets its own (possibly copied) resources; shared dictionaries stay untouched. + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + Ok(()) +} + /// Add the standard `faces` (resource name, base font) and an opacity state to the page's own /// resources. pub(crate) fn add_page_resources( @@ -575,10 +588,3 @@ pub(crate) fn undrawable(c: char) -> EditError { pub fn undrawable_message(c: char) -> String { format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) } - -pub(crate) fn text_op(x: f64, y: f64, text: &str) -> Vec { - let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); - v.extend(literal(&win_ansi(text))); - v.extend_from_slice(b" Tj\n"); - v -} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4b3b6633e4..ba80058c45 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1521,6 +1521,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"; @@ -6199,6 +6238,55 @@ fn header_and_watermark_drawability_matches_what_applying_refuses() { } } +/// Thai in a header, footer or text watermark is drawn from the Thai face as Add text draws it: +/// Type 3 glyphs with a ToUnicode map, one set of fonts shared by every page the mark goes on, +/// beside the mark's standard face. A build without a Thai face refuses it, saying what to do. +#[test] +fn thai_headers_footers_and_watermarks_are_drawn_from_the_thai_face() { + let blank = String::new; + let footer = HeaderFooter { + text: [blank(), "หน้า <<1>> บุญฤทธิ์".into(), blank(), blank(), blank(), "น้ำ <>".into()], + ..HeaderFooter::default() + }; + let wm = Watermark { text: "ลับ ปั้น\nDRAFT".into(), ..Watermark::default() }; + if pdfcraft_fonts::document_thai_font().is_none() { + let mut doc = fixture(); + for err in [add_header_footer(&mut doc, &[0], &footer, false, &cx()).unwrap_err(), add_watermark(&mut doc, &[0], &wm, false).unwrap_err()] { + assert!(matches!(&err, EditError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err:?}"); + } + assert!(!doc.is_modified(), "nothing is written when the font is missing"); + assert_eq!((footer.first_undrawable(), wm.first_undrawable()), (Some('ห'), Some('ล'))); + return; + } + assert_eq!((footer.first_undrawable(), wm.first_undrawable()), (None, None)); + let mut doc = fixture(); + add_header_footer(&mut doc, &[0, 1], &footer, false, &cx()).unwrap(); + add_watermark(&mut doc, &[0, 1], &wm, false).unwrap(); + let doc = reopen(&doc); + let mut shared = Vec::new(); + for page in 0..2 { + let drawn = streams(&doc, page).concat(); + assert!(drawn.contains("/PCTh") && drawn.contains("(DRAFT) Tj") && !drawn.contains("(?"), "{drawn}"); + let p = &pdfcraft_model::pages(&doc)[page]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone(); + let thai: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCTh")).map(|(k, v)| (k.clone(), v.clone())).collect(); + assert_eq!(thai.len(), 2, "the footer's fonts and the watermark's"); + let mut mapped = String::new(); + for (_, f) in &thai { + let f = doc.resolve(f).as_dict().unwrap().clone(); + assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..])); + mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap()); + } + // น้ำ, ก (the Bates prefix) and ปั้น copy as typed. + for text in ["<0E190E490E33>", "<0E01>", "<0E1B0E310E49>"] { + assert!(mapped.contains(text), "{text} in {mapped}"); + } + shared.push(thai); + } + assert_eq!(shared[0], shared[1], "every page draws with the same fonts"); +} + /// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`, /// and leave the document untouched. #[test] @@ -6619,6 +6707,57 @@ fn added_thai_text_wraps_clusters_and_survives_reopen() { assert!(arabic_font_names(&doc, 0).is_empty()); } +#[test] +fn added_thai_text_wraps_between_dictionary_words() { + if pdfcraft_fonts::document_thai_font().is_none() { + eprintln!("skipping the Thai word wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "การประปาส่วนภูมิภาคให้บริการน้ำประปาที่สะอาดแก่ประชาชนทั่วประเทศ"; + let t = AddedText { rect: [0.0, 0.0, 90.0, 400.0], text: text.into(), size: 16.0, ..AddedText::default() }; + let rows = super::added::lines(&t); + assert!(rows.len() > 2, "{rows:?}"); + assert_eq!(rows.concat(), text, "no space is added or lost between the rows"); + // Every row ends where the dictionary lets one end, never inside a word such as ประปา. + let cuts = crate::linebreak::breaks(text); + let mut at = 0; + for row in rows.iter().take(rows.len() - 1) { + at += row.len(); + assert!(cuts.contains(&at), "row {row:?} ends inside a word: {rows:?}"); + } + // Spaces where a row ends are dropped, as for Latin text, and a word wider than the row is + // still broken between clusters. + let spaced = + AddedText { text: "น้ำประปา สะอาด ปลอดภัย".into(), rect: [0.0, 0.0, 60.0, 400.0], ..t.clone() }; + let rows = super::added::lines(&spaced); + assert!(rows.iter().all(|r| !r.starts_with(' ') && !r.ends_with(' ')), "{rows:?}"); + assert_eq!(rows.concat(), "น้ำประปาสะอาดปลอดภัย"); + let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t.clone() }; + assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]); + // Spaces that open a paragraph are kept, but they are not a row of their own: a first word + // too wide to fit after them stays on the first row (or breaks between clusters there). + for width in [16.0, 40.0, 60.0, 90.0] { + let indented = AddedText { rect: [0.0, 0.0, width, 400.0], text: " ประปาสะอาด".into(), ..t.clone() }; + let rows = super::added::lines(&indented); + assert!(rows.iter().all(|r| !r.trim().is_empty()), "an empty row at width {width}: {rows:?}"); + assert!(rows[0].starts_with(" "), "the opening spaces are kept at width {width}: {rows:?}"); + assert_eq!(rows.concat().trim_start(), "ประปาสะอาด"); + } + // A first word that fits a row alone but not after the opening spaces breaks between clusters + // on the first row rather than running past its end. + let word = "ประปา"; + let word_w = super::added::shaped_width(&t, word, false); + let indent_w = super::added::shaped_width(&t, " ", false); + let width = word_w + indent_w / 2.0; + let indented = AddedText { rect: [0.0, 0.0, width, 400.0], text: format!(" {word}"), ..t.clone() }; + let rows = super::added::lines(&indented); + for row in &rows { + assert!(super::added::shaped_width(&t, row, false) <= width + 0.5, "{row:?} overflows {width}: {rows:?}"); + } + assert!(rows.len() > 1 && rows[0].starts_with(" "), "{rows:?}"); + assert_eq!(rows.concat().trim_start(), word); +} + #[test] fn group_moves_preserve_glyph_bytes_and_unselected_text_state() { for content in [ @@ -9648,6 +9787,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] @@ -10754,6 +10907,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). @@ -13657,3 +13920,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 06a8b62646..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)] @@ -63,9 +63,9 @@ pub(crate) use lines::{ pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; use rewrite::{coincident_ops, rewrite_block_from}; +pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks}; #[cfg(test)] pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit}; -pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks}; /// One line of existing text. #[derive(Clone, Debug, PartialEq)] @@ -277,6 +277,18 @@ mod tests { assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]); } + #[test] + fn thai_text_wraps_between_dictionary_words_and_keeps_marks_on_their_word() { + // Without word breaks the whole run is one word and overflows a single row. + assert_eq!(wrapped("ภาษาไทยไม่มีช่องว่าง", 8.0), ["ภาษาไทย", "ไม่มี", "ช่องว่าง"]); + // Mai Yamok stays with the word it repeats; pieces join without a space, words keep theirs. + assert_eq!(wrapped("เด็กๆชอบกิน ขนม", 6.0), ["เด็กๆ", "ชอบกิน", "ขนม"]); + assert_eq!(wrapped("ไฟล์ PDF ประปาส่วนภูมิภาค", 9.0), ["ไฟล์ PDF", "ประปาส่วน", "ภูมิภาค"]); + // A wrap between Thai words consumed no separator, so a rejoin puts back no space. + let rows = wrap("ภาษาไทยไม่มีช่องว่าง", 8.0, |s| s.chars().count() as f64); + assert_eq!(rows.iter().map(|(_, soft, gap)| (*soft, *gap)).collect::>(), [(false, 0), (true, 0), (true, 0)]); + } + #[test] fn fallback_face_moves_on_when_the_best_face_lacks_a_character() { static JPAN: &[&str] = &["Jpan"]; 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/rewrite.rs b/crates/edit/src/text/rewrite.rs index 8741888c4e..c19f4ce98f 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,4 +1,4 @@ -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, @@ -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()?; @@ -240,7 +240,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 +259,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() @@ -1117,6 +1117,36 @@ fn vertical_encoding(doc: &Document, font: &Dict) -> Result { Err(EditError::Invalid("the text encoding is nested too deeply".into())) } +/// The text-showing operators of paragraphs `indexes` (from [`group_blocks`] of the page's +/// lines), as indexes among the page's joined operators. `editable`: refuse a line that can't be +/// edited (moving rewrites its operators; copying takes them as they are). +pub(crate) fn paragraph_ops(doc: &Document, page: usize, indexes: &[usize], editable: bool) -> Result, EditError> { + let mut selected = std::collections::BTreeSet::new(); + if indexes.is_empty() { + return Ok(selected); + } + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + for index in indexes { + let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; + for line in &b.lines { + let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; + if editable { + l.ensure_editable()?; + } + selected.extend(l.ops.iter().copied()); + } + } + Ok(selected) +} + +/// Where each text-showing operator of `ops` (a page's joined operators, drawn with `resources`) +/// starts its glyphs: the text matrix there, and its rendering mode (`Tr`). +pub(crate) fn show_starts(doc: &Document, ops: &[Op], resources: &Dict) -> Result, EditError> { + let shown = interpret(doc, ops, resources, &mut HashMap::new(), &mut Carry::new(), None, None)?; + Ok(shown.into_iter().map(|s| (s.op, (s.tm, s.state.render_mode))).collect()) +} + /// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing. /// Numeric TJ offsets and text rise temporarily move each selected show operation, then /// restore both advances and rise. The PDF reader advances the original glyph bytes itself: @@ -1127,17 +1157,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize } let p = page_dict(doc, page)?; let streams = content_streams(doc, &p.dict)?; - let lines = text_lines(doc, page)?; - let blocks = group_blocks(&lines); - let mut selected = std::collections::BTreeSet::new(); - for index in indexes { - let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; - for line in &b.lines { - let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; - l.ensure_editable()?; - selected.extend(l.ops.iter().copied()); - } - } + let selected = paragraph_ops(doc, page, indexes, true)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = joined(&streams); 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 3d38a594f2..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; } @@ -685,18 +686,41 @@ fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 { type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec); +/// For each of `chars`, whether a row may end just before it inside a run of non-space characters +/// (Chinese, Japanese and Thai), worked out once per run: Thai words need the whole run, not just +/// two neighbours. +fn styled_break_before(chars: &[StyledChar]) -> Vec { + let mut break_before = vec![false; chars.len()]; + let mut run_start = 0; + while run_start < chars.len() { + let mut run_end = run_start; + while chars.get(run_end).is_some_and(|c| !c.0.is_whitespace()) { + run_end += 1; + } + let run: String = chars.get(run_start..run_end).unwrap_or_default().iter().map(|c| c.0).collect(); + for (i, cut) in crate::linebreak::breaks_before_chars(&run).into_iter().enumerate() { + if let Some(slot) = break_before.get_mut(run_start.saturating_add(i)) { + *slot = cut; + } + } + run_start = run_end.max(run_start.saturating_add(1)); + } + break_before +} + fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, ) -> Result { + let break_before = styled_break_before(chars); let mut rows: Vec> = Vec::new(); let mut row_ranges = Vec::new(); // True when this row starts because the previous word did not fit, not because of a newline. let mut row_soft = Vec::new(); let mut next_soft = false; // How many separator characters a wrap trimmed off the row that just ended, for the row - // about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless + // about to start (0 for a Chinese, Japanese or Thai word split, which trims none; meaningless unless // the row it belongs to is soft). Mirrors `next_soft`. let mut row_gap = Vec::new(); let mut next_gap = 0usize; @@ -729,11 +753,8 @@ fn wrap_styled_rows( } let mut end = at + 1; if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) - && !current.0.is_whitespace() - && !crate::linebreak::between(before.0, current.0) - { + // A word ends at a space, or where Chinese, Japanese or Thai text may break inside it. + while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) && !break_before.get(end).copied().unwrap_or(false) { end += 1; } } @@ -903,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 @@ -1077,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; } @@ -1092,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 = @@ -1411,7 +1438,7 @@ fn push_whole_paragraph_marker(block_ops: &mut Vec, marked: bool, op: &str) /// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in /// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of -/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores +/// separator characters it consumed (0 for a Chinese, Japanese or Thai word split), so a rejoin restores /// what was actually there instead of guessing a single space (#2529). Neither is a paragraph /// region, so the lines stay in one scope. fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[usize], row_index: usize) { diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs index 1b1916a1c8..c8b0d2f4d4 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -41,11 +41,11 @@ use crate::EditError; pub(crate) const MAX_FORM_BYTES: usize = 64 << 20; /// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34). -const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; +pub(crate) const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"]; /// The `/ColorSpace` entries that remap device colours painted with the resources they are in /// (ISO 32000-2 §8.6.5.6). -const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"]; +pub(crate) const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"]; /// The most distinct patterns one ungrouped form may select; each becomes a new object. const MAX_MOVED_PATTERNS: usize = 256; @@ -258,7 +258,7 @@ pub(crate) fn ungroup_ops( // up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it // would find the page's, which differ wherever a name was renamed (every form resource that // isn't already the page's, the nested item's own entry included). - if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) { + if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op, budget)) { return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") }); } let mut depth = 0usize; @@ -505,7 +505,7 @@ fn shows_text(op: &Op) -> bool { } /// An operator whose operands the renderer can't read as it expects, so it skips the operator. -struct Unreadable; +pub(crate) struct Unreadable; /// Which operand of `op` names a resource, and from which category, read as the renderer reads /// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`, @@ -513,7 +513,7 @@ struct Unreadable; /// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them. /// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which /// [`ungroup_ops`] refuses rather than guess at. -fn resource_operand(op: &Op) -> Result, Unreadable> { +pub(crate) fn resource_operand(op: &Op) -> Result, Unreadable> { let operands = &op.operands; let name_at = |i: Option| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_)))); let last = operands.len().checked_sub(1); @@ -553,25 +553,44 @@ fn resource_operand(op: &Op) -> Result, Unreadabl } /// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from -/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. -fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool { +/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. A +/// form or pattern whose content names no resource inherits nothing (a filled shape, say), so it +/// needs no renaming; its content is read within `budget`. A Type 3 font's glyphs are not read, +/// and always count. +fn inherits_resources(doc: &Document, resources: &Dict, op: &Op, budget: &mut DecodeBudget) -> bool { let Ok(Some((cat @ (b"XObject" | b"Font" | b"Pattern"), i))) = resource_operand(op) else { return false }; let Some(Object::Name(name)) = op.operands.get(i) else { return false }; let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else { return false; }; - let dict = match &*doc.resolve(&entry) { - Object::Stream(s) => s.dict.clone(), - Object::Dict(d) => d.clone(), + let entry = doc.resolve(&entry); + let (dict, stream) = match &*entry { + Object::Stream(s) => (&s.dict, Some(s)), + Object::Dict(d) => (d, None), _ => return false, }; let drawn = match cat { - b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"), - b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"), + b"XObject" => is_name(doc, dict, b"Subtype", b"Form"), + b"Font" => is_name(doc, dict, b"Subtype", b"Type3"), // A shading pattern has no content of its own. - _ => int_of(doc, &dict, b"PatternType") == Some(1), + _ => int_of(doc, dict, b"PatternType") == Some(1), }; - drawn && dict.get(b"Resources").is_none() + drawn && dict.get(b"Resources").is_none() && (cat == b"Font" || stream.is_none_or(|s| names_resources(doc, s, budget))) +} + +/// Whether content stream `s` names a resource (or can't be read within `budget` to tell): device +/// colour spaces are not resources; an inline image counts, as its colour space may be one. +fn names_resources(doc: &Document, s: &pdfcraft_cos::Stream, budget: &mut DecodeBudget) -> bool { + let Ok(data) = budget.decode_strict(doc, s) else { return true }; + let parsed = parse(&data); + parsed.skipped > 0 + || parsed.ops.iter().any(|op| match resource_operand(op) { + Ok(None) => op.inline.is_some(), + Ok(Some((b"ColorSpace", i))) => { + !matches!(op.operands.get(i), Some(Object::Name(n)) if [&b"DeviceGray"[..], b"DeviceRGB", b"DeviceCMYK", b"Pattern"].contains(&n.as_slice())) + } + _ => true, + }) } /// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts 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 4cddc9eade..7acddc14a3 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, @@ -803,6 +804,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. @@ -854,7 +863,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.rs b/crates/engine/src/edit.rs index f1a99a2387..6ead3ca09c 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -457,6 +457,12 @@ pub enum Edit { objects: Vec, offset: [f64; 2], }, + /// Delete mixed existing and added objects together (references from one inventory of + /// `page`), as one step: what Delete and Cut do to objects selected together. + DeleteObjects { + page: usize, + objects: Vec, + }, /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), /// rewrapped to the box's width. EditTextBlock { @@ -549,6 +555,13 @@ pub enum Edit { content: AddedContent, file: Option<(String, Arc>)>, }, + /// Edit ▸ Paste of objects copied together in Edit a PDF ([`Document::copy_objects`](crate::Document::copy_objects)), in this + /// document or another: drawn on top of `page` as they were, moved by `offset` (user space). + PasteObjects { + page: usize, + clip: Arc, + offset: [f64; 2], + }, /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. ApplyRedactions { pages: Option>, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index da7d718811..762e8ced2f 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -302,9 +302,9 @@ impl Edit { Edit::AddText { .. } => Label::of("Add text"), Edit::AddImage { .. } => Label::of("Add image"), Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::DeleteContent { .. } | Edit::DeleteObjects { .. } => Label::of("Delete content"), Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), + Edit::PasteContent { .. } | Edit::PasteObjects { .. } => Label::of("Paste"), Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), Edit::ClearRedactions => Label::of("Remove redaction marks"), Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 227e0c2d46..4a04f6b042 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -111,9 +111,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::MoveObjects { .. } + | Edit::DeleteObjects { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } + | Edit::PasteObjects { .. } | Edit::ClearRedactions | Edit::RemoveHidden { .. } | Edit::ImportData { .. } @@ -566,8 +568,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, @@ -736,6 +738,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?, Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?, Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::DeleteObjects { page, objects } => crate::object_clip::delete(doc, *page, objects, |doc, edit| run_edit(doc, edit, cx))?, + Edit::PasteObjects { page, clip, offset } => crate::object_clip::paste(doc, *page, clip, *offset)?, Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 4997f64efe..04d848eb8d 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -79,6 +79,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { // Page content, resources and added content/marks only: the page tree, annotations, // fields, links and document metadata are untouched (see `refresh_content`). Edit::MoveObjects { .. } + | Edit::DeleteObjects { .. } | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::PrepareText { .. } @@ -90,10 +91,15 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } + | Edit::PasteObjects { .. } | 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 326d47f060..53487f2f77 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; @@ -36,6 +37,7 @@ pub mod js; mod layers; pub mod links; pub mod model3d; +mod object_clip; pub mod ocr; mod open; pub mod optimizer; @@ -85,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, @@ -108,6 +110,7 @@ pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, T pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; +pub use object_clip::{ObjectClip, ObjectClipboard}; pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; @@ -149,7 +152,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 +163,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/object_clip.rs b/crates/engine/src/object_clip.rs new file mode 100644 index 0000000000..3a2f0915a1 --- /dev/null +++ b/crates/engine/src/object_clip.rs @@ -0,0 +1,211 @@ +//! Edit ▸ Copy, Cut and Paste of objects selected together in Edit a PDF (#5989). +//! +//! A copy ([`ObjectClip`]) holds what the objects draw and the resources that drawing names +//! ([`pdfcraft_edit::copy_objects`]), with a snapshot of the document they came from, so it can +//! be pasted into any open document, also after the original changes or closes. Within the same +//! document, while the objects it refers to are unchanged, a paste draws them again from the same +//! fonts and images; otherwise it first copies them over ([`pdfcraft_organize::import_value`]): +//! each once, through cycles, sharing what the target already holds, and within a size limit. + +use std::sync::Arc; + +use pdfcraft_cos::Object; + +use crate::{DocId, Document, Edit, EditError, EditableObject, ObjectTarget}; + +/// The most objects a paste copies into another document. +const MAX_IMPORT_OBJECTS: usize = 20_000; +/// The most stream data (fonts, images…) a paste copies into another document. +const MAX_IMPORT_BYTES: usize = 256 << 20; +/// How far each further paste onto the same page steps down and to the right, in points, as +/// pasted added content steps ([`pdfcraft_edit::added::PASTE_STEP`]). +const STEP: f64 = pdfcraft_edit::added::PASTE_STEP; + +/// Objects copied from one page of a document. +#[derive(Clone)] +pub struct ObjectClip { + copied: pdfcraft_edit::CopiedObjects, + /// The document as it was when copied: what the copy's resources refer to. + source: pdfcraft_cos::Document, + doc: DocId, + page: usize, +} + +impl PartialEq for ObjectClip { + fn eq(&self, other: &Self) -> bool { + self.copied == other.copied + && self.doc == other.doc + && self.page == other.page + && self.source.source_identity() == other.source.source_identity() + } +} + +impl std::fmt::Debug for ObjectClip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ObjectClip").field("doc", &self.doc).field("page", &self.page).field("objects", &self.copied.objects.len()).finish() + } +} + +impl ObjectClip { + /// The objects as they were listed when copied ([`Document::editable_objects`]). + pub fn objects(&self) -> &[EditableObject] { + &self.copied.objects + } + + /// The text of the copied paragraphs and text boxes, one per line. + pub fn text(&self) -> String { + self.copied.text() + } + + /// The union of the objects' boxes, in the user space of the page they came from. + pub fn rect(&self) -> [f64; 4] { + self.copied.rect + } + + /// The document and page the objects were copied from. + pub fn source(&self) -> (DocId, usize) { + (self.doc, self.page) + } +} + +impl Document { + /// Edit ▸ Copy of `targets` (references from one [`Document::editable_objects`] inventory of + /// `page`). Refused when the document doesn't allow copying. Changes nothing. + pub fn copy_objects(&self, page: usize, targets: &[ObjectTarget]) -> Result { + self.check_copying()?; + let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly("the document can't be read".into()))?; + let copied = pdfcraft_edit::copy_objects(&editor.cos, page, targets)?; + Ok(ObjectClip { copied, source: editor.cos.clone(), doc: self.id, page }) + } + + /// Where Paste puts `clip` on `page`: the user-space offset that moves it `steps` × 18 pt down + /// and to the right as displayed, or, when that leaves nothing of it on the page's crop box, + /// the offset that centres it there. + pub fn object_paste_offset(&self, page: usize, clip: &ObjectClip, steps: u32) -> Result<[f64; 2], String> { + let step = STEP * f64::from(steps); + let offset = self.object_move_offset(page, [step, step])?; + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let crop = p.crop.map(f64::from); + let r = clip.rect(); + let moved = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + if moved[0] < crop[2] && moved[2] > crop[0] && moved[1] < crop[3] && moved[3] > crop[1] { + return Ok(offset); + } + let centre = |a: f64, b: f64| (a + b) / 2.0; + Ok([centre(crop[0], crop[2]) - centre(r[0], r[2]), centre(crop[1], crop[3]) - centre(r[1], r[3])]) + } +} + +/// The objects copied in an application or a script, and how many times each page has had them +/// pasted, which sets how far the next paste there steps ([`Document::object_paste_offset`]). +#[derive(Clone, Debug)] +pub struct ObjectClipboard { + clip: Arc, + pastes: Vec<(DocId, usize, u32)>, +} + +impl ObjectClipboard { + pub fn new(clip: ObjectClip) -> Self { + ObjectClipboard { clip: Arc::new(clip), pastes: Vec::new() } + } + + pub fn clip(&self) -> &ObjectClip { + &self.clip + } + + /// The edit that pastes the copy onto `page` of `doc`: where it was, or on the page it came + /// from one step down and to the right (as Acrobat offsets a paste onto the same page), and + /// one step further for each earlier paste onto that page. + pub fn paste(&mut self, doc: &Document, page: usize) -> Result { + let at = self.pastes.iter().position(|(d, p, _)| *d == doc.id && *p == page); + let earlier = at.and_then(|i| self.pastes.get(i)).map_or(0, |(_, _, n)| *n); + let own = u32::from(self.clip.source() == (doc.id, page)); + let offset = doc.object_paste_offset(page, &self.clip, earlier.saturating_add(own))?; + match at.and_then(|i| self.pastes.get_mut(i)) { + Some(entry) => entry.2 = entry.2.saturating_add(1), + None => self.pastes.push((doc.id, page, 1)), + } + Ok(Edit::PasteObjects { page, clip: Arc::clone(&self.clip), offset }) + } +} + +impl Edit { + /// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step: + /// what Delete and Cut do to objects selected together. One object is deleted by its own + /// edit, several by [`Edit::DeleteObjects`]; none is no edit. + pub fn delete_objects(page: usize, mut targets: Vec) -> Option { + targets.sort(); + targets.dedup(); + match targets.as_slice() { + [] => None, + [t] => Some(delete_one(page, *t)), + _ => Some(Edit::DeleteObjects { page, objects: targets }), + } + } +} + +fn delete_one(page: usize, t: ObjectTarget) -> Edit { + use crate::ObjectKind; + match t.kind { + ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: crate::BlockStyle::default() }, + ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: crate::ImageEdit::Delete }, + ObjectKind::Added => Edit::DeleteContent { page, index: t.index }, + } +} + +/// [`Edit::DeleteObjects`], each object by its own edit (`run`). A deletion renumbers what follows +/// it in its own inventory, so pictures go from the last to the first, and added content, which +/// the other inventories leave out, after them. Deleting a paragraph also removes a copy drawn +/// exactly over it (fake bold, a paste onto itself), which renumbers paragraphs before it too: so +/// each paragraph is found again by its text and box, and one already gone with another is done. +pub(crate) fn delete( + doc: &mut pdfcraft_cos::Document, + page: usize, + objects: &[ObjectTarget], + mut run: impl FnMut(&mut pdfcraft_cos::Document, &Edit) -> Result<(), EditError>, +) -> Result<(), EditError> { + use crate::ObjectKind; + let mut targets = objects.to_vec(); + targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index))); + targets.dedup(); + let blocks = pdfcraft_edit::text_blocks(doc, page)?; + for t in targets { + let edit = match t.kind { + ObjectKind::Text => { + let key = blocks.get(t.index).map(crate::BlockKey::of).ok_or_else(|| { + EditError::Edit(pdfcraft_edit::EditError::Invalid("a selected paragraph no longer exists; select the objects again".into())) + })?; + let Some(block) = key.find(&pdfcraft_edit::text_blocks(doc, page)?, t.index) else { continue }; + delete_one(page, ObjectTarget { kind: t.kind, index: block }) + } + _ => delete_one(page, t), + }; + run(doc, &edit)?; + } + Ok(()) +} + +/// [`Edit::PasteObjects`]: the copy's resources as `doc` holds them, then the paste. +pub(crate) fn paste(doc: &mut pdfcraft_cos::Document, page: usize, clip: &ObjectClip, offset: [f64; 2]) -> Result<(), EditError> { + let resources = Object::Dict(clip.copied.resources.clone()); + let refused = + |e: pdfcraft_organize::OrganizeError| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("the copied objects can't be pasted: {e}"))); + let used = pdfcraft_organize::reachable(&clip.source, &resources, MAX_IMPORT_OBJECTS, MAX_IMPORT_BYTES).map_err(refused)?; + // Still the objects the copy was made from: the same document, and each of them unchanged. + let same = doc.source_identity() == clip.source.source_identity() + && used.iter().all(|r| { + let (here, there) = (doc.get(*r), clip.source.get(*r)); + Arc::ptr_eq(&here, &there) || here == there + }); + let resources = if same { resources } else { pdfcraft_organize::import_value(doc, &clip.source, &resources).map_err(refused)? }; + let Object::Dict(resources) = resources else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the copied objects' resources could not be copied".into()))); + }; + let copied = pdfcraft_edit::CopiedObjects { resources, ..clip.copied.clone() }; + pdfcraft_edit::paste_objects(doc, page, &copied, offset)?; + Ok(()) +} + +#[cfg(test)] +#[path = "object_clip_tests.rs"] +mod tests; diff --git a/crates/engine/src/object_clip_tests.rs b/crates/engine/src/object_clip_tests.rs new file mode 100644 index 0000000000..49baae69b8 --- /dev/null +++ b/crates/engine/src/object_clip_tests.rs @@ -0,0 +1,257 @@ +use super::*; +use crate::{ObjectKind, Session}; + +/// A PDF of 300 × 300 pages, each drawing its `content` with `resources` and objects `extra` +/// (numbered after the pages' content streams). +fn pdf(pages: &[(&str, &str)], extra: &[String]) -> Arc> { + let n = pages.len(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 300] >>", kids.join(" ")), + ]; + for (i, (_, resources)) in pages.iter().enumerate() { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 3 + n + i)); + } + for (content, _) in pages { + objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + } + objs.extend(extra.iter().cloned()); + 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 {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Arc::new(out) +} + +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; +const COURIER: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>"; +/// A 2 × 2 RGB picture of the twelve bytes `data` (ASCII, so the file's lengths are its bytes). +fn picture(data: &str) -> String { + assert!(data.is_ascii() && data.len() == 12); + format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n{data}\nendstream" + ) +} + +/// A one-page document whose paragraph "Copied" (blue, half opaque, Helvetica /F1 at objects 5) +/// and picture /Im0 (object 6) are copied; "Left" is not. +fn source() -> Arc> { + let content = "0 0 1 rg /GS0 gs BT /F1 24 Tf 20 200 Td (Copied) Tj ET q 60 0 0 40 20 100 cm /Im0 Do Q BT /F1 12 Tf 200 40 Td (Left) Tj ET"; + pdf( + &[(content, "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >>")], + &[HELVETICA.into(), picture("\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}")], + ) +} + +/// A one-page document that already draws with Courier as /F1 and has a picture of its own as /Im0. +fn target() -> Arc> { + pdf( + &[("BT /F1 10 Tf 250 280 Td (x) Tj ET q 10 0 0 10 280 5 cm /Im0 Do Q", "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >>")], + &[COURIER.into(), picture("@@@@@@@@@@@@")], + ) +} + +fn copy_all_but_left(s: &Session, id: DocId) -> ObjectClip { + let doc = s.get(id).unwrap(); + let targets: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter(|o| o.text.as_deref() != Some("Left")).map(|o| o.target).collect(); + assert_eq!(targets.len(), 2); + doc.copy_objects(0, &targets).unwrap() +} + +fn render(s: &Session, id: DocId) -> pdfcraft_render::RenderedPage { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) +} + +/// The pixels in user-space box `r` of a 300 pt page rendered at scale 1. +fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4]) -> Vec { + let mut out = Vec::new(); + for y in (300.0 - r[3]) as u32..(300.0 - r[1]) as u32 { + for x in r[0] as u32..r[2] as u32 { + let i = ((y * page.width + x) * 4) as usize; + out.extend_from_slice(&page.rgba[i..i + 4]); + } + } + out +} + +fn images(s: &Session, id: DocId) -> Vec> { + s.get(id).unwrap().page_images(0).into_iter().map(|i| i.object).collect() +} + +#[test] +fn objects_copied_from_one_document_paste_into_another_and_draw_the_same() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let b = s.open("b.pdf", None, target(), None).unwrap(); + let clip = copy_all_but_left(&s, a); + assert_eq!(clip.text(), "Copied"); + let mut clipboard = ObjectClipboard::new(clip); + // The source can change, or close: the copy is a snapshot. + s.apply(a, Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Image, index: 0 }]).unwrap()).unwrap(); + s.close(a); + let a = s.open("a.pdf", None, source(), None).unwrap(); + + let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap(); + assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [0.0, 0.0]), "another document: where it was"); + s.apply(b, paste).unwrap(); + assert_eq!(s.get(b).unwrap().can_undo(), Some("Paste")); + let area = clipboard.clip().rect().map(|v| v.round()); + let area = [area[0] - 3.0, area[1] - 3.0, area[2] + 3.0, area[3] + 3.0]; + let (original, pasted) = (pixels(&render(&s, a), area), pixels(&render(&s, b), area)); + assert!(original.chunks(4).any(|p| p[2] > 200 && p[0] < 200), "the blue paragraph is drawn"); + assert_eq!(original, pasted, "the pasted paragraph and picture draw the same pixels"); + + // Its own /F1 and /Im0 are left alone; what the copy uses came over under other names. + let doc = s.get(b).unwrap(); + let texts: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect(); + assert!(texts.contains(&"Copied".to_string()) && !texts.contains(&"Left".to_string()), "{texts:?}"); + let pictures = images(&s, b); + assert_eq!(pictures.len(), 2); + assert_ne!(pictures[0], pictures[1]); + + // A second paste steps down and to the right, and shares the fonts and pictures the first + // brought instead of copying them again. + let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap(); + assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "{paste:?}"); + s.apply(b, paste).unwrap(); + let pictures = images(&s, b); + assert_eq!(pictures.len(), 3); + assert_eq!(pictures[1], pictures[2], "one picture object for both pastes"); + + // Saved and reopened, the pastes are there; undone, the page is as it was. + let saved = s.save_bytes(b).unwrap(); + let mut again = Session::new(); + let reopened = again.open("b.pdf", None, saved, None).unwrap(); + assert_eq!(again.get(reopened).unwrap().page_images(0).len(), 3); + s.undo(b).unwrap(); + s.undo(b).unwrap(); + assert_eq!(images(&s, b).len(), 1); + assert_eq!(s.get(b).unwrap().can_undo(), None); +} + +#[test] +fn a_paste_onto_the_page_it_came_from_steps_and_draws_from_the_same_objects() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a)); + let paste = clipboard.paste(s.get(a).unwrap(), 0).unwrap(); + assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "one step down and right: {paste:?}"); + s.apply(a, paste).unwrap(); + let pictures = images(&s, a); + assert_eq!(pictures.len(), 2); + assert_eq!(pictures[0], pictures[1], "the same picture object, not a copy"); + // Far off the page (a copy from a larger page), a paste is centred instead. + let doc = s.get(a).unwrap(); + let mut far = clipboard.clip().clone(); + far.copied.rect = [1000.0, 1000.0, 1100.0, 1050.0]; + assert_eq!(doc.object_paste_offset(0, &far, 0).unwrap(), [150.0 - 1050.0, 150.0 - 1025.0]); +} + +#[test] +fn pasting_into_another_document_follows_cycles_and_refuses_past_its_limit() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + // A font that refers to itself and to an object that refers back. + let cyclic = pdf( + &[("BT /F1 20 Tf 20 200 Td (Loop) Tj ET", "<< /Font << /F1 5 0 R >> >>")], + &[ + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Self 5 0 R /Next [6 0 R] >>".into(), + "<< /Back 5 0 R /Again 6 0 R >>".into(), + ], + ); + let a = s.open("cyclic.pdf", None, cyclic, None).unwrap(); + let b = s.open("b.pdf", None, target(), None).unwrap(); + let all: Vec<_> = s.get(a).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect(); + let mut clipboard = ObjectClipboard::new(s.get(a).unwrap().copy_objects(0, &all).unwrap()); + s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap(); + assert!(s.get(b).unwrap().editable_objects(0).unwrap().iter().any(|o| o.text.as_deref() == Some("Loop"))); + + // An ExtGState that drags in more objects than a paste copies is refused with a reason. + let many = 20_001; + let refs: Vec = (0..many).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut extra = vec![format!("<< /ca 0.5 /Junk [{}] >>", refs.join(" "))]; + extra.extend((0..many).map(|i| format!("<< /N {i} >>"))); + let resources = format!("<< /Font << /F1 {HELVETICA} >> /ExtGState << /GS0 5 0 R >> >>"); + let big = pdf(&[("/GS0 gs BT /F1 20 Tf 20 200 Td (Big) Tj ET", &resources)], &extra); + let c = s.open("big.pdf", None, big, None).unwrap(); + let all: Vec<_> = s.get(c).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect(); + assert_eq!(all.len(), 1); + let mut clipboard = ObjectClipboard::new(s.get(c).unwrap().copy_objects(0, &all).unwrap()); + let before = s.get(b).unwrap().can_undo().map(str::to_string); + let err = s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap_err().to_string(); + assert!(err.contains("copy fewer objects"), "{err}"); + assert_eq!(s.get(b).unwrap().can_undo().map(str::to_string), before, "a refused paste changes nothing"); +} + +#[test] +fn copying_refuses_a_stale_selection_and_deleting_several_objects_is_one_step() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let doc = s.get(a).unwrap(); + assert!(doc.copy_objects(0, &[ObjectTarget { kind: ObjectKind::Text, index: 7 }]).is_err()); + assert!(doc.copy_objects(3, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }]).is_err()); + let delete = Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }, ObjectTarget { kind: ObjectKind::Image, index: 0 }]); + assert!(matches!(delete, Some(Edit::DeleteObjects { .. }))); + assert!(Edit::delete_objects(0, Vec::new()).is_none()); + s.apply(a, delete.unwrap()).unwrap(); + assert_eq!(s.get(a).unwrap().can_undo(), Some("Delete content")); + assert!(s.get(a).unwrap().editable_objects(0).unwrap().iter().all(|o| o.text.as_deref() == Some("Left"))); +} + +/// Two pastes of a paragraph and a picture exactly over each other: deleting a paragraph also +/// removes a copy drawn exactly over it, so deleting all four objects together found the second +/// paragraph's number gone and refused the whole delete. Both copies go, in one step. +#[test] +fn objects_pasted_exactly_over_each_other_delete_together_in_one_step() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let a = s.open("a.pdf", None, source(), None).unwrap(); + let blank = s.create_blank(300.0, 300.0, 1).unwrap(); + let b = s.open_new("b.pdf", blank).unwrap(); + let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a)); + let Edit::PasteObjects { clip, .. } = clipboard.paste(s.get(b).unwrap(), 0).unwrap() else { panic!("a paste") }; + for _ in 0..2 { + s.apply(b, Edit::PasteObjects { page: 0, clip: Arc::clone(&clip), offset: [0.0, 0.0] }).unwrap(); + } + let pasted = s.get(b).unwrap().editable_objects(0).unwrap(); + assert_eq!(pasted.iter().filter(|o| o.text.as_deref() == Some("Copied")).count(), 2, "{pasted:?}"); + assert_eq!(pasted.len(), 4); + let delete = Edit::delete_objects(0, pasted.iter().map(|o| o.target).collect()).unwrap(); + s.apply(b, delete).unwrap(); + assert!(s.get(b).unwrap().editable_objects(0).unwrap().is_empty()); + assert_eq!(s.get(b).unwrap().can_undo(), Some("Delete content")); + s.undo(b).unwrap(); + assert_eq!(s.get(b).unwrap().editable_objects(0).unwrap(), pasted, "one undo brings both copies back"); +} + +/// Deleting renumbers what follows in the same inventory, so paragraphs and pictures go from +/// the last to the first, and added content (outside those inventories) after them. +#[test] +fn several_objects_delete_last_first() { + let content = + "BT /F1 12 Tf 20 250 Td (A) Tj ET BT /F1 12 Tf 20 180 Td (B) Tj ET BT /F1 12 Tf 20 110 Td (C) Tj ET BT /F1 12 Tf 20 40 Td (D) Tj ET"; + let mut doc = pdfcraft_cos::Document::open(pdf(&[(content, "<< /Font << /F1 5 0 R >> >>")], &[HELVETICA.into()])).unwrap(); + let t = |kind, index| ObjectTarget { kind, index }; + let targets = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)]; + let mut order = Vec::new(); + delete(&mut doc, 0, &targets, |_, e| { + order.push(match e { + Edit::EditTextBlock { block, text, .. } if text.is_empty() => ('t', *block), + Edit::EditPageImage { index, change: crate::ImageEdit::Delete, .. } => ('i', *index), + Edit::DeleteContent { index, .. } => ('a', *index), + other => panic!("unexpected {other:?}"), + }); + Ok(()) + }) + .unwrap(); + assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]); +} 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 0f18c04c75..f99a0b547d 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 { @@ -440,7 +440,7 @@ impl Session { | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } | Edit::EditPageImage { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + ) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) @@ -521,6 +521,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() { @@ -551,6 +553,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 a87528f858..7a7a53c5c7 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 { @@ -3478,6 +3577,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); @@ -3941,7 +4219,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:?}"); @@ -3952,6 +4232,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); @@ -5053,6 +5347,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 @@ -5062,6 +5357,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]); @@ -6117,6 +6418,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] @@ -8894,6 +9209,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] @@ -10184,6 +10539,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] 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/fonts/README.md b/crates/fonts/README.md index 963ee889b9..c3c0ea5021 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -56,9 +56,12 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes: editor can use the first one that has all of the replacement's glyphs (the faces differ in coverage, e.g. of Cyrillic). - `ui_thai_fonts` / `document_thai_font` / `shape_thai`: the `Thai` faces for the interface - and shaped Thai Add Text content. Harfrust positions stacked vowel/tone marks and decomposes + and shaped Thai content. Harfrust positions stacked vowel/tone marks and decomposes Sara Am; cluster ToUnicode entries preserve the original text. The shared Type 3 writer - embeds outlines, so reopening does not depend on installed fonts. + (`shaped_text`) embeds outlines, so reopening does not depend on installed fonts: Add text, + text boxes, Fill & Sign text, form fields, headers, footers and watermarks all write with it + (`shaped_text::appearance_lines` for appearances, whose glyph streams `store_streams` moves into + the document). `wrap_with` never breaks a line before a Thai mark. - `craft_glyph(face, ch)` and `face_has(bytes, ch)`: bounded outlines and glyph coverage for any bundled face. - `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`. - `TextPlan::for_words`: how the OCR output writes text beyond WinAnsi — per word the first diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 4ebe181a75..253246a79e 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -16,6 +16,7 @@ pub mod fallback; pub mod pdf; pub mod plan; mod script; +pub mod shaped_text; mod shaping; pub mod source_shaping; mod std14; @@ -99,7 +100,6 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); let space = measure(" ", size); - let mut buf = [0u8; 4]; // A CRLF pair is one break, a lone CR or LF is one break each. for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); @@ -125,14 +125,24 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) lines.push(std::mem::take(&mut line)); } line_width = 0.0; - for ch in word.chars() { - let ch_width = measure(ch.encode_utf8(&mut buf), size); - if !line.is_empty() && line_width + ch_width > width { + // Broken between characters, but never before a Thai mark: it stays on its letter. + let mut start = 0; + let mut units = Vec::new(); + for (i, ch) in word.char_indices().skip(1) { + if !shaped_text::joins_previous(ch) { + units.extend(word.get(start..i)); + start = i; + } + } + units.extend(word.get(start..).filter(|u| !u.is_empty())); + for unit in units { + let unit_width = measure(unit, size); + if !line.is_empty() && line_width + unit_width > width { lines.push(std::mem::take(&mut line)); line_width = 0.0; } - line.push(ch); - line_width += ch_width; + line.push_str(unit); + line_width += unit_width; } fresh = line.is_empty(); } @@ -320,6 +330,15 @@ mod tests { assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); } + #[test] + fn a_broken_thai_word_keeps_its_marks_on_their_letters() { + // Every character one point wide: "น้ำ" (three characters) is one unit, never split. + let lines = wrap_with("น้ำปั้นบุญฤทธิ์", 1.0, 2.0, |s, _| s.chars().count() as f64); + assert_eq!(lines.concat(), "น้ำปั้นบุญฤทธิ์"); + assert_eq!(lines, ["น้ำ", "ปั้", "น", "บุ", "ญฤ", "ท", "ธิ์"]); + assert!(lines.iter().all(|l| l.chars().next().is_some_and(|c| !shaped_text::joins_previous(c))), "{lines:?}"); + } + #[test] fn crlf_is_one_line_break() { assert_eq!(wrap("a\r\nb", 12.0, 100.0), ["a", "b"]); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 0ef49c81a5..6373d6427f 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, } } diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 59cd46d4be..d1b96fe3d0 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. diff --git a/crates/fonts/src/shaped_text.rs b/crates/fonts/src/shaped_text.rs new file mode 100644 index 0000000000..c85de92f64 --- /dev/null +++ b/crates/fonts/src/shaped_text.rs @@ -0,0 +1,515 @@ +//! Shaped text written into a PDF: each distinct shaped cluster (a letter with its marks, and the +//! text it stands for) becomes one glyph of a Type 3 font drawn from the craft-fonts face's +//! outlines, with a `/ToUnicode` map so copy and search get each character once. No font file is +//! embedded. Add text writes Arabic, Hebrew and Thai with it; text boxes, Fill & Sign text, form +//! fields, headers, footers and watermarks write Thai with it, beside the standard font that +//! draws the rest ([`thai_pieces`]). + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; + +use crate::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, literal, shape_thai, shaped_glyph}; + +/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. Text gets one such font per +/// 240 distinct clusters it needs. +pub const GLYPHS_PER_FONT: usize = 240; + +/// The Thai block (U+0E00–U+0E7F): letters, vowels, tone marks, digits and signs. +pub fn is_thai(c: char) -> bool { + matches!(u32::from(c), 0x0E00..=0x0E7F) +} + +/// Thai vowel signs and tone marks drawn on the consonant before them, and Sara Am, which the +/// face splits over it: a line never breaks before one, so a mark stays on its letter. +pub fn joins_previous(c: char) -> bool { + matches!(u32::from(c), 0x0E31 | 0x0E33..=0x0E3A | 0x0E47..=0x0E4E) +} + +/// Why Thai text can't be drawn, for a message an agent or user can act on. +pub fn thai_message(e: GlyphError, text: &str) -> String { + match e { + GlyphError::NoFont => "Thai text needs a Thai font, and this build has none; rebuild with CRAFT_FONTS_DIR pointing to a craft-fonts \ + checkout with a Thai face" + .into(), + GlyphError::Missing => format!("the Thai font can't show \"{text}\""), + GlyphError::TooComplex => "the Thai glyphs are too complex; use shorter text".into(), + } +} + +/// A piece of a line of shaped text, in drawing order. +#[derive(Clone, Debug, PartialEq)] +pub enum Piece { + /// Shown in the text's own standard font. + Latin(String), + Shaped(Vec), +} + +/// `line` split into its Thai runs, shaped with the Thai face, and the text between them, left to +/// the standard font. Thai runs left to right, so the pieces are in typing order. +/// [`GlyphError::NoFont`] when there's Thai and this build has no Thai face. +pub fn thai_pieces(line: &str) -> Result, GlyphError> { + let mut runs: Vec<(bool, String)> = Vec::new(); + for c in line.chars() { + match runs.last_mut() { + Some((thai, s)) if *thai == is_thai(c) => s.push(c), + _ => runs.push((is_thai(c), c.to_string())), + } + } + runs.into_iter().map(|(thai, s)| if thai { shape_thai(&s).map(Piece::Shaped) } else { Ok(Piece::Latin(s)) }).collect() +} + +/// The width of `text` at `size`: its Thai shaped, the rest measured by `latin`. Text the Thai +/// face can't shape is measured by `latin` as a whole (drawing then reports why). +pub fn thai_width(text: &str, size: f64, latin: impl Fn(&str, f64) -> f64) -> f64 { + if !text.chars().any(is_thai) { + return latin(text, size); + } + match thai_pieces(text) { + Ok(pieces) => pieces_width(&pieces, size, 0.0, latin), + Err(_) => latin(text, size), + } +} + +/// The width of `pieces` at `size`, with `spacing` after each shaped cluster (as `Tc` moves the +/// pen; the caller measures it into `latin`'s widths). +pub fn pieces_width(pieces: &[Piece], size: f64, spacing: f64, latin: impl Fn(&str, f64) -> f64) -> f64 { + pieces + .iter() + .map(|p| match p { + Piece::Latin(s) => latin(s, size), + // Each cluster is shown on its own, so the spacing follows each once. + Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * size + spacing).sum::(), + }) + .sum() +} + +/// The shaped clusters of `lines`' pieces, for [`ShapedFonts::new`]. +pub fn clusters<'a>(lines: impl IntoIterator) -> impl Iterator { + lines.into_iter().flatten().flat_map(|p| match p { + Piece::Shaped(clusters) => clusters.as_slice(), + Piece::Latin(_) => &[], + }) +} + +/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; +/// equal clusters come from the same font units). +type ClusterKey = (ShapedScript, String, Vec<(u32, u64, u64)>, u64); + +fn cluster_key(c: &ShapedCluster) -> ClusterKey { + (c.script, c.text.clone(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) +} + +/// The Type 3 fonts that draw a set of shaped clusters, one glyph per distinct cluster. The +/// default has none (for text with nothing shaped). +#[derive(Default)] +pub struct ShapedFonts { + index: HashMap, + /// Each font's resource name. + names: Vec, +} + +impl ShapedFonts { + /// Build the fonts for `clusters` into `fonts`, under names starting `prefix` that neither + /// `taken` (the page's fonts) nor `fonts` already has. `outline` gives a glyph's outline in its + /// script's face (the craft-fonts faces, or a stand-in in tests). `store` keeps each new + /// object and returns what refers to it: `doc.add` as a reference, or the object itself for + /// an appearance that isn't in a document yet, whose streams [`store_streams`] then moves in. + /// + /// Every outline is read before anything is stored, so a failure (and the cluster it came + /// from) leaves the document as it was. + pub fn new<'a>( + clusters: impl IntoIterator, + outline: impl Fn(ShapedScript, u32) -> Result, + taken: &Dict, + fonts: &mut Dict, + prefix: &str, + store: &mut dyn FnMut(Object) -> Object, + ) -> Result { + let mut keys: Vec<&ShapedCluster> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for c in clusters { + index.entry(cluster_key(c)).or_insert_with(|| { + keys.push(c); + keys.len() - 1 + }); + } + let mut outlines = HashMap::new(); + for c in &keys { + for (id, _) in &c.glyphs { + if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { + entry.insert(outline(c.script, *id).map_err(|e| (e, *c))?); + } + } + } + // A name no font on the page has, so items never replace each other's fonts (object + // numbers alone don't do: a full save renumbers objects but keeps resource names). + let mut taken_names: HashSet> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect(); + let mut names = Vec::new(); + for (i, chunk) in keys.chunks(GLYPHS_PER_FONT).enumerate() { + let font = type3_font(chunk, &outlines, store); + let font = store(Object::Dict(font)); + let number = font.as_ref().map_or(i + 1, |r| r.num as usize); + let mut name = format!("{prefix}{number}"); + let mut suffix = 0usize; + while taken_names.contains(name.as_bytes()) { + suffix = suffix.saturating_add(1); + name = format!("{prefix}{number}_{suffix}"); + } + taken_names.insert(name.clone().into_bytes()); + fonts.set(name.clone().into_bytes(), font); + names.push(name); + } + Ok(ShapedFonts { index, names }) + } + + /// The font resource name and code that show `c`, if it was one of the clusters. + pub fn code(&self, c: &ShapedCluster) -> Option<(&str, u8)> { + let &k = self.index.get(&cluster_key(c))?; + let name = self.names.get(k / GLYPHS_PER_FONT)?; + // Codes start at 1; k % 240 + 1 is at most 240. + Some((name.as_str(), u8::try_from(k % GLYPHS_PER_FONT + 1).ok()?)) + } + + /// Show `clusters` inside `BT … ET` from `pen` on baseline `y` at `size`, each placed with its + /// own `Tm`, `spacing` after each and `word_spacing` more after a space. `current` is the + /// shaped font the text state has selected (`None` after another font); returns the pen + /// after the clusters. + #[allow(clippy::too_many_arguments)] + pub fn show( + &self, + out: &mut Vec, + clusters: &[ShapedCluster], + mut pen: f64, + y: f64, + size: f64, + spacing: f64, + word_spacing: f64, + current: &mut Option, + ) -> f64 { + for c in clusters { + let Some(&k) = self.index.get(&cluster_key(c)) else { continue }; + let Some(name) = self.names.get(k / GLYPHS_PER_FONT) else { continue }; + let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); + if *current != Some(k / GLYPHS_PER_FONT) { + out.extend(format!("/{name} {} Tf ", n(size)).bytes()); + *current = Some(k / GLYPHS_PER_FONT); + } + out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); + out.extend(literal(&[code])); + out.extend_from_slice(b" Tj\n"); + pen += c.advance * size + spacing + if c.text == " " { word_spacing } else { 0.0 }; + } + pen + } + + /// Show a line's `pieces` inside `BT … ET` from `x` on baseline `y` at `size`: shaped clusters + /// in their Type 3 fonts, the rest in the font `latin` (which the text state has selected when + /// `current` is `None`), written by `encode` and `measure`d. `current` is as for [`Self::show`]. + #[allow(clippy::too_many_arguments)] + pub fn show_line( + &self, + out: &mut Vec, + pieces: &[Piece], + x: f64, + y: f64, + size: f64, + latin: &str, + encode: impl Fn(&str) -> Vec, + measure: impl Fn(&str) -> f64, + current: &mut Option, + ) { + let mut pen = x; + for p in pieces { + match p { + Piece::Latin(s) => { + if current.take().is_some() { + out.extend(format!("/{latin} {} Tf ", n(size)).bytes()); + } + out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); + out.extend(literal(&encode(s))); + out.extend_from_slice(b" Tj\n"); + pen += measure(s); + } + Piece::Shaped(clusters) => pen = self.show(out, clusters, pen, y, size, 0.0, 0.0, current), + } + } + } +} + +/// `lines` as a generated appearance not yet in a document draws them: each line's pieces, its +/// Thai shaped, and the Type 3 fonts for that Thai built into `fonts` (the form's font resources) +/// as direct objects, whose streams [`store_streams`] moves into the document with the +/// appearance. Thai the face can't draw, and every line when a glyph can't be read, is left to the +/// standard font, whose encoder writes `?` for it; [`first_undrawable`] reports that. +pub fn appearance_lines(lines: &[String], fonts: &mut Dict) -> (Vec>, ShapedFonts) { + let plain = |l: &String| vec![Piece::Latin(l.clone())]; + let pieces: Vec> = + lines.iter().map(|l| if l.chars().any(is_thai) { thai_pieces(l).unwrap_or_else(|_| plain(l)) } else { plain(l) }).collect(); + match ShapedFonts::new(clusters(pieces.iter().map(Vec::as_slice)), shaped_glyph, &Dict::new(), fonts, "PCTh", &mut |o| o).map_err(|(e, _)| e) { + Ok(shaped) => (pieces, shaped), + Err(_) => (lines.iter().map(plain).collect(), ShapedFonts::default()), + } +} + +/// How far the Thai glyphs of `line` reach below and above its baseline, in em units; `None` +/// without Thai, or with Thai the face can't draw. +pub fn thai_reach(line: &str) -> Option<[f64; 2]> { + if !line.chars().any(is_thai) { + return None; + } + let pieces = thai_pieces(line).ok()?; + let mut reach: Option<[f64; 2]> = None; + for c in clusters([pieces.as_slice()]) { + for (id, [_, dy]) in &c.glyphs { + let g = shaped_glyph(c.script, *id).ok()?; + if !g.contours.is_empty() { + let b = [g.bbox[1] + dy, g.bbox[3] + dy]; + reach = Some(reach.map_or(b, |r| [r[0].min(b[0]), r[1].max(b[1])])); + } + } + } + reach +} + +/// The first character of `s` a generated appearance would draw as `?`: one the standard fonts +/// (WinAnsiEncoding) can't draw and that isn't Thai, or Thai on a line the Thai face can't draw +/// (this build has none, or it lacks a character). Line breaks don't count. +pub fn first_undrawable(s: &str) -> Option { + s.split(['\n', '\r']).find_map(first_undrawable_in_line) +} + +/// [`first_undrawable`] for one line drawn as it is: a line break in it counts. +pub fn first_undrawable_in_line(line: &str) -> Option { + let thai_fails = line.chars().any(is_thai) && thai_pieces(line).is_err(); + line.chars().find(|c| if is_thai(*c) { thai_fails } else { !crate::win_ansi_encodable(*c) }) +} + +/// Why the Thai in `text` can't be drawn ([`thai_message`]): this build has no Thai face, or the +/// face lacks a character. For tools that refuse such text rather than write `?`; `None` when all +/// of its Thai draws, or it has none. +pub fn thai_refusal(text: &str) -> Option { + text.split(['\n', '\r']).filter(|l| l.chars().any(is_thai)).find_map(|l| thai_pieces(l).err().map(|e| thai_message(e, l))) +} + +/// [`type3_font`] written into `doc`, with its glyphs, descriptor and ToUnicode as objects of their +/// own: for content that draws `clusters` (at most [`GLYPHS_PER_FONT`]) as codes 1… directly. +pub fn add_type3_font( + doc: &mut Document, + clusters: &[&ShapedCluster], + outlines: &HashMap<(ShapedScript, u32), GlyphOutline>, +) -> pdfcraft_cos::ObjRef { + let font = type3_font(clusters, outlines, &mut |o| Object::Ref(doc.add(o))); + doc.add(Object::Dict(font)) +} + +/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`. +fn type3_font(clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>, store: &mut dyn FnMut(Object) -> Object) -> Dict { + let mut charprocs = Dict::new(); + let mut differences = vec![Object::Int(1)]; + let mut widths = Vec::with_capacity(clusters.len()); + let mut bbox = [0.0f64; 4]; + let mut to_unicode = Vec::new(); + for (i, c) in clusters.iter().enumerate() { + let code = i + 1; + // Glyph space is 1000 units per em. + let mut paths = Vec::new(); + let mut ink: Option<[f64; 4]> = None; + for (id, [dx, dy]) in &c.glyphs { + let Some(g) = outlines.get(&(c.script, *id)) else { continue }; + for contour in &g.contours { + for (j, p) in contour.iter().enumerate() { + paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); + } + paths.extend_from_slice(b"h\n"); + let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; + ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); + } + } + // Rounded outwards so no ink is clipped. + let [x0, y0, x1, y1] = + ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); + bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; + let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); + if !paths.is_empty() { + proc.extend(paths); + proc.extend_from_slice(b"f\n"); + } + let glyph = format!("g{code:02X}"); + charprocs.set(glyph.clone().into_bytes(), store(Object::Stream(Stream::flate(Dict::new(), &proc)))); + differences.push(Object::name(&glyph)); + widths.push(Object::Real((c.advance * 1000.0).round())); + if !c.text.is_empty() { + let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + to_unicode.push(format!("<{code:02X}> <{hex}>\n")); + } + } + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", + ); + // A CMap block holds at most 100 entries. + for block in to_unicode.chunks(100) { + cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + let cmap = store(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + // The face's family when every glyph comes from one script; without the face, the script's + // name (as Arabic items were always named). + let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) { + Some(script) => script.font().map_or( + match script { + ShapedScript::Arabic => "Arabic", + ShapedScript::Hebrew => "Hebrew", + ShapedScript::Thai => "Thai", + }, + |f| f.family, + ), + None => "PdfCraft Shaped Text", + }; + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); + descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let mut encoding = Dict::new(); + encoding.set(b"Type".to_vec(), Object::name("Encoding")); + encoding.set(b"Differences".to_vec(), Object::Array(differences)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type3")); + font.set(b"FontDescriptor".to_vec(), store(Object::Dict(descriptor))); + font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); + font.set( + b"FontMatrix".to_vec(), + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ); + font.set(b"FirstChar".to_vec(), Object::Int(1)); + font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); + font.set(b"Widths".to_vec(), Object::Array(widths)); + font.set(b"Encoding".to_vec(), Object::Dict(encoding)); + font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + font.set(b"ToUnicode".to_vec(), cmap); + font +} + +/// Most levels of dictionaries and arrays [`store_streams`] looks into: a Type 3 font in a form's +/// resources is four deep (`/Font` → font → `/CharProcs` → glyph). +const STREAM_DEPTH: usize = 8; + +/// Move the streams written into `d` (the fonts [`ShapedFonts::new`] built for an appearance not +/// yet in a document) into `doc` as objects of their own, leaving references: a stream is never a +/// direct object (ISO 32000-2 §7.3.8). +pub fn store_streams(doc: &mut Document, d: &mut Dict) { + fn walk(doc: &mut Document, o: &mut Object, depth: usize) { + if depth > STREAM_DEPTH { + return; + } + match o { + Object::Stream(_) => { + let Object::Stream(mut s) = std::mem::replace(o, Object::Null) else { return }; + walk_dict(doc, &mut s.dict, depth + 1); + *o = Object::Ref(doc.add(Object::Stream(s))); + } + Object::Dict(d) => walk_dict(doc, d, depth + 1), + Object::Array(a) => a.iter_mut().for_each(|o| walk(doc, o, depth + 1)), + _ => {} + } + } + fn walk_dict(doc: &mut Document, d: &mut Dict, depth: usize) { + let keys: Vec> = + d.iter().filter(|(_, v)| matches!(v, Object::Stream(_) | Object::Dict(_) | Object::Array(_))).map(|(k, _)| k.clone()).collect(); + for k in keys { + if let Some(v) = d.get_mut(&k) { + walk(doc, v, depth); + } + } + } + walk_dict(doc, d, 0); +} + +/// Format a number for content streams (up to three decimals). +fn n(v: f64) -> String { + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn boxed(width: f64) -> GlyphOutline { + GlyphOutline { contours: vec![vec![[0.0, -0.4], [width, -0.4], [width, 1.1], [0.0, 1.1]]], width, bbox: [0.0, -0.4, width, 1.1] } + } + + fn cluster(text: &str, id: u32) -> ShapedCluster { + ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: 0.5, text: text.into() } + } + + #[test] + fn appearance_fonts_keep_their_streams_until_stored_then_refer_to_them() { + let clusters = [cluster("น้ำ", 1), cluster("ก", 2), cluster("น้ำ", 1)]; + let mut fonts = Dict::new(); + let made = ShapedFonts::new(&clusters, |_, _| Ok(boxed(0.5)), &Dict::new(), &mut fonts, "PCTh", &mut |o| o).unwrap(); + assert_eq!(made.code(&clusters[0]), Some(("PCTh1", 1))); + assert_eq!(made.code(&clusters[1]), Some(("PCTh1", 2)), "a repeated cluster is one glyph"); + let mut line = Vec::new(); + let pieces = [Piece::Latin("a".into()), Piece::Shaped(clusters[..1].to_vec()), Piece::Latin("b".into())]; + made.show_line(&mut line, &pieces, 0.0, 0.0, 10.0, "Helv", |s| s.as_bytes().to_vec(), |_| 1.0, &mut None); + let shown = String::from_utf8_lossy(&line).into_owned(); + assert_eq!(shown, "1 0 0 1 0 0 Tm (a) Tj\n/PCTh1 10 Tf 1 0 0 1 1 0 Tm (\u{1}) Tj\n/Helv 10 Tf 1 0 0 1 6 0 Tm (b) Tj\n"); + let mut out = Vec::new(); + let mut current = None; + let pen = made.show(&mut out, &clusters, 10.0, 20.0, 10.0, 0.0, 0.0, &mut current); + assert_eq!(pen, 25.0); + assert_eq!(String::from_utf8_lossy(&out).matches(" Tf").count(), 1, "one font selection for one font"); + let mut doc = Document::new_empty(); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + store_streams(&mut doc, &mut res); + let font = res.get(b"Font").and_then(Object::as_dict).and_then(|f| f.get(b"PCTh1")).and_then(Object::as_dict).unwrap(); + assert!(matches!(font.get(b"ToUnicode"), Some(Object::Ref(_)))); + let procs = font.get(b"CharProcs").and_then(Object::as_dict).unwrap(); + assert!(procs.iter().count() == 2 && procs.iter().all(|(_, v)| matches!(v, Object::Ref(_)))); + } + + #[test] + fn a_missing_glyph_names_its_cluster_and_writes_nothing() { + let clusters = [cluster("ก", 2)]; + let mut fonts = Dict::new(); + let mut stored = 0; + let made = ShapedFonts::new(&clusters, |_, _| Err(GlyphError::Missing), &Dict::new(), &mut fonts, "PCTh", &mut |o| { + stored += 1; + o + }); + assert!(matches!(made, Err((GlyphError::Missing, c)) if c.text == "ก")); + assert_eq!((stored, fonts.iter().count()), (0, 0)); + } + + #[test] + fn thai_runs_are_shaped_and_the_rest_left_to_the_standard_font() { + assert_eq!(thai_pieces("Hello").unwrap(), [Piece::Latin("Hello".into())]); + assert!(joins_previous('\u{0E49}') && joins_previous('\u{0E33}') && !joins_previous('ก') && !joins_previous('a')); + assert_eq!((first_undrawable("Café\nпривет"), thai_refusal("Café"), thai_reach("Hg")), (Some('п'), None, None)); + if crate::document_thai_font().is_none() { + assert_eq!(thai_pieces("น้ำ 1"), Err(GlyphError::NoFont)); + assert!(thai_message(GlyphError::NoFont, "").contains("this build has none")); + assert_eq!(thai_width("น้ำ", 10.0, |s, size| s.chars().count() as f64 * size), 30.0, "measured by the standard font"); + assert_eq!(first_undrawable("ok\nชื่อ น้ำ"), Some('ช')); + assert!(thai_refusal("ok\nน้ำ").is_some_and(|m| m.contains("CRAFT_FONTS_DIR"))); + return; + } + assert_eq!((first_undrawable("ชื่อ: น้ำ\nOK"), thai_refusal("ชื่อ: น้ำ")), (None, None)); + assert_eq!(first_undrawable("น้ำ ☃"), Some('☃')); + // Stacked marks reach above the em box's 0.9, a descender below its 0.3. + let [_, top] = thai_reach("น้ำ").unwrap(); + let [bottom, _] = thai_reach("ฎู").unwrap(); + assert!(top > 0.95 && bottom < -0.35, "{top} {bottom}"); + let pieces = thai_pieces("ชื่อ: บุญฤทธิ์ 12").unwrap(); + assert!(matches!(&pieces[..], [Piece::Shaped(_), Piece::Latin(a), Piece::Shaped(_), Piece::Latin(b)] if a == ": " && b == " 12")); + let text: String = clusters([pieces.as_slice()]).map(|c| c.text.as_str()).collect(); + assert_eq!(text, "ชื่อบุญฤทธิ์"); + assert!(thai_width("น้ำ", 10.0, |_, _| 0.0) > 0.0); + } +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 0c8f5ad9b9..6126dc1cbd 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -45,7 +45,10 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table (cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined - Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold + Unicode CMap; otherwise Helvetica (WinAnsi). Thai in a text field or combo box that the font + can't hold is drawn from the craft-fonts Thai face with Add text's writer + (`pdfcraft_fonts::shaped_text`), beside the field's font, keeping `/DA`; a build without a Thai + face refuses it. Any other value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn) 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 8403b729d9..891a533eaa 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -23,6 +23,7 @@ use pdfcraft_cos::{Dict, Document, Object, Stream}; use pdfcraft_fonts::pdf::Metrics; +use pdfcraft_fonts::shaped_text::{self, Piece, ShapedFonts}; use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with}; use crate::{Field, FieldKind, Widget, acroform, flags}; @@ -398,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))), @@ -747,6 +756,21 @@ fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool { enc.first_unencodable(text).is_none() } +/// Why text field `f` can't take `text`: it has Thai that a widget's font can't hold and this +/// build can't draw (it has no Thai face, or the face lacks a character), which would come out +/// as `?`. `None` for text it can draw, and for a password field, which shows `*`. +pub(crate) fn thai_refusal(doc: &Document, f: &Field, text: &str) -> Option { + if f.has(flags::PASSWORD) || !text.chars().any(shaped_text::is_thai) { + return None; + } + let held = |w: &Widget| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + dr_font(doc, &da.font).is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && text.split(['\n', '\r']).all(|l| enc.can_encode(l))) + }; + if f.widgets.iter().all(held) { None } else { shaped_text::thai_refusal(text) } +} + /// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { let wobj = doc.get(w.obj); @@ -778,36 +802,43 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] values.first().cloned().unwrap_or_default() }; let text = if f.has(flags::PASSWORD) { "*".repeat(text.chars().count()) } else { text }; - let can_show = |enc: &FontText| { + let can_show = |enc: &FontText, text: &str| { if f.kind == FieldKind::List { f.options.iter().all(|(_, display)| enc.can_encode(display)) } else if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { text.split(['\n', '\r']).all(|line| enc.can_encode(line)) } else { - enc.can_encode(&text) + enc.can_encode(text) } }; - let (font_name, font_obj, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| can_show(enc)) { + let dr = dr_font(doc, &da.font); + // Thai the form's font can't hold (it isn't a simple font encoding it) is drawn from the Thai + // face, as Add text draws it: Type 3 glyphs beside the form's font, which draws the rest. One + // line of a text field or combo box at a time; the `/DA` is kept. + let thai = matches!(f.kind, FieldKind::Text | FieldKind::Combo) + && !f.is_comb() + && text.chars().any(shaped_text::is_thai) + && !dr.as_ref().is_some_and(|(_, enc)| matches!(enc, FontText::Simple(_)) && can_show(enc, &text)) + && shaped_text::thai_refusal(&text).is_none(); + let latin_text: String = if thai { text.chars().filter(|c| !shaped_text::is_thai(*c)).collect() } else { text.clone() }; + let (font_name, font_obj, enc) = match dr.filter(|(_, enc)| can_show(enc, &latin_text)) { Some((o, enc)) => (da.font.clone(), o, enc), None => ("Helv".to_string(), helvetica(), FontText::WinAnsi), }; - let width_of = |text: &str, size: f64| enc.width(text, size); + let width_of = + |text: &str, size: f64| if thai { shaped_text::thai_width(text, size, |s, size| enc.width(s, size)) } else { enc.width(text, size) }; + // How far a line's ink reaches below and above its baseline (em), where Thai stacks or + // descenders reach past the form font's (Helvetica's) -0.207 and 0.718; `None` otherwise. + let thai_ink = |line: &str| thai.then(|| shaped_text::thai_reach(line)).flatten().map(|[lo, hi]| [lo.min(-0.207), hi.max(0.718)]); let (mut c, bw) = frame(doc, &wd, width, height); let pad = 2.0 + bw; let inner_w = (width - 2.0 * pad).max(1.0); let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding); - let mut body: Vec = Vec::new(); - // The first character drawn that the font can't hold (drawn as `?`), for the caller to report. - let mut unencodable: Option = None; - let mut show = |body: &mut Vec, x: f64, y: f64, text: &str| { + // The lines to draw (where, and what), drawn once all are known: their Thai shares fonts. + let mut shown: Vec<(f64, f64, String)> = Vec::new(); + let mut show = |x: f64, y: f64, text: &str| { // A line break in a single-line value is a space, as in viewers (multiline text is split). - let text: String = text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); - if unencodable.is_none() { - unencodable = enc.first_unencodable(&text); - } - body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - body.extend(literal(&enc.encode(&text))); - body.extend_from_slice(b" Tj\n"); + shown.push((x, y, text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect())); }; let x_for = |text: &str, size: f64| -> f64 { let tw = width_of(text, size); @@ -834,7 +865,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] if values.contains(export) { c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line))); } - show(&mut body, pad, y - line + size * 0.25, display); + show(pad, y - line + size * 0.25, display); y -= line; } } @@ -844,18 +875,20 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } - let mut y = height - pad - size * 0.85; let lines = wrap_with(&text, size, inner_w, width_of); + // The first line hangs from the top by its ascent: higher for stacked Thai marks. + let mut y = height - pad - size * lines.first().and_then(|l| thai_ink(l)).map_or(0.85, |[_, hi]| hi.max(0.85)); // The lines whose descent stays inside the border; the rest are cut off. let mut room = 0; for line in &lines { if y < -size { break; } - if y - 0.207 * size >= bw - 0.01 { + let descent = thai_ink(line).map_or(0.207, |[lo, _]| -lo); + if y - descent * size >= bw - 0.01 { room += 1; } - show(&mut body, x_for(line, size), y, line); + show(x_for(line, size), y, line); y -= size * 1.15; } if room < lines.len() { @@ -872,12 +905,22 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; } - // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. - let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // Centre Helvetica's ascent (0.718) and descent (0.207) vertically, or the reach of + // Thai stacks and descenders, which an automatic size also keeps inside the padding. + let reach = thai_ink(&text); + if let Some([lo, hi]) = reach + && da.size == 0.0 + { + size = size.min((height - 2.0 * pad) / (hi - lo)).max(4.0); + } + let (y, ink) = match reach { + Some([lo, hi]) => ((height - (hi - lo) * size) / 2.0 - lo * size, (hi - lo) * size), + None => ((height - 0.925 * size) / 2.0 + 0.207 * size, 0.925 * size), + }; // The clip box is inside the border; a line taller than it is cut at the top and bottom. let room_h = height - 2.0 * bw; - if 0.925 * size > room_h + 0.01 { - overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + if ink > room_h + 0.01 { + overflow = Some(Overflow::High { height: ink, room: room_h, size }); } if comb { let cells = f.max_len.unwrap_or(1).max(1); @@ -885,18 +928,19 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] c.push_str(&comb_dividers(doc, &wd, width, height, cells)); for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); - show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); + show(cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { let tw = width_of(&text.replace(['\n', '\r'], " "), size); if overflow.is_none() && tw > inner_w + 0.01 { overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); } - show(&mut body, x_for(&text, size), y, &text); + show(x_for(&text, size), y, &text); } } } } + let (body, fonts, unencodable) = draw_lines(&shown, size, &font_name, font_obj, &enc, thai); // Acrobat regenerates only what is inside /Tx BMC when it redraws a field (e.g. on focus // changes), discarding what came before it (issues #1613, #633). The background and border // therefore go inside the marked-content block too, so a redraw keeps them with the text. @@ -913,8 +957,6 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] .into_bytes(); content.extend(body); content.extend_from_slice(b"ET\nQ\nEMC\n"); - let mut fonts = Dict::new(); - fonts.set(font_name.into_bytes(), font_obj); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); let mut d = Dict::new(); @@ -927,6 +969,31 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] (Stream::flate(d, &content), unencodable, overflow) } +/// The text operators for the `shown` lines (where each starts, and what it says) at `size`, in +/// the field's font `font_name` (`font`, written with `enc`) and, with `thai`, their Thai from the +/// Thai face; the font resources they use, and the first character drawn as `?` because the +/// font can't hold it, for the caller to report. +fn draw_lines(shown: &[(f64, f64, String)], size: f64, font_name: &str, font: Object, enc: &FontText, thai: bool) -> (Vec, Dict, Option) { + let mut fonts = Dict::new(); + fonts.set(font_name.as_bytes().to_vec(), font); + let lines: Vec = shown.iter().map(|(_, _, text)| text.clone()).collect(); + let (pieces, shaped) = if thai { + shaped_text::appearance_lines(&lines, &mut fonts) + } else { + (lines.into_iter().map(|l| vec![Piece::Latin(l)]).collect(), ShapedFonts::default()) + }; + let mut body = Vec::new(); + let mut current = None; + for ((x, y, _), pieces) in shown.iter().zip(&pieces) { + shaped.show_line(&mut body, pieces, *x, *y, size, font_name, |s| enc.encode(s), |s| enc.width(s, size), &mut current); + } + let unencodable = pieces.iter().flatten().find_map(|p| match p { + Piece::Latin(s) => enc.first_unencodable(s), + Piece::Shaped(_) => None, + }); + (body, fonts, unencodable) +} + /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). /// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index 02c1eec5bc..0db605be6b 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -75,6 +75,9 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance } }; let target = copy.get_or_insert_with(|| doc.clone()); + let mut stream = stream; + // Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(target, &mut stream.dict); let ap = target.add(Object::Stream(stream)); let mut fresh = Dict::new(); match &state { diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 23661ed70a..325a1ec3ad 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -323,8 +323,10 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); missing = m; + // Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict); let r = doc.add(Object::Stream(s)); let mut d = Dict::new(); d.set(b"N".to_vec(), Object::Ref(r)); diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 34775ee75e..16bdae314d 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -106,12 +106,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 +338,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 +364,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 +432,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 +528,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 +551,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..93ed2d7c7a 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(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 3948e06e83..09564c2dee 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] @@ -967,6 +1077,61 @@ fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() { assert!(need_appearances(&doc)); } +/// Thai typed into a field whose `/DA` font (Helvetica, WinAnsi) can't hold it is drawn from the +/// Thai face as Add text draws it: Type 3 glyphs (stream objects of their own, with a ToUnicode +/// map) beside the field's font, which draws the rest, so every viewer shows the same glyphs. The +/// `/DA` is kept, nothing is marked and other viewers aren't asked to redraw. The ink of stacked +/// marks and descenders stays inside the border, an automatic size shrinking to keep it there. +/// A build without a Thai face refuses the text, saying why, and keeps the old value. +#[test] +fn thai_a_field_font_cannot_hold_is_drawn_from_the_thai_face() { + 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 [6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 0 Tf 0 g) /V (Ada) /Rect [50 700 250 720] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (big) /DA (/Helv 16 Tf 0 g) /Rect [50 600 350 640] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /Ff 4096 /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 500] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let thai = "ชื่อ: บุญฤทธิ์ น้ำฎู"; + if pdfcraft_fonts::document_thai_font().is_none() { + let err = set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap_err(); + assert!(matches!(&err, FormError::Invalid(m) if m.contains("Thai") && m.contains("CRAFT_FONTS_DIR")), "{err}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + assert!(!need_appearances(&doc) && undrawn_fields(&doc).is_empty()); + return; + } + set_value(&mut doc, "name", &FieldValue::Text(thai.into())).unwrap(); + set_value(&mut doc, "big", &FieldValue::Text(thai.into())).unwrap(); + set_value(&mut doc, "note", &FieldValue::Text(format!("น้ำฎู {thai}\nปั้น ฎู"))).unwrap(); + let doc = reopen(&doc); + assert!(undrawn_fields(&doc).is_empty() && !need_appearances(&doc)); + let (lo, hi) = (pdfcraft_fonts::shaped_text::thai_reach("ฎู").unwrap()[0], pdfcraft_fonts::shaped_text::thai_reach("น้ำ").unwrap()[1]); + for (name, height) in [("name", 20.0), ("big", 40.0), ("note", 100.0)] { + let all = fields(&doc); + let w = &field(&all, name).widgets[0]; + let wd = doc.get(w.obj).as_dict().unwrap().clone(); + assert!(wd.get(b"DA").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().starts_with("/Helv ")), "the /DA is kept"); + let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + let ap = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(!ap.contains('?') && ap.contains("/PCTh1 ") && ap.contains("(: ) Tj"), "{ap}"); + let fonts = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).unwrap(); + let font = fonts.get(b"PCTh1").and_then(|f| f.as_dict()).unwrap(); + assert_eq!(font.name(b"Subtype"), Some(&b"Type3"[..])); + let Object::Stream(cmap) = &*doc.get(font.reference(b"ToUnicode").unwrap()) else { panic!("ToUnicode is a stream object") }; + assert!(String::from_utf8_lossy(&cmap.decoded().unwrap()).contains("<0E190E490E33>"), "น้ำ copies as typed"); + // The size each line is drawn at, and where: every line's ink is inside the box. + let size: f64 = ap.split(" Tf").next().and_then(|s| s.rsplit(' ').next()).and_then(|s| s.parse().ok()).unwrap(); + let ys: Vec = ap.split(" Tm").filter_map(|s| s.rsplit(' ').next()?.parse().ok()).collect(); + let (top, bottom) = (ys.iter().copied().fold(f64::MIN, f64::max), ys.iter().copied().fold(f64::MAX, f64::min)); + assert!(top + hi * size <= height + 1e-3 && bottom + lo * size >= -1e-3, "{name}: {ys:?} at {size} in {height}"); + } +} + /// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids /// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off. #[test] @@ -1519,7 +1684,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(); @@ -1778,6 +1943,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. @@ -2933,20 +3151,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 } } } } @@ -2967,6 +3187,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(); @@ -5651,3 +5926,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 9d670e62db..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 { @@ -206,6 +241,11 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr { return invalid(format!("{:?} takes at most {max} characters", f.name)); } + // Thai this build can't draw is refused (before any script runs), rather than stored + // and drawn as `?`. + if let Some(why) = appearance::thai_refusal(doc, f, t) { + return invalid(format!("{:?} can't take this text: {why}", f.name)); + } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; @@ -275,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()), @@ -430,7 +470,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc // fonts can't draw the date language's (#5303). let mut english: Option> = None; for w in &f.widgets { - let (stream, missing) = match &shown { + let (mut stream, missing) = match &shown { Some(s) => { let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false); // Taken when the English run happened and draws whole, even where it is the @@ -447,6 +487,8 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); + // Thai glyphs are streams of their own. + pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict); let existing_ap = in_place .then(|| { doc.get(w.obj) 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/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/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..10388b8107 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. @@ -1330,7 +1342,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 +1359,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 +1437,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 +1644,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 +1792,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/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/import.rs b/crates/organize/src/import.rs index b52c9175b3..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(); @@ -480,6 +511,64 @@ pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRe Ok(()) } +/// The objects `value` (of `src`) refers to, directly or through others, each once whatever the +/// cycles: what [`import_value`] copies. Refused past `max_objects` objects or `max_bytes` bytes +/// of stream data, with a message that says how much there is. +pub fn reachable(src: &Document, value: &Object, max_objects: usize, max_bytes: usize) -> Result, OrganizeError> { + let mut seen = HashSet::new(); + let mut found = Vec::new(); + let mut bytes = 0usize; + // The values still to look into: owned, or an object of `src`. + let mut pending: Vec> = vec![std::sync::Arc::new(value.clone())]; + while let Some(next) = pending.pop() { + let mut refs = Vec::new(); + let mut inside: Vec<&Object> = vec![&next]; + while let Some(o) = inside.pop() { + match o { + Object::Ref(r) => refs.push(*r), + Object::Array(a) => inside.extend(a.iter()), + Object::Dict(d) => inside.extend(d.iter().map(|(_, v)| v)), + Object::Stream(s) => { + bytes = bytes.saturating_add(s.raw.len()); + inside.extend(s.dict.iter().map(|(_, v)| v)); + } + _ => {} + } + } + for r in refs { + if seen.insert(r) { + found.push(r); + pending.push(src.get(r)); + } + } + if found.len() > max_objects || bytes > max_bytes { + return Err(OrganizeError::Invalid(format!( + "it uses more than {max_objects} objects or {} MB of fonts, images and other data; copy fewer objects at a time", + max_bytes >> 20 + ))); + } + } + Ok(found) +} + +/// Copy `value` from `src` into `dst` with every object it refers to, each once (cycles end), as +/// Insert Pages copies a page's resources; what equals an object already in `dst` (the same font +/// or image) is shared rather than stored twice. Returns `value` as `dst` refers to it. A chain +/// nested more than 256 levels deep is refused; [`reachable`] bounds the size first. +pub fn import_value(dst: &mut Document, src: &Document, value: &Object) -> Result { + let before: HashSet = dst.object_numbers().into_iter().collect(); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + let copied = copier.copy_value(dst, value, None)?; + // Held in an array object while duplicates merge (an array is never a resource that merges + // itself away), so the references in it follow them. + let holder = dst.add(Object::Array(vec![copied])); + let created = added_objects(dst, &before); + crate::dedupe::dedupe_resources(dst, &created, true); + let held = dst.get(holder).as_array().and_then(|a| a.first().cloned()).unwrap_or(Object::Null); + dst.free(holder); + Ok(held) +} + /// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations), /// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box /// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index 5e4be54150..8fed2112b6 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,6 +99,9 @@ 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), @@ -112,6 +121,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 +133,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 +175,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 +263,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/lib.rs b/crates/organize/src/lib.rs index 3b3d56cd83..d85cd15645 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -33,8 +33,8 @@ pub use comments::import_comments; pub use dedupe::dedupe_resources; pub use import::{ CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with, - combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, insert_file_pages, - page_as_form, split, split_ranges, + combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, import_value, + insert_file_pages, page_as_form, reachable, split, split_ranges, }; pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7eb677a84c..593efd2600 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -4241,3 +4241,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/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..fffab083af 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,10 +45,10 @@ 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); } match t.parse::() { @@ -27,12 +61,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 +86,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 +111,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)?); + } } } } @@ -129,4 +180,31 @@ mod tests { 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/elsewhere.rs b/crates/redact/src/elsewhere.rs index 4ebac7bad9..42ddbcf5bc 100644 --- a/crates/redact/src/elsewhere.rs +++ b/crates/redact/src/elsewhere.rs @@ -184,7 +184,9 @@ fn comments(doc: &Document, search: &Search, hit: &dyn Fn(Option<&Object>) -> bo fn rich_text(doc: &Document, o: &Object) -> Option { let xml = match &*doc.resolve(o) { Object::String(s) => crate::metadata::text_string(s), - Object::Stream(s) if s.filters().len() <= 1 => String::from_utf8(s.decoded_first_filter_strict(crate::metadata::MAX_XMP_BYTES).ok()?).ok()?, + Object::Stream(s) if s.filters().len() <= 1 => { + String::from_utf8(doc.with_direct_parms(s).decoded_first_filter_strict(crate::metadata::MAX_XMP_BYTES).ok()?).ok()? + } Object::Stream(_) => return None, _ => return Some(String::new()), }; diff --git a/crates/redact/src/metadata.rs b/crates/redact/src/metadata.rs index f579aeb8e2..a2a1605f25 100644 --- a/crates/redact/src/metadata.rs +++ b/crates/redact/src/metadata.rs @@ -388,7 +388,8 @@ pub(crate) fn xmp_text(doc: &Document, o: &Object) -> Option { if s.filters().len() > 1 { return None; } - let bytes = s.decoded_first_filter_strict(MAX_XMP_BYTES).ok()?; + // `/DecodeParms` given by reference are read too ([`Document::with_direct_parms`]). + let bytes = doc.with_direct_parms(s).decoded_first_filter_strict(MAX_XMP_BYTES).ok()?; decode_xmp(&bytes) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index c9a596e128..b458cacdfc 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 @@ -505,7 +591,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() @@ -543,6 +629,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), }; @@ -551,6 +638,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 @@ -669,6 +784,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 0e42d7a77d..657ca463ef 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3253,6 +3253,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. @@ -4917,3 +4984,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/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 7c4543b733..1609ad7e75 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -22,6 +22,31 @@ pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20; /// 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") @@ -340,14 +380,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 +406,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 +445,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 +527,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 +535,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 +555,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..a7a1d5ba7d 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::{ @@ -144,13 +144,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)) @@ -210,12 +213,13 @@ impl<'a> Inspector<'a> { let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; 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 +235,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); } } } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 907401f8bb..a2e559ac44 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] @@ -1932,3 +2010,64 @@ 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); +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 9331c82ea7..f99ae74c64 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 { @@ -355,16 +359,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(); diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index be232d80eb..28339c4dc5 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -4073,6 +4073,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] @@ -5796,6 +5813,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] @@ -8983,6 +9086,10 @@ mod devicen_inks; // #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 35). 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; 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/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/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/tests/it/main.rs b/crates/render/tests/it/main.rs index 7e8307d908..591fc79b3e 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -13,6 +13,7 @@ mod search; mod separation_all; mod stray_q_in_a_form; mod stroke_degenerate_cubic; +mod stroke_zero_end_tangent; mod substitute_widths; mod tiling_cell_placement; mod type3_text_clip; 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/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