diff --git a/README.md b/README.md index f488d160cc..7ca789c6e0 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo adds it, as a first install would. Use the MSI for your architecture. Per-user installation overrides are not supported. +To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the +welcome screen (type it, or Browse…) or pass it on the command line: +`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1` +on the command line for everyone). An upgrade keeps the folder the installed version uses unless +the command line gives another one. + **Pin the checksum, not just the version.** Release candidates of a version share its MSI version (MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't diff --git a/ROADMAP.md b/ROADMAP.md index ee91096427..a15fb5d69f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. +- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way. + +- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`. + - **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged. - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged. @@ -445,6 +449,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. +- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. - **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index c64f9e1c0f..86b31bafd3 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -421,6 +421,12 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); } + pdfcraft_render::RenderWarning::Type3WidthsMissing => { + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give" + ); + } } } Ok(()) diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 751785d0c5..448eb40d68 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -4,8 +4,9 @@ //! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe ""`, and //! a file manager or `xdg-open` starts `pdfcraft ` the same way, so until now every file got //! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10 -//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy, -//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same +//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft` (a portable copy's too, as +//! `portable-instance-…` and never in its own folder, #889) and is named after the Windows +//! session (`instance-s1.sock` for session 1), so two sign-ins of the same //! user never open files in each other's windows. On Linux and the BSDs it is in //! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a //! launch on another display or seat opens its own window there. Either way it lives in a folder diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 7cf4328c7c..e2199e4d23 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -8,13 +8,77 @@ use crate::{AnnotError, annots, page_ref, page_refs, set_annots}; /// Where a link goes. #[derive(Clone, Debug, PartialEq)] pub enum LinkAction { - /// A page of this document (0-based), shown fitting the window (`/Fit`). - Page(usize), + /// A page of this document (0-based), shown at `zoom`. + Page { + page: usize, + zoom: LinkZoom, + }, Uri(String), /// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is. Other(String), } +/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an +/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the +/// reader keeps its position in that direction, as a new bookmark does. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LinkZoom { + /// `/Fit`: the whole page in the window. + FitPage, + /// `/FitH null`: the page's width across the window. + FitWidth, + /// `/FitV null`: the page's height down the window. + FitHeight, + /// `/FitB`: the page's content (its bounding box) in the window. + FitVisible, + /// `/XYZ null null null`: the reader's zoom is kept. + Inherit, + /// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written + /// within 1–6400%; a link rewritten with the zoom it has keeps its own factor. + Percent(f64), +} + +impl LinkZoom { + /// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The + /// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown + /// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom + /// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its + /// zoom is written (`write_action`) but not its position. + fn of_dest(doc: &Document, dest: &[Object]) -> Self { + let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec)); + match kind.as_deref() { + Some(b"FitB") => LinkZoom::FitVisible, + Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth, + Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight, + Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) { + // Finite even for a factor near f64::MAX. A link given a new page with this zoom + // keeps the factor itself, however far outside 1–6400% (`write_action`). + Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)), + // A zoom of 0 or null keeps the reader's (Table 149). + _ => LinkZoom::Inherit, + }, + _ => LinkZoom::FitPage, + } + } + + /// The destination's elements after the page reference. + fn operands(self) -> Result, AnnotError> { + Ok(match self { + LinkZoom::FitPage => vec![Object::name("Fit")], + LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null], + LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null], + LinkZoom::FitVisible => vec![Object::name("FitB")], + LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null], + LinkZoom::Percent(p) => { + if !(p.is_finite() && p > 0.0) { + return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into())); + } + vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))] + } + }) + } +} + /// Acrobat's Link Properties ▸ Appearance. #[derive(Clone, Copy, Debug, PartialEq)] pub struct LinkStyle { @@ -87,17 +151,18 @@ fn nums(doc: &Document, o: Option<&Object>) -> Vec { } fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { - let dest_page = |dest: &Object| -> Option { + let page_view = |dest: &Object| -> Option { let a = doc.resolve(dest); - let first = a.as_array()?.first()?.clone(); - match first { - Object::Ref(r) => pages.iter().position(|p| *p == r), - Object::Int(i) => usize::try_from(i).ok(), + let a = a.as_array()?; + let page = match a.first()? { + Object::Ref(r) => pages.iter().position(|p| p == r), + Object::Int(i) => usize::try_from(*i).ok(), _ => None, - } + }?; + Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) }) }; if let Some(dest) = d.get(b"Dest") { - return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page); + return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into())); } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { @@ -115,7 +180,7 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) None => LinkAction::Other("web address too long".into()), } } - Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), + Some(b"GoTo") => a.get(b"D").and_then(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), } @@ -214,12 +279,52 @@ fn uri_bytes(u: &str) -> Vec { out } +/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`. +fn current_dest(doc: &Document, d: &Dict) -> Option> { + let dest = match d.get(b"Dest") { + Some(dest) => doc.resolve(dest), + None => { + let action = doc.resolve(d.get(b"A")?); + let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?; + doc.resolve(go_to.get(b"D")?) + } + }; + dest.as_array().cloned() +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { - LinkAction::Page(p) => { - let target = page_ref(doc, *p)?; + LinkAction::Page { page, zoom } => { + let target = page_ref(doc, *page)?; + let mut operands = zoom.operands()?; + let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom); + if let Some(c) = current { + // The page and zoom the link has (`link_edit` naming its own page): the + // destination is left as it is, position on the page and GoTo form included. + let same_page = match c.first() { + Some(Object::Ref(r)) => *r == target, + Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page), + _ => false, + }; + if same_page { + return Ok(()); + } + // A new page with the zoom the link has keeps how that zoom is written: a boxed + // fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400% + // a new zoom is written within. Positions belong to the old page and are dropped. + let (slot, own) = match zoom { + LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)), + LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)), + _ => (None, None), + }; + if let (Some(slot), Some(own)) = (slot, own) { + *slot = doc.resolve(own).as_ref().clone(); + } + } + let mut dest = vec![Object::Ref(target)]; + dest.extend(operands); d.remove(b"A"); - d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); + d.set(b"Dest".to_vec(), Object::Array(dest)); } LinkAction::Uri(u) => { let u = u.trim(); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 7561938676..bfb780dabb 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1003,12 +1003,12 @@ fn stamps_draw_their_label_and_by_line() { #[test] fn links_are_added_edited_listed_and_removed() { - use crate::links::{self, Highlight, LinkAction, LinkStyle}; + use crate::links::{self, Highlight, LinkAction, LinkStyle, LinkZoom}; let mut doc = fixture(); let before = links::list(&doc).len(); let i = links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri("https://example.org".into()), &LinkStyle::default()).unwrap(); let style = LinkStyle { visible: true, color: [1.0, 0.0, 0.0], width: 2.0, dashed: true, underline: false, highlight: Highlight::Outline }; - links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page(0), &style).unwrap(); + links::add(&mut doc, 1, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &style).unwrap(); let doc = reopen(&doc); let all = links::list(&doc); assert_eq!(all.len(), before + 2); @@ -1017,7 +1017,7 @@ fn links_are_added_edited_listed_and_removed() { (web.action.clone(), web.style.visible, web.style.highlight), (LinkAction::Uri("https://example.org".into()), false, Highlight::Invert) ); - let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page(0)).unwrap().clone(); + let page = all.iter().find(|l| l.page == 1 && l.action == LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }).unwrap().clone(); assert_eq!(page.style, style); let mut doc = doc; links::set(&mut doc, 1, page.index, Some([0.0, 0.0, 50.0, 20.0]), Some(&LinkAction::Uri("mailto:ada@example.org".into())), None).unwrap(); @@ -1027,7 +1027,201 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::delete(&mut doc, 0, 999).is_err(), "no such annotation"); let n = links::remove_all(&mut doc, None).unwrap(); assert!(n >= 1 && links::list(&doc).is_empty()); - assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); + assert!( + links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }, &LinkStyle::default()).is_err(), + "too small" + ); +} + +#[test] +fn page_links_keep_their_zoom() { + use crate::links::{self, LinkAction, LinkStyle, LinkZoom}; + let page1 = page_refs(&fixture()).unwrap()[1]; + let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()); + // Each zoom is written as its destination type (Table 149) and reads back after a save. + let null = Object::Null; + for (zoom, written) in [ + (LinkZoom::FitPage, vec![Object::name("Fit")]), + (LinkZoom::FitWidth, vec![Object::name("FitH"), null.clone()]), + (LinkZoom::FitHeight, vec![Object::name("FitV"), null.clone()]), + (LinkZoom::FitVisible, vec![Object::name("FitB")]), + (LinkZoom::Inherit, vec![Object::name("XYZ"), null.clone(), null.clone(), null.clone()]), + (LinkZoom::Percent(100.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(1.0)]), + (LinkZoom::Percent(250.0), vec![Object::name("XYZ"), null.clone(), null.clone(), Object::Real(2.5)]), + ] { + let mut doc = fixture(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom }, &LinkStyle::default()).unwrap(); + let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap(); + let mut expected = vec![Object::Ref(page1)]; + expected.extend(written); + assert_eq!(dest_of(&doc, link.obj.unwrap()), Some(Object::Array(expected)), "{zoom:?}"); + let doc = reopen(&doc); + let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap(); + assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{zoom:?} after a save"); + } + // What other writers use reads as the nearest zoom: a zero zoom keeps the reader's, the boxed + // fits as the plain ones, a rectangle as Fit Page, and a GoTo action like a /Dest. + let mut doc = fixture(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + let dest = |kind: &str, operands: Vec| { + let mut d = vec![Object::Ref(page1), Object::name(kind)]; + d.extend(operands); + Object::Array(d) + }; + for (key, value, zoom) in [ + ("Dest", dest("XYZ", vec![Object::Int(100), Object::Int(700), Object::Int(0)]), LinkZoom::Inherit), + ("Dest", dest("FitBH", vec![Object::Int(500)]), LinkZoom::FitWidth), + ("Dest", dest("FitBV", vec![Object::Int(20)]), LinkZoom::FitHeight), + ("Dest", dest("FitR", vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)]), LinkZoom::FitPage), + ("A", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1.5)]), LinkZoom::Percent(150.0)), + // A factor outside what PdfCraft writes reads as the destination has it, not capped, so + // it agrees with the viewer's `destination`; one whose percentage overflows stays finite. + ("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(100.0)]), LinkZoom::Percent(10000.0)), + ("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(0.005)]), LinkZoom::Percent(0.5)), + ("Dest", dest("XYZ", vec![null.clone(), null.clone(), Object::Real(1e307)]), LinkZoom::Percent(f64::MAX)), + ] { + let mut d = doc.get(obj).as_dict().cloned().unwrap(); + d.remove(b"Dest"); + d.remove(b"A"); + if key == "A" { + let mut go_to = pdfcraft_cos::Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), value); + d.set(b"A".to_vec(), Object::Dict(go_to)); + } else { + d.set(b"Dest".to_vec(), value); + } + doc.set(obj, Object::Dict(d)); + let link = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap(); + assert_eq!(link.action, LinkAction::Page { page: 1, zoom }, "{key}"); + } + // A zoom that isn't a positive percentage is refused before anything is written. + for p in [0.0, -50.0, f64::NAN, f64::INFINITY] { + let before = links::list(&doc).len(); + let refused = + links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(p) }, &LinkStyle::default()); + assert!(refused.is_err(), "{p}"); + assert_eq!(links::list(&doc).len(), before, "{p}: no link added"); + } +} + +/// A link rewritten with the zoom it has keeps its own `/XYZ` factor, however far outside the +/// 1–6400% a new zoom is written within: giving it a new page (Link Properties' OK, `link_edit` +/// without `zoom`) must not change its zoom. A zoom chosen anew is still written within 1–6400%. +#[test] +fn a_rewritten_link_keeps_its_own_zoom_factor() { + use crate::links::{self, LinkAction, LinkStyle, LinkZoom}; + let pages = page_refs(&fixture()).unwrap(); + let dest_of = |doc: &Document, obj: ObjRef| doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()); + let xyz = |page: ObjRef, factor: Object| Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, factor]); + for (key, factor) in [ + ("Dest", Object::Real(0.005)), + ("Dest", Object::Real(100.0)), + ("Dest", Object::Int(100)), + ("Dest", Object::Real(1e307)), + ("A", Object::Real(0.005)), + ] { + let mut doc = fixture(); + let i = + links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + let mut d = doc.get(obj).as_dict().cloned().unwrap(); + d.remove(b"Dest"); + if key == "A" { + let mut go_to = pdfcraft_cos::Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), xyz(pages[1], factor.clone())); + d.set(b"A".to_vec(), Object::Dict(go_to)); + } else { + d.set(b"Dest".to_vec(), xyz(pages[1], factor.clone())); + } + doc.set(obj, Object::Dict(d)); + let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else { + panic!("{key} {factor:?}") + }; + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap(); + assert_eq!(dest_of(&doc, obj), Some(xyz(pages[0], factor.clone())), "{key} {factor:?}: the factor is the link's own"); + let doc = reopen(&doc); + assert_eq!( + links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action, + LinkAction::Page { page: 0, zoom }, + "{key} {factor:?}" + ); + } + // A zoom other than the link's is written within 1–6400%. + let mut doc = fixture(); + let i = links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::Percent(50.0) }, &LinkStyle::default()) + .unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 1, zoom: LinkZoom::Percent(10000.0) }), None).unwrap(); + assert_eq!(dest_of(&doc, obj), Some(xyz(pages[1], Object::Real(64.0)))); +} + +/// A go-to edited without a change (its own page and zoom, as `link_edit` naming its page does) +/// is left as it is: the `/XYZ` position, a fit's position and the GoTo form stay. Given a new +/// page with its zoom, a link keeps how that zoom is written, a boxed fit (`/FitBH`, `/FitBV`) +/// included, but not its position, which belongs to the old page. +#[test] +fn an_unchanged_go_to_keeps_its_destination_and_a_new_page_keeps_its_fit() { + use crate::links::{self, LinkAction, LinkStyle, LinkZoom}; + let pages = page_refs(&fixture()).unwrap(); + let dest = |page: ObjRef, kind: &str, operands: Vec| { + let mut d = vec![Object::Ref(page), Object::name(kind)]; + d.extend(operands); + Object::Array(d) + }; + let link_with = |key: &str, value: Object| { + let mut doc = fixture(); + let i = + links::add(&mut doc, 0, [10.0, 10.0, 60.0, 30.0], &LinkAction::Page { page: 1, zoom: LinkZoom::FitPage }, &LinkStyle::default()).unwrap(); + let obj = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().obj.unwrap(); + let mut d = doc.get(obj).as_dict().cloned().unwrap(); + d.remove(b"Dest"); + if key == "A" { + let mut go_to = pdfcraft_cos::Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), value); + d.set(b"A".to_vec(), Object::Dict(go_to)); + } else { + d.set(b"Dest".to_vec(), value); + } + doc.set(obj, Object::Dict(d)); + (doc, i, obj) + }; + let (num, null) = (|n: i64| Object::Int(n), Object::Null); + // The same page and zoom: nothing is rewritten. + for (key, value) in [ + ("Dest", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)])), + ("Dest", dest(pages[1], "FitBH", vec![num(250)])), + ("Dest", dest(pages[1], "FitV", vec![num(20)])), + ("A", dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(0.005)])), + ] { + let (mut doc, i, obj) = link_with(key, value); + let before = doc.get(obj).as_dict().cloned(); + let LinkAction::Page { page, zoom } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else { + panic!("{key}") + }; + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page, zoom }), None).unwrap(); + assert_eq!(doc.get(obj).as_dict().cloned(), before, "{key} {zoom:?}: left as it is"); + } + // A new page: the fit's kind and the factor stay, positions go. + for (value, written) in [ + (dest(pages[1], "FitBH", vec![num(250)]), dest(pages[0], "FitBH", vec![null.clone()])), + (dest(pages[1], "FitBV", vec![num(20)]), dest(pages[0], "FitBV", vec![null.clone()])), + (dest(pages[1], "FitH", vec![num(250)]), dest(pages[0], "FitH", vec![null.clone()])), + ( + dest(pages[1], "XYZ", vec![num(72), num(500), Object::Real(2.0)]), + dest(pages[0], "XYZ", vec![null.clone(), null.clone(), Object::Real(2.0)]), + ), + ] { + let (mut doc, i, obj) = link_with("Dest", value.clone()); + let LinkAction::Page { zoom, .. } = links::list(&doc).into_iter().find(|l| l.page == 0 && l.index == i).unwrap().action else { + panic!("{value:?}") + }; + links::set(&mut doc, 0, i, None, Some(&LinkAction::Page { page: 0, zoom }), None).unwrap(); + assert_eq!(doc.get(obj).as_dict().and_then(|d| d.get(b"Dest").cloned()), Some(written), "{value:?}"); + } } #[test] diff --git a/crates/automation/README.md b/crates/automation/README.md index 9da911c932..e25ff9b8b2 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -61,7 +61,8 @@ do not keep an old PDF byte buffer alive. This bounds retained text-cache data, not total process memory: active extraction batches, renderers, document buffers and the requested output have separate lifetimes. Search still reads -growing batches (8 to 128 pages), with one lookahead match to determine continuation. +growing batches (8 to 128 pages), with one lookahead match to determine continuation, unless +`stop_at_limit` is set (below), which starts at one page and looks no further than its limit. `text_find` keeps matches from readable pages when another reached page fails extraction. It returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and @@ -75,6 +76,16 @@ that reaches the end with warnings therefore has `complete: false` **without** ` should follow `next` when present and separately inspect warnings. A capped search can return both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged. +That lookahead means a search with exactly `limit` matches reads on to the end of the document to +learn that no further match exists. `stop_at_limit: true` skips it: the search ends at the +`limit`th match. It reads one page first, then batches of 2, 4, … up to 128 pages, so nothing past +the batch holding that match is read or can add warnings (a match on the first page reads only that +page). The answer carries +`stopped_at_limit`. When that is `true`, `complete` is `false` and `next` is only the position just +past the last match, not a promise of another one; following it may return no matches and +`complete: true`. When it is `false`, the search ran out of pages first and `complete` means what +it always does. + `text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed semantics; they never silently act on partial text. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1f0c9cb8a5..8fb1e0fd52 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; -use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic}; mod documents; mod form_tools; diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index 5ae3d4fe46..ceb520e9d4 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { @@ -148,18 +148,7 @@ impl Automation { .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); out.push(match result { Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; + let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?; json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) } Err(e) => json!({ "path": p, "error": e }), diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index f650e565b7..14a98f23e2 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -281,7 +281,7 @@ impl Automation { } /// Publish atomically without replacing any existing name, even if another writer wins a race. -fn publish(target: &Path, bytes: &[u8]) -> Result { +pub(crate) fn publish(target: &Path, bytes: &[u8]) -> Result { let dir = target.parent().ok_or_else(|| failed("output has no parent"))?; std::fs::create_dir_all(dir).map_err(failed)?; let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?; diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs index 6493df9968..71ae6b3b91 100644 --- a/crates/automation/src/bates.rs +++ b/crates/automation/src/bates.rs @@ -415,7 +415,11 @@ mod tests { let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap(); assert!(acl.success()); - assert!(matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied)); + // Where the deny entry does not bind the test's own account (it did not on CI's Windows image), + // there is no denied folder to probe. + if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) { + return; + } let direct_probe = dir.path().join("direct-probe"); let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap(); drop(file); diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 95f94f3b88..8741b2829f 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, nest_rows, security, summary}; use super::paths::{child, read_input, write_atomic}; -use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, TextBlock, Value, comments, json}; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; @@ -135,18 +135,18 @@ impl Automation { pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { let doc = self.doc(a)?; let id = doc.id; - let target = match a.opt_str("path")? { + let explicit = a.opt_str("path")?; + let target = match explicit { Some(p) => self.resolve(p, true)?, None => { // Saving in place writes where the file was opened from, which the read root - // allows and the write root may not. + // allows and the write root may not. It also goes through --root again: a + // folder on the remembered path may have become a link out since (#1791). let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; - let own_path = PathBuf::from(&own); - self.check_roots(&own, &own_path, file_access::Access::Write)?; - own_path + self.resolve(&own, true)? } }; - let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + let same_file = explicit.is_none() || doc.path.as_deref().is_some_and(|p| Path::new(p) == target); // Saving to a new file is a full rewrite unless asked otherwise, like Save As. let full = a.opt_bool("full")?.unwrap_or(!same_file); let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c950e72f05..c4ad98efa0 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value { }) } +/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks, +/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs. pub(crate) fn info(d: &Document) -> Value { let i = &d.info; let page1 = |p: usize| p + 1; @@ -294,7 +296,7 @@ pub(crate) fn info(d: &Document) -> Value { // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the // ones the spec names, set and cleared with doc_set_info. "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, + "creator": i.creator, "producer": i.producer, "created": i.created, "modified": i.modified, "tagged": i.tagged, "has_javascript": i.has_javascript, // Where the document opens (/OpenAction, named destinations resolved): 1-based page and @@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value { "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, })).collect::>(), - "outline": outline(&i.outline), + "outline": outline(i, &i.outline), "outline_more": i.outline_more, // Rectangles use the tools' convention (top-left of the displayed page, like // comment_list and link_list), not raw PDF user space, so they can be fed back to @@ -329,32 +331,7 @@ pub(crate) fn info(d: &Document) -> Value { })).collect::>(), "links": i.links.iter().map(|l| json!({ "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), - "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), - pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ - "action": if *launch { "Launch" } else { "GoToR" }, - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, + "target": link_target(i, &l.target), })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with @@ -389,6 +366,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [ i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) } -fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { - Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`). +fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array( + items + .iter() + .map(|o| { + json!({ + "title": o.title, "page": o.page.map(|p| p + 1), + "action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children), + }) + }) + .collect(), + ) +} + +/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer +/// changes, another PDF, or the name of an action that isn't run. +fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value { + // A remote page number is the file's, however large. + let page1 = |p: usize| p.saturating_add(1); + match target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + } } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 28cbbfdca4..594a129d8b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -70,7 +70,7 @@ use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content}; use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; #[cfg(test)] pub(crate) use paths::MAX_INPUT; -use paths::{child, lexical, unused, write_atomic}; +use paths::{child, lexical, write_atomic}; pub(crate) use paths::{read_input, read_input_bytes}; #[cfg(test)] use paths::{write_atomic_synced, write_atomic_with}; diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index 41fda9b7db..a3bf9cd28b 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -2,7 +2,7 @@ //! all links. Rectangles are points from the top-left of the displayed page. use pdfcraft_engine::{ - Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, + Edit, LinkAction, LinkHighlight, LinkStyle, LinkZoom, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; use pdfcraft_render::{DestView, LinkTarget, RemoteDestination}; use serde_json::{Value, json}; @@ -25,18 +25,30 @@ impl Automation { Ok(Some([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])) } - fn link_action(&self, a: &Args) -> Result> { + /// The action `url`, `to_page` and `zoom` ask for, if any. `current` is the link's action now + /// (link_edit): a new page keeps the link's zoom, and a zoom alone keeps its page. + fn link_action(&self, a: &Args, current: Option<&LinkAction>) -> Result> { + let zoom = link_zoom(a)?; + let (current_page, current_zoom) = match current { + Some(LinkAction::Page { page, zoom }) => (Some(*page), Some(*zoom)), + _ => (None, None), + }; match (a.opt_str("url")?, a.opt_int("to_page")?) { (Some(_), Some(_)) => Err(bad("pass url or to_page, not both")), + (Some(_), None) if zoom.is_some() => Err(bad("zoom goes with to_page, not url")), (Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))), (None, Some(p)) => { let n = self.doc(a)?.info.pages.len() as i64; if p < 1 || p > n { return Err(bad(format!("to_page must be 1–{n}"))); } - Ok(Some(LinkAction::Page(p as usize - 1))) + Ok(Some(LinkAction::Page { page: p as usize - 1, zoom: zoom.or(current_zoom).unwrap_or(LinkZoom::FitPage) })) } - (None, None) => Ok(None), + (None, None) => match (zoom, current_page) { + (None, _) => Ok(None), + (Some(zoom), Some(page)) => Ok(Some(LinkAction::Page { page, zoom })), + (Some(_), None) => Err(bad("zoom needs to_page: the link doesn't go to a page")), + }, } } @@ -86,11 +98,16 @@ impl Automation { if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) { v["destination"] = d; } - if matches!(action, LinkAction::Other(_)) { - v["named"] = json!(true); + match action { + LinkAction::Page { zoom, .. } => v["zoom"] = zoom_value(*zoom), + LinkAction::Other(_) => v["named"] = json!(true), + LinkAction::Uri(_) => {} } } - (LinkAction::Page(p), None) => v["to_page"] = json!(p + 1), + (LinkAction::Page { page, zoom }, None) => { + v["to_page"] = json!(page + 1); + v["zoom"] = zoom_value(*zoom); + } (LinkAction::Other(o), None) => v["other"] = json!(o), } v @@ -168,12 +185,13 @@ impl Automation { pub(crate) fn link_add(&mut self, a: &Args) -> Result { let page = self.page(a)?; let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?; - let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?; + let action = self.link_action(a, None)?.ok_or_else(|| bad("pass url or to_page"))?; let style = self.link_style(a, LinkStyle::default())?; self.apply(a, Edit::AddLink { page, rect, action, style }) } - fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> { + /// The link `page` and `index` name: its page, index in `/Annots`, appearance and action. + fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle, LinkAction)> { let page = self.page(a)?; let index = a.int("index")?; let doc = self.doc(a)?; @@ -182,19 +200,19 @@ impl Automation { .iter() .find(|l| l.page == page && l.index as i64 + 1 == index) .ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?; - Ok((page, l.index, l.style)) + Ok((page, l.index, l.style, l.action.clone())) } pub(crate) fn link_edit(&mut self, a: &Args) -> Result { - let (page, index, style) = self.link_index(a)?; + let (page, index, style, current) = self.link_index(a)?; let rect = self.view_rect(a, page)?; - let action = self.link_action(a)?; + let action = self.link_action(a, Some(¤t))?; let new_style = self.link_style(a, style)?; self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) }) } pub(crate) fn link_delete(&mut self, a: &Args) -> Result { - let (page, index, _) = self.link_index(a)?; + let (page, index, ..) = self.link_index(a)?; self.apply(a, Edit::DeleteLink { page, index }) } @@ -225,6 +243,40 @@ impl Automation { } } +/// The zoom a `zoom` argument names: fit_page, fit_width, fit_height, fit_visible, actual +/// (100%), inherit (keep the reader's zoom), or a percentage, kept within 1–6400. +fn link_zoom(a: &Args) -> Result> { + match a.get("zoom") { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(p)) => match p.as_f64() { + Some(p) if p.is_finite() && p > 0.0 => Ok(Some(LinkZoom::Percent(p.clamp(1.0, 6400.0)))), + _ => Err(bad("zoom must be a positive percentage")), + }, + Some(Value::String(z)) => Ok(Some(match z.as_str() { + "fit_page" => LinkZoom::FitPage, + "fit_width" => LinkZoom::FitWidth, + "fit_height" => LinkZoom::FitHeight, + "fit_visible" => LinkZoom::FitVisible, + "actual" => LinkZoom::Percent(100.0), + "inherit" => LinkZoom::Inherit, + o => return Err(bad(format!("unknown zoom {o:?}"))), + })), + Some(_) => Err(bad("zoom is a name or a percentage")), + } +} + +/// How `link_list` reports a link's zoom: the names `link_zoom` reads, a percentage as a number. +fn zoom_value(zoom: LinkZoom) -> Value { + match zoom { + LinkZoom::FitPage => json!("fit_page"), + LinkZoom::FitWidth => json!("fit_width"), + LinkZoom::FitHeight => json!("fit_height"), + LinkZoom::FitVisible => json!("fit_visible"), + LinkZoom::Inherit => json!("inherit"), + LinkZoom::Percent(p) => json!(p), + } +} + fn dest_view_value(view: DestView) -> Value { match view { DestView::Top => json!({ "kind": "top" }), diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 6db58ab475..04ab8dace4 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -270,6 +270,8 @@ impl Automation { /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported /// as bounded warnings; `complete` requires both no continuation and no failed pages. + /// `stop_at_limit` drops that lookahead: the search ends at the `limit`th match, so a lone match + /// never sends it to the end of the document, and `next` is only where to resume (#5328). pub(crate) fn text_find(&mut self, a: &Args) -> Result { self.text_find_with(a, &mut extract_parallel) } @@ -289,11 +291,15 @@ impl Automation { Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), }; let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); + let stop_at_limit = a.opt_bool("stop_at_limit")?.unwrap_or(false); let mut matches = Vec::new(); let mut next = None; + let mut stopped = false; let mut warnings = Vec::new(); let mut warning_count = 0usize; - let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + // A search that stops at its limit starts with one page, so a match on it reads nothing + // more; the batches still double for a match further in. + let (mut first, mut batch) = (start, if stop_at_limit { 1 } else { FIND_FIRST_BATCH }); 'search: while first < n { let end = first.saturating_add(batch).min(n); let pages: Vec = (first..end).collect(); @@ -315,13 +321,19 @@ impl Automation { let skip_here = if p == start { skip } else { 0 }; // One extra match proves that the caller needs a continuation. Include skipped // matches on the starting page when bounding the temporary per-page result. - let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(usize::from(!stop_at_limit)); for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { if matches.len() == limit { next = Some(json!({ "start_page": p + 1, "skip": k })); break 'search; } matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + if stop_at_limit && matches.len() == limit { + // Resume just past this match; whether another exists isn't looked at. + next = Some(json!({ "start_page": p + 1, "skip": k.saturating_add(1) })); + stopped = true; + break 'search; + } } } first = end; @@ -329,8 +341,13 @@ impl Automation { } let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); - if let (Some(next), Some(o)) = (next, out.as_object_mut()) { - o.insert("next".into(), next); + if let Some(o) = out.as_object_mut() { + if let Some(next) = next { + o.insert("next".into(), next); + } + if stop_at_limit { + o.insert("stopped_at_limit".into(), stopped.into()); + } } Ok(out) } @@ -393,6 +410,7 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str match warning { RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give", } } diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index acfa204b4b..408cb7cac0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -146,6 +146,84 @@ fn search_bounds_warning_volume_and_does_not_reach_distant_failures() { assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true)); } +#[test] +fn stop_at_limit_ends_at_a_lone_match_without_reading_the_distant_tail() { + let mut a = Automation::new(); + let id = search_document(&mut a, 200); + // #5328: "Page 1" is only on page 1 (every other readable page reads "Page 2"), and the last + // page fails, so only a search that runs on to the end hears about it. + let requested = std::cell::RefCell::new(Vec::new()); + let mut extract = |bytes: &Arc>, password: Option>, pages: &[usize]| { + requested.borrow_mut().extend_from_slice(pages); + let filler = extract_parallel(bytes, password.clone(), &[1]).remove(0).1; + let first = extract_parallel(bytes, password, &[0]).remove(0).1; + pages + .iter() + .map(|&p| { + ( + p, + match p { + 0 => first.clone(), + 199 => Err("synthetic page 200 failure".into()), + _ => filler.clone(), + }, + ) + }) + .collect::>() + }; + let looked_ahead = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page 1", "limit": 1 })), &mut extract).unwrap(); + assert_eq!((looked_ahead["count"].clone(), looked_ahead["warning_count"].clone()), (json!(1), json!(1)), "{looked_ahead}"); + assert!(looked_ahead.get("next").is_none() && looked_ahead.get("stopped_at_limit").is_none()); + + a.texts.remove(&id); + requested.borrow_mut().clear(); + let args = json!({ "doc": id.0, "query": "Page 1", "limit": 1, "stop_at_limit": true }); + let stopped = a.text_find_with(&Args(&args), &mut extract).unwrap(); + assert_eq!(stopped["matches"][0]["page"], 1); + assert_eq!((stopped["count"].clone(), stopped["warning_count"].clone()), (json!(1), json!(0)), "{stopped}"); + assert_eq!((stopped["complete"].clone(), stopped["stopped_at_limit"].clone()), (json!(false), json!(true))); + assert_eq!(stopped["next"], json!({ "start_page": 1, "skip": 1 })); + assert_eq!(*requested.borrow(), [0], "a match on the first page reads only that page"); + + // Resuming finds nothing more and reaches the failing page, which it now reports. + let mut resume = args.clone(); + resume["start_page"] = stopped["next"]["start_page"].clone(); + resume["skip"] = stopped["next"]["skip"].clone(); + let rest = a.text_find_with(&Args(&resume), &mut extract).unwrap(); + assert_eq!((rest["count"].clone(), rest["stopped_at_limit"].clone(), rest["complete"].clone()), (json!(0), json!(false), json!(false))); + assert_eq!(rest["warnings"][0]["page"], 200); + assert!(rest.get("next").is_none()); +} + +#[test] +fn stop_at_limit_continuations_return_every_match_once_and_end_complete() { + let mut a = Automation::new(); + let id = search_document(&mut a, 120); + let all = json(&mut a, "text_find", json!({ "doc": id.0, "query": "1", "limit": 10_000 })); + assert_eq!(all["complete"], true); + // "1" appears twice on pages like 11 and 111, so resuming has to skip within a page. + for limit in [1, 2, 3] { + let mut args = json!({ "doc": id.0, "query": "1", "limit": limit, "stop_at_limit": true }); + let mut pieced = Vec::new(); + for _ in 0..=all["count"].as_u64().unwrap() { + let part = json(&mut a, "text_find", args.clone()); + assert_eq!(part["warning_count"], 0); + pieced.extend(part["matches"].as_array().unwrap().iter().cloned()); + if part["stopped_at_limit"] == false { + assert_eq!(part["complete"], true); + assert!(part.get("next").is_none()); + break; + } + assert_eq!((part["count"].clone(), part["complete"].clone()), (json!(limit), json!(false))); + args["start_page"] = part["next"]["start_page"].clone(); + args["skip"] = part["next"]["skip"].clone(); + } + assert_eq!(pieced, *all["matches"].as_array().unwrap(), "limit {limit}: no match lost or repeated"); + } + let none = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent", "limit": 1, "stop_at_limit": true })); + assert_eq!((none["count"].clone(), none["complete"].clone(), none["stopped_at_limit"].clone()), (json!(0), json!(true), json!(false))); +} + #[test] fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id())); @@ -192,6 +270,30 @@ fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn a_save_without_a_path_stays_inside_the_root() { + let base = std::env::temp_dir().join(format!("pdfcraft-automation-save-root-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let root = base.join("root"); + let outside = base.join("outside"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let mut a = Automation::new().with_root(&root).unwrap(); + let bytes = Arc::new(numbered_pages(1)); + // Remembered path inside the root: still saves. + let inside = root.canonicalize().unwrap().join("in.pdf"); + let id = a.session.open("in.pdf", Some(inside.to_string_lossy().into_owned()), bytes.clone(), None).unwrap(); + json(&mut a, "doc_save", json!({ "doc": id.0 })); + assert!(inside.exists()); + // Remembered path outside the root: refused, and nothing is written there. + let escaped = outside.join("out.pdf"); + let id = a.session.open("out.pdf", Some(escaped.to_string_lossy().into_owned()), bytes, None).unwrap(); + let error = a.call("doc_save", &json!({ "doc": id.0 })).unwrap_err(); + assert!(error.to_string().contains("outside the allowed directory"), "{error}"); + assert!(!escaped.exists()); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { let base = std::env::temp_dir(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f43996e098..0cade83abd 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -246,12 +246,13 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.") + t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation. With stop_at_limit the search ends at the limit-th match instead of looking for one more, so a lone early match doesn't read the rest of the document.") .ro() .cmd("edit.find") .with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." }, "start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." }, - "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])), + "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." }, + "stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])), t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.") .cmd("page.rotate") .with(schema( @@ -812,7 +813,7 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).") + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") @@ -820,22 +821,24 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), &["doc", "page", "index"], )), - t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.") + t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page, shown at zoom: fit_page (default), fit_width, fit_height, fit_visible, actual, inherit (keep the reader's zoom) or a percentage. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.") .cmd("edit.link") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 }, + "zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" }, "visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] }, }), &["doc", "page", "rect"], )), - t("link_edit", "Edit a link", "Change a link's rect, destination (url or to_page) or appearance. Undoable.") + t("link_edit", "Edit a link", "Change a link's rect, destination (url, or to_page and zoom as in link_add; a new to_page keeps the link's zoom, a zoom alone keeps its page) or appearance. Undoable.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 }, + "zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" }, "visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] }, }), &["doc", "page", "index"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6e05d752b7..9a042320ee 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6201,6 +6201,80 @@ fn links_through_tools() { assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn link_zoom_through_tools() { + let dir = workdir("link-zoom"); + std::fs::write(dir.join("notes.txt"), "Contents").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "notes.txt" }))["doc"].as_u64().unwrap(); + let link = |a: &mut Automation| ok(a, "link_list", json!({ "doc": doc }))["links"][0].clone(); + // A page link opens at Fit Page unless told otherwise. + ok(&mut a, "link_add", json!({ "doc": doc, "page": 1, "rect": [72, 300, 200, 320], "to_page": 1 })); + assert_eq!((link(&mut a)["to_page"].clone(), link(&mut a)["zoom"].clone()), (json!(1), json!("fit_page"))); + let index = link(&mut a)["index"].clone(); + // A zoom alone keeps the page; a page alone keeps the zoom. + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": 200 })); + let l = link(&mut a); + assert_eq!((l["to_page"].clone(), l["zoom"].clone()), (json!(1), json!(200.0))); + assert_eq!(l["destination"]["zoom"], 2.0, "the viewer reads the zoom the link writes: {l}"); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": "fit_width" })); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "to_page": 1 })); + assert_eq!(link(&mut a)["zoom"], "fit_width"); + for (name, zoom) in [("actual", json!(100.0)), ("inherit", json!("inherit")), ("fit_visible", json!("fit_visible"))] { + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "zoom": name })); + assert_eq!(link(&mut a)["zoom"], zoom, "{name}"); + } + // A zoom needs a page to show: refused with a web address, unknown, or not positive. + for args in [ + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "url": "https://example.org", "zoom": "fit_page" }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "zoom": "fit_page" }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": "sideways" }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": 0 }), + json!({ "doc": doc, "page": 1, "rect": [72, 400, 200, 420], "to_page": 1, "zoom": [2] }), + ] { + assert!(matches!(a.call("link_add", &args), Err(ToolError::InvalidArgs(_))), "{args}"); + } + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "url": "https://example.org" })); + assert!(matches!(a.call("link_edit", &json!({ "doc": doc, "page": 1, "index": index, "zoom": 150 })), Err(ToolError::InvalidArgs(_)))); +} + +/// `link_edit` with a page and no zoom keeps the link's zoom, and with it the link's own `/XYZ` +/// factor, even outside the 1–6400% a chosen zoom is written within: 0.5% and 10000% stay so, in +/// link_list and in the viewer's destination. Naming the link's own page changes nothing, so its +/// position stays too. A zoom chosen anew is still kept within 1–6400%. +#[test] +fn link_edit_keeps_a_links_own_zoom_outside_the_range_it_writes() { + let (_base, root) = sandbox("link-own-zoom"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Link /Rect [10 10 100 30] /Border [0 0 0] /Dest [3 0 R /XYZ null null 0.005] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 50 100 70] /Border [0 0 0] /Dest [3 0 R /XYZ null null 100] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 90 100 110] /Border [0 0 0] /Dest [3 0 R /XYZ 72 300 2] >>", + ]); + std::fs::write(root.join("zooms.pdf"), pdf).unwrap(); + let mut a = auto(&root); + let doc = ok(&mut a, "doc_open", json!({ "path": "zooms.pdf" }))["doc"].as_u64().unwrap(); + let link = |a: &mut Automation, index: u64| { + ok(a, "link_list", json!({ "doc": doc }))["links"].as_array().unwrap().iter().find(|l| l["index"] == index).unwrap().clone() + }; + for (index, percent, factor) in [(1, 0.5, 0.005), (2, 10000.0, 100.0)] { + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": index, "to_page": 2 })); + let l = link(&mut a, index); + assert_eq!((l["to_page"].clone(), l["zoom"].clone()), (json!(2), json!(percent)), "{l}"); + let shown = l["destination"]["zoom"].as_f64().unwrap(); + assert!((shown - factor).abs() <= factor * 1e-6, "the viewer's zoom is the link's own: {l}"); + } + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": 3, "to_page": 1 })); + let l = link(&mut a, 3); + // `/XYZ 72 300` on a 400 pt page: 72 from the left and 100 from the top, as the tools measure. + assert_eq!((&l["zoom"], &l["destination"]["left"], &l["destination"]["top"]), (&json!(200.0), &json!(72.0), &json!(100.0)), "{l}"); + ok(&mut a, "link_edit", json!({ "doc": doc, "page": 1, "index": 1, "zoom": 10000 })); + assert_eq!(link(&mut a, 1)["zoom"], json!(6400.0)); +} + /// #4168: a Launch action whose file is a PDF (here with Windows `\` separators, relative to the /// source as a remote link is) opens that PDF in PdfCraft through link_open, within the root; one /// naming a program is listed but never followed. @@ -6228,6 +6302,40 @@ fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() { assert_eq!(a.session().docs().len(), before, "nothing was opened or launched"); } +/// A bookmark's action is reported as a link's target is, and only a go-to names a page here: a +/// remote go-to's `[1 /Fit]` is the other file's page 2, so doc_info and bookmark_list give this +/// file's no page for it. +#[test] +fn bookmarks_report_their_actions_and_a_remote_go_to_names_no_page_here() { + let (_base, root) = sandbox("bookmark-actions"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 3 >>", + "<< /Title (Web) /Parent 5 0 R /Next 7 0 R /A << /S /URI /URI (https://example.org/manual) >> >>", + "<< /Title (Other file) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>", + "<< /Title (Here) /Parent 5 0 R /Prev 7 0 R /A << /S /GoTo /D [4 0 R /Fit] >> >>", + ]); + std::fs::write(root.join("bookmarks.pdf"), pdf).unwrap(); + let mut a = auto(&root); + let doc = ok(&mut a, "doc_open", json!({ "path": "bookmarks.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let outline = &info["outline"]; + assert_eq!((&outline[0]["page"], &outline[0]["action"]), (&Value::Null, &json!({ "uri": "https://example.org/manual" })), "{info}"); + assert_eq!(outline[1]["page"], Value::Null, "{info}"); + assert_eq!( + (&outline[1]["action"]["action"], &outline[1]["action"]["remote_document"], &outline[1]["action"]["destination"]["page"]), + (&json!("GoToR"), &json!("other.pdf"), &json!(2)), + "{info}" + ); + assert_eq!((&outline[2]["page"], &outline[2]["action"]), (&json!(2), &Value::Null), "{info}"); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + let pages: Vec<_> = marks.as_array().unwrap().iter().map(|b| (b["title"].clone(), b["page"].clone())).collect(); + assert_eq!(pages, [(json!("Web"), Value::Null), (json!("Other file"), Value::Null), (json!("Here"), json!(2))], "{marks}"); +} + #[test] fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { let (_base, root) = sandbox("gotor-not-a-file"); @@ -8260,6 +8368,22 @@ fn action_run_keeps_outputs_with_duplicate_input_basenames() { assert_eq!(std::fs::read(first_path).unwrap(), first_bytes, "an earlier output must not be replaced"); } +#[cfg(unix)] +#[test] +fn action_run_never_replaces_an_entry_that_exists_check_misses() { + let dir = workdir("action-noclobber"); + std::fs::create_dir_all(dir.join("results")).unwrap(); + std::fs::write(dir.join("report.pdf"), fixture(1)).unwrap(); + // A dangling link is invisible to `exists()` but is still an occupied name. + std::os::unix::fs::symlink(dir.join("nowhere.pdf"), dir.join("results/report.pdf")).unwrap(); + let mut a = auto(&dir); + let args = json!({ "steps": [{ "step": "set_title", "arg": "Processed" }], "paths": ["report.pdf"], "folder": "results" }); + let result = ok(&mut a, "action_run", args); + let output = result["files"][0]["output"].as_str().unwrap(); + assert!(output.ends_with("report (2).pdf"), "{output}"); + assert!(dir.join("results/report.pdf").symlink_metadata().unwrap().file_type().is_symlink(), "the existing entry must stay"); +} + #[test] fn pdfa_through_tools() { let dir = workdir("pdfa"); diff --git a/crates/compare/src/lib.rs b/crates/compare/src/lib.rs index 26a50eaf71..e611b0a59c 100644 --- a/crates/compare/src/lib.rs +++ b/crates/compare/src/lib.rs @@ -411,6 +411,12 @@ mod tests { } } +/// Byte offset of pixel (`x`, `y`) in a `w`-wide RGBA buffer, in wide integers so an image past 4 GiB +/// (its `y * w` or `* 4` would wrap a `u32`) still indexes the right pixel; past `usize` it is out of range. +fn rgba_index(x: u32, y: u32, w: u32) -> usize { + usize::try_from((u128::from(y) * u128::from(w) + u128::from(x)) * 4).unwrap_or(usize::MAX) +} + /// Visual compare: the regions where two renderings of a page differ, as pixel boxes /// `[x0, y0, x1, y1]` (y down) in `a`'s image. Both images are RGBA; `b` is sampled at the /// same relative position when sizes differ. Pixels count as different when a channel differs @@ -421,6 +427,11 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8) if wa == 0 || ha == 0 || wb == 0 || hb == 0 { return Vec::new(); } + // Declared sizes the buffers cannot hold (a damaged render) would overflow the u32 index maths. + let holds = |px: &[u8], w: u32, h: u32| u128::from(w) * u128::from(h) * 4 <= px.len() as u128; + if !holds(pa, wa, ha) || !holds(pb, wb, hb) { + return Vec::new(); + } const CELL: u32 = 8; let (gw, gh) = (wa.div_ceil(CELL), ha.div_ceil(CELL)); let mut grid = vec![false; (gw * gh) as usize]; @@ -428,9 +439,8 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8) let yb = (y as u64 * hb as u64 / ha as u64) as u32; for x in 0..wa { let xb = (x as u64 * wb as u64 / wa as u64) as u32; - let ia = ((y * wa + x) * 4) as usize; - let ib = ((yb * wb + xb) * 4) as usize; - let (Some(ca), Some(cb)) = (pa.get(ia..ia + 3), pb.get(ib..ib + 3)) else { continue }; + let (ia, ib) = (rgba_index(x, y, wa), rgba_index(xb, yb, wb)); + let (Some(ca), Some(cb)) = (pa.get(ia..ia.saturating_add(3)), pb.get(ib..ib.saturating_add(3))) else { continue }; if ca.iter().zip(cb).any(|(p, q)| p.abs_diff(*q) > tolerance) { grid[((y / CELL) * gw + x / CELL) as usize] = true; } @@ -500,3 +510,28 @@ mod visual_tests { assert_eq!(visual_regions((&a, 200, 200), (&c, 200, 200), 16).len(), 2); } } + +#[cfg(test)] +mod visual_hostile_tests { + use super::*; + + #[test] + fn absurd_dimensions_with_short_buffers_return_quickly() { + let px = [0u8; 16]; + let r = visual_regions((&px, u32::MAX, u32::MAX), (&px, 2, 2), 0); + assert!(r.is_empty()); + } + + #[test] + fn pixel_offsets_past_four_gib_do_not_wrap() { + // A 32768 x 32769 RGBA image is 4,295,098,368 bytes, so a buffer that large passes the size check; + // its last pixel sits past u32::MAX and must not wrap back onto an early pixel. + let (w, h) = (32768u32, 32769u32); + let last = rgba_index(w - 1, h - 1, w); + assert_eq!(last as u64, (u64::from(h - 1) * u64::from(w) + u64::from(w - 1)) * 4); + assert!(last as u64 > u64::from(u32::MAX)); + assert_eq!(rgba_index(0, 0, w), 0); + // Out of range for any buffer: saturates instead of overflowing. + assert_eq!(rgba_index(u32::MAX, u32::MAX, u32::MAX), usize::MAX); + } +} diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index c54b062af6..73c5389c25 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -136,6 +136,160 @@ fn xobject_name(existing: &Dict, pending: &Dict, n: &mut u32) -> String { } } +/// Copy usable property resources, including inherited resources supplied by the page model. +/// Invalid data must stay intact rather than being replaced while transferring membership. +fn properties(doc: &Document, page: &Dict) -> Option { + let Some(resources) = page.get(b"Resources") else { return Some(Dict::new()) }; + let resources = doc.resolve(resources); + let Object::Dict(resources) = &*resources else { return None }; + let Some(properties) = resources.get(b"Properties") else { return Some(Dict::new()) }; + match &*doc.resolve(properties) { + Object::Dict(properties) => Some(properties.clone()), + _ => None, + } +} + +/// Validate an ISO 32000-1 §8.11.2.2 `/VE` visibility expression within recursion depth 16 +/// and a shared node-visit `budget`. +fn valid_ve(doc: &Document, expr: &Object, depth: usize, budget: &mut usize) -> bool { + if depth > 16 || *budget == 0 { + return false; + } + *budget -= 1; + let resolved = doc.resolve(expr); + if let Some(arr) = resolved.as_array() { + let [op, rest @ ..] = arr.as_slice() else { return false }; + let op_obj = doc.resolve(op); + let Some(name) = op_obj.as_name() else { return false }; + match (name, rest) { + (b"Not", [operand]) => valid_ve(doc, operand, depth + 1, budget), + (b"And" | b"Or", [_, ..]) => rest.iter().all(|o| valid_ve(doc, o, depth + 1, budget)), + _ => false, + } + } else if depth > 0 { + expr.as_ref().is_some_and(|r| matches!(&*doc.get(r), Object::Dict(d) if d.name(b"Type") == Some(b"OCG"))) + } else { + false + } +} + +/// Validate an `/OCMD` dictionary's `/P` policy and `/VE` visibility expression, if present. +fn valid_ocmd(doc: &Document, d: &Dict) -> bool { + if let Some(p) = d.get(b"P") + && !matches!(doc.resolve(p).as_name(), Some(b"AllOn" | b"AnyOn" | b"AnyOff" | b"AllOff")) + { + return false; + } + if let Some(ve) = d.get(b"VE") + && !valid_ve(doc, ve, 0, &mut 4096) + { + return false; + } + true +} + +/// Preserve the stored value, not a reconstructed membership dictionary. A direct inline +/// dictionary lacks an indirect reference (ObjRef) to identify it in page resources, and an +/// OCMD with a malformed or overly deep `/VE` expression stays on the annotation. +fn transferable(doc: &Document, annotation: &Dict, usable_properties: bool) -> bool { + let Some(membership) = annotation.get(b"OC") else { return true }; + if !usable_properties { + return false; + } + match &*doc.resolve(membership) { + Object::Dict(d) => match d.name(b"Type") { + Some(b"OCG") => membership.as_ref().is_some(), + Some(b"OCMD") => valid_ocmd(doc, d), + _ => false, + }, + _ => false, + } +} + +#[derive(Default)] +struct Retained { + refs: HashSet, + inline: HashSet, +} + +impl Retained { + fn contains(&self, index: usize, entry: &Object) -> bool { + self.inline.contains(&index) || entry.as_ref().is_some_and(|r| self.refs.contains(&r)) + } + + fn insert(&mut self, index: usize, entry: &Object) { + if let Some(r) = entry.as_ref() { + self.refs.insert(r); + } else { + self.inline.insert(index); + } + } +} + +/// Retained annotations keep their replies and pop-ups, including transitive reply chains. +/// +/// Retention is strictly downward (parent -> child/reply/popup). A non-transferable child does not +/// keep its transferable parent, matching the engine invariant for undrawn children (#1794). +fn retained_annotations(doc: &Document, list: &[Object], usable_properties: bool) -> Retained { + let mut children: HashMap> = HashMap::new(); + let mut inline_children: HashMap> = HashMap::new(); + let mut pending = Vec::new(); + let mut retained = Retained::default(); + for (k, entry) in list.iter().enumerate() { + let object = doc.resolve(entry); + let Some(annotation) = object.as_dict() else { continue }; + let not_transferable = !transferable(doc, annotation, usable_properties); + if let Some(reference) = entry.as_ref() { + if not_transferable { + pending.push(reference); + } + if let Some(parent) = annotation.reference(b"IRT") { + children.entry(parent).or_default().push(reference); + } + if annotation.name(b"Subtype") == Some(b"Popup") + && let Some(parent) = annotation.reference(b"Parent") + { + children.entry(parent).or_default().push(reference); + } + } else { + if not_transferable { + retained.inline.insert(k); + } + if let Some(parent) = annotation.reference(b"IRT") { + inline_children.entry(parent).or_default().push(k); + } + if annotation.name(b"Subtype") == Some(b"Popup") + && let Some(parent) = annotation.reference(b"Parent") + { + inline_children.entry(parent).or_default().push(k); + } + } + } + while let Some(current) = pending.pop() { + if !retained.refs.insert(current) { + continue; + } + if let Some(kids) = children.get(¤t) { + pending.extend(kids.iter().copied()); + } + if let Some(inline_kids) = inline_children.get(¤t) { + retained.inline.extend(inline_kids.iter().copied()); + } + } + retained +} + +/// A Properties resource name that is not already on the page or in `pending`. +fn property_name(existing: &Dict, pending: &Dict, n: &mut u64) -> Option { + loop { + let name = format!("PCFlOC{n}"); + *n = n.checked_add(1)?; + if !existing.contains(name.as_bytes()) && !pending.contains(name.as_bytes()) { + return Some(name); + } + } +} + /// Flatten comments and/or form fields on `pages`. Returns how many annotations were merged /// into page content. pub fn flatten(doc: &mut Document, pages: &[usize], comments: bool, fields: bool) -> Result { @@ -157,10 +311,15 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result Option<(usize, bool, char)> { for &i in pages { let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - for entry in &list { + let usable_properties = properties(doc, &all[i].dict).is_some(); + let retained = retained_annotations(doc, &list, usable_properties); + for (k, entry) in list.iter().enumerate() { + if retained.contains(k, entry) { + continue; + } let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue }; let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 { + if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) { continue; } let widget = d.name(b"Subtype") == Some(b"Widget"); @@ -174,6 +333,26 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi None } +/// Ensure comments without stored appearances have appearances generated before flattening. +fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retained: &HashSet, usable_properties: bool) { + for entry in list { + let Some(r) = entry.as_ref() else { continue }; + if retained.contains(&r) { + continue; + } + let d = doc.resolve(entry).as_dict().cloned().unwrap_or_default(); + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"Widget" || !selected(doc, &d, which) || !transferable(doc, &d, usable_properties) { + continue; + } + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + continue; + } + let _ = pdfcraft_annot::set_appearance(doc, r); + } +} + /// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on /// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,6 +397,10 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result Result Result> /AP << /N 6 0 R >>", 1, false); } +/// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and +/// valid OCMDs, including ISO 32000-1 §8.11.2.2 `/VE` expressions within depth 16 and the +/// 4,096-node visit budget) in `/OC /PCFlOC0 BDC … EMC` and registers them in +/// `/Resources /Properties`, while retaining annotations with untransferable, malformed, +/// overly deep, or over-budget `/OC` definitions in `/Annots`. +#[test] +fn flattening_transfers_annotation_optional_content_to_marked_content() { + let page = stream("", " "); + let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; + let case = |extra: &str, drawn: usize, left: bool, bdc: bool| { + let annot = format!("<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /AP << /N 6 0 R >> {extra} >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R 8 0 R 13 0 R] /D << /OFF [7 0 R] /ON [8 0 R 13 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [4 0 R] >>", + &annot, + &page, + form, + "<< /Type /OCG /Name (Hidden layer) >>", + "<< /Type /OCG /Name (Shown layer) >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOn >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AnyOff >>", + "<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOff >>", + "<< /Type /OCG /Name (Second shown layer) >>", + "[ /Or 15 0 R 15 0 R 15 0 R 15 0 R ]", + "[ /Or 16 0 R 16 0 R 16 0 R 16 0 R ]", + "[ /Or 17 0 R 17 0 R 17 0 R 17 0 R ]", + "[ /Or 18 0 R 18 0 R 18 0 R 18 0 R ]", + "[ /Or 8 0 R 8 0 R 8 0 R 8 0 R ]", + ]); + let before = streams(&doc, 0); + let expected_oc = doc.get(pdfcraft_cos::ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"OC").cloned()); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), drawn, "{extra}"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(p.dict.contains(b"Annots"), left, "{extra}"); + if drawn == 0 { + assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the retained comment: {extra}"); + } else { + let flat = streams(&doc, 0).pop().unwrap(); + assert_eq!(flat.contains("/OC /PCFlOC0 BDC") && flat.contains("EMC"), bdc, "{extra}: {flat}"); + if bdc { + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let props = doc.resolve(res.as_dict().unwrap().get(b"Properties").unwrap()); + assert_eq!(props.as_dict().unwrap().get(b"PCFlOC0").cloned(), expected_oc, "{extra}"); + } + } + }; + case("/OC 7 0 R", 1, false, true); + case("/OC 8 0 R", 1, false, true); + case("/OC 9 0 R", 1, false, true); + case("/OC 10 0 R", 1, false, true); + case("/OC 11 0 R", 1, false, true); + case("/OC 12 0 R", 1, false, true); + case("", 1, false, false); + // Valid ISO 32000-1 §8.11.2.2 /VE visibility expressions are transferred with their OCMD. + case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /And 8 0 R 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Or 8 0 R 7 0 R ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Or 7 0 R [ /And 8 0 R 13 0 R ] ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /And 8 0 R [ /Not 13 0 R ] ] >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 8 0 R ] /OCGs [8 0 R] /P /AllOn >>", 1, false, true); + case("/OC << /Type /OCMD /VE [ /Not 7 0 R ] /OCGs [7 0 R] /P /AllOn >>", 1, false, true); + // Malformed /VE, invalid /P, inline /OCG, and /VE exceeding depth 16 or the node budget fail closed and stay in /Annots. + case("/OC << /Type /OCG /Name (Inline layer) >>", 0, true, false); + case("/OC << /Type /OCMD /OCGs [8 0 R] /P /InvalidPolicy >>", 0, true, false); + case("/OC << /Type /OCMD /VE 8 0 R /OCGs [8 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [ /Not 7 0 R 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + case("/OC << /Type /OCMD /VE [ /Xor 8 0 R ] /OCGs [7 0 R] /P /AllOn >>", 0, true, false); + let deep_ve = format!("{}8 0 R{}", "[ /Not ".repeat(17), " ]".repeat(17)); + case(&format!("/OC << /Type /OCMD /VE {deep_ve} /OCGs [8 0 R] /P /AllOn >>"), 0, true, false); + case("/OC << /Type /OCMD /VE [ /Or 14 0 R 14 0 R 14 0 R 14 0 R ] /OCGs [8 0 R] /P /AllOn >>", 0, true, false); +} + /// #805: a supported note with no stored appearance is still drawn by viewers. Flattening must /// generate that appearance and bake it into the page, not delete a comment that shows an icon. #[test] @@ -11279,6 +11356,38 @@ fn q_inside_a_text_object_restores_the_text_matrices() { } } +#[test] +fn variation_public_line_and_block_edits_preserve_selectors_after_reopen() { + let replacement = "1\u{FE0F}"; + let mut results = Vec::new(); + for kind in ["line", "block"] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + if kind == "line" { + text::replace_line(&mut doc, 0, 0, replacement).unwrap(); + } else { + text::replace_block(&mut doc, 0, 0, replacement).unwrap(); + } + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + results.push((kind, lines[0].text.clone())); + } + assert_eq!(results, vec![("line", replacement.to_owned()), ("block", replacement.to_owned())]); +} + +#[test] +fn variation_public_selector_does_not_shift_centered_text_with_character_spacing() { + // қ requires the same bundled fallback for both drafts, without optional font inputs. + for spacing in [0.0, 2.0] { + let mut plain = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let mut selected = plain.clone(); + let style = text::BlockStyle { width: Some(100.0), align: Some(added::Align::Center), char_spacing: Some(spacing), ..Default::default() }; + text::rewrite_block(&mut plain, 0, 0, Some("1қ"), &style).unwrap(); + text::rewrite_block(&mut selected, 0, 0, Some("1\u{FE0F}қ"), &style).unwrap(); + let a = text::text_lines(&reopen(&plain), 0).unwrap(); + let b = text::text_lines(&reopen(&selected), 0).unwrap(); + assert_eq!(a[0].base_font, b[0].base_font); + assert!(close(a[0].rect, b[0].rect), "Tc={spacing}: plain {:?}, selector {:?}", a[0].rect, b[0].rect); + } +} /// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. /// Selected-row alignment for existing PDF content is withheld until it can persist safely. #[test] diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 80b20e0cda..789ffc5456 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -5,7 +5,27 @@ use super::{ }; /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. -pub(super) type Encoder = Box Option)>>>; +pub(super) type Encoder = Box Option>>; + +/// One run a rewrite shows: bytes drawn in one generated font. `spacing` set (a variation +/// selector's run) shows at that character spacing: its empty glyph advances nothing, and the +/// spacing in force must not add to it either. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Type3Run { + pub(super) name: String, + pub(super) bytes: Vec, + pub(super) spacing: Option, +} + +/// A Unicode variation selector (U+FE00..U+FE0F): it picks a variant of the character before it +/// and has no glyph of its own. The Type 3 fallback gives it an empty zero-width glyph instead of +/// failing the edit on it. +pub(super) fn is_variation_selector(ch: char) -> bool { + matches!(u32::from(ch), 0xFE00..=0xFE0F) +} + +/// A variation selector's glyph procedure: nothing painted, zero advance. +const SELECTOR_GLYPH_PROC: &[u8] = b"0 0 0 0 0 0 d1\n"; fn is_win_ansi_char(c: char) -> bool { c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some() @@ -377,7 +397,13 @@ pub(super) fn type3_font( // The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every // character. When none has them all, the best face reports the character it lacks. Hebrew // text keeps the Hebrew face and takes each character it lacks from the next face that has it. - let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) } + // A variation selector has no outline anywhere: it takes a glyph slot of its own below, so the + // face is chosen by the characters that have outlines. + let named: Vec = chars.iter().copied().filter(|ch| !is_variation_selector(*ch)).collect(); + if named.is_empty() { + return Err(EditError::Invalid("replacement text is empty".into())); + } + let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &named, |face, ch| japanese_glyph_from(face, ch).is_ok()) } .ok_or_else(|| no_fallback_font(text))?; let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] }; // Every outline is read before the document is touched, so a character that fails leaves it @@ -385,15 +411,21 @@ pub(super) fn type3_font( let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut glyphs = Vec::with_capacity(chars.len()); for (i, ch) in chars.into_iter().enumerate() { - // The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew - // face lacks from the next face that has it); a character no face draws is reported once. - let mut drawn = type3_glyph(face, ch)?; - for f in others.iter().copied() { - if drawn.is_some() { - break; + let drawn = if is_variation_selector(ch) { + Some((SELECTOR_GLYPH_PROC.to_vec(), 0.0)) + } else { + // The chosen face first, then the faces after it (Hebrew text takes a character the + // Hebrew face lacks from the next face that has it); a character no face draws is + // reported once. + let mut drawn = type3_glyph(face, ch)?; + for f in others.iter().copied() { + if drawn.is_some() { + break; + } + drawn = type3_glyph(f, ch)?; } - drawn = type3_glyph(f, ch)?; - } + drawn + }; let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) }; let code = u8::try_from((i % per_font).saturating_add(1)) .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; @@ -580,17 +612,22 @@ pub(crate) fn type3_font_dict(doc: &mut Document, glyphs: &[(char, Vec, f64) } /// The runs that show `text` in the fallback: consecutive characters in one font form one run, so -/// a font is switched only where the text moves to another font. `None` if a character has no code. -pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option)>> { - let mut runs: Vec<(usize, Vec)> = Vec::new(); +/// a font is switched only where the text moves to another font. A variation selector forms its +/// own run, drawn at character spacing zero (see [`Type3Run`]). `None` if a character has no code. +pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option> { + let mut runs: Vec<(usize, Vec, Option)> = Vec::new(); for ch in text.chars() { let (font, code) = *fallback.codes.get(&ch)?; + if is_variation_selector(ch) { + runs.push((font, vec![code], Some(0.0))); + continue; + } match runs.last_mut() { - Some((last, bytes)) if *last == font => bytes.push(code), - _ => runs.push((font, vec![code])), + Some((last, bytes, None)) if *last == font => bytes.push(code), + _ => runs.push((font, vec![code], None)), } } - runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect() + runs.into_iter().map(|(font, bytes, spacing)| Some(Type3Run { name: fallback.names.get(font)?.clone(), bytes, spacing })).collect() } /// The source font's own TrueType/OpenType program, when the document embeds a complete one whose @@ -685,3 +722,26 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variation_selectors_show_an_empty_glyph_at_zero_spacing() { + assert!(is_variation_selector('\u{FE0F}')); + assert!(!is_variation_selector('\u{27A1}')); + let fallback = Type3Fallback { + family: "Inter", + names: vec!["PCLat".into()], + widths: vec![('1', 0.556), ('\u{FE0F}', 0.0)], + codes: [('1', (0, 1)), ('\u{FE0F}', (0, 2))].into_iter().collect(), + }; + let runs = type3_encode(&fallback, "1\u{FE0F}").expect("the selector after a drawn base is kept, not an error"); + assert_eq!(runs.len(), 2, "the selector forms its own run"); + assert_eq!(runs[0], Type3Run { name: "PCLat".into(), bytes: vec![1], spacing: None }); + assert_eq!(runs[1], Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }, "the selector draws at spacing zero"); + let runs = type3_encode(&fallback, "\u{FE0F}").expect("a lone selector keeps its empty glyph rather than failing"); + assert_eq!(runs, vec![Type3Run { name: "PCLat".into(), bytes: vec![2], spacing: Some(0.0) }]); + } +} diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index c19f4ce98f..692c5e68de 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,7 +1,7 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, row_break, wrap}; use super::fallback::{ - Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, - source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, Type3Run, drop_unused_fallback_fonts, is_variation_selector, needs_type3, no_japanese_font, + source_encodes, source_family, source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines}; @@ -94,9 +94,18 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let runs = type3_encode(&fallback, &fallback_shown) .ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?; let size = font_size_before(&ops, first).unwrap_or(target.size); - for (name, bytes) in runs { - replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + let spacing = target.origin.state.char_spacing; + for run in runs { + replacement.push(Op::new("Tf", vec![Object::name(&run.name), pdfcraft_content::num(size)])); + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the + // line's own spacing back, so it adds no advance. + replacement.push(Op::new("Tc", vec![pdfcraft_content::num(tc)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + replacement.push(Op::new("Tc", vec![pdfcraft_content::num(spacing)])); + } else { + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + } } replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(format!("{} Type3", fallback.family)); @@ -351,7 +360,8 @@ struct Advance<'a> { impl Advance<'_> { fn of(&self, s: &str) -> f64 { - let extra = s.chars().count() as f64 * self.char_spacing; + // A variation selector draws nothing and adds no spacing: it takes no room. + let extra = s.chars().filter(|ch| !is_variation_selector(*ch)).count() as f64 * self.char_spacing; let t = match (self.source, self.type3) { (Some(m), _) => { let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; @@ -399,7 +409,7 @@ fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result, fonts_res: &mut Dict) -> Result { ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); return Ok(()); } - for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != cursor.shown_font { - ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); - cursor.shown_font = font; + for run in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if run.name != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&run.name), n(self.size)])); + cursor.shown_font = run.name; + } + if let Some(tc) = run.spacing { + // A variation selector's empty glyph: shown at spacing zero, then the paragraph's + // own spacing back, so it adds no advance. + ops.push(Op::new("Tc", vec![n(tc)])); + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); + ops.push(Op::new("Tc", vec![n(self.char_spacing)])); + } else { + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(run.bytes))])); } - ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } Ok(()) } diff --git a/crates/edit/tests/flatten_optional_content.rs b/crates/edit/tests/flatten_optional_content.rs new file mode 100644 index 0000000000..b6a6165149 --- /dev/null +++ b/crates/edit/tests/flatten_optional_content.rs @@ -0,0 +1,472 @@ +//! Optional-content regression fixtures for issue #3213. +use std::sync::Arc; + +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental}; + +/// Build a single-page PDF fixture with a hidden OCG (`4 0 R`), a shown OCG (`5 0 R`), +/// a form appearance (`7 0 R`), an annotation (`8 0 R`), and an OCMD (`9 0 R`). +fn fixture(membership: &str, flags: i64, appearance_membership: &str) -> Document { + let ink = "1 0 0 rg 0 0 100 50 re f"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [4 0 R] /ON [5 0 R] /Order [4 0 R 5 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Properties << /Existing 5 0 R /PCFlOC0 5 0 R /Private (keep) >> >> /Contents 6 0 R /Annots [8 0 R] >>".into(), + "<< /Type /OCG /Name (Hidden layer) /Private (keep layer) >>".into(), + "<< /Type /OCG /Name (Shown layer) >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] {appearance_membership} /Length {} >>\nstream\n{ink}\nendstream", ink.len()), + format!("<< /Type /Annot /Subtype /Square /Rect [50 50 150 100] /C [1 0 0] /AP << /N 7 0 R >> /F {flags} {membership} /Private (keep annotation) >>"), + "<< /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep membership) >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// Serialize `doc` using an incremental save. +fn saved(doc: &Document) -> Vec { + write_incremental(doc, &SaveOptions::default()).unwrap() +} + +/// Optionally export test PDF bytes when `PDFCRAFT_3213_ARTIFACTS` is set. +fn export(name: &str, stage: &str, bytes: &[u8]) { + if let Some(directory) = std::env::var_os("PDFCRAFT_3213_ARTIFACTS") { + std::fs::write(std::path::Path::new(&directory).join(format!("{name}-{stage}.pdf")), bytes).unwrap(); + } +} + +/// Verify that transferable optional-content memberships are bound to flattened appearances +/// after saving and reopening, while hidden-flag annotations remain untouched. +#[test] +fn flatten_keeps_annotation_optional_content_after_save_and_reopen() { + let mut lost = Vec::new(); + for (name, membership, flags, ap_oc) in [ + ("off", "/OC 4 0 R", 4, ""), + ("on", "/OC 5 0 R", 4, ""), + ("ocmd", "/OC 9 0 R", 4, ""), + ("independent_ap", "/OC 4 0 R", 4, "/OC 5 0 R"), + ("direct_ocmd", "/OC << /Type /OCMD /OCGs [4 0 R] /P /AllOn /Private (keep inline) >>", 4, ""), + ("ocmd_ve", "/OC << /Type /OCMD /VE [/And 4 0 R [/Not 5 0 R]] /Private (keep expression) >>", 4, ""), + ("visible", "", 4, ""), + ("hidden_flag", "", 2, ""), + ] { + let mut doc = fixture(membership, flags, ap_oc); + let original_appearance = (*doc.get(ObjRef::new(7, 0))).clone(); + let expected_membership = doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().unwrap().get(b"OC").cloned(); + export(name, "before", &saved(&doc)); + let count = pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(); + let bytes = saved(&doc); + export(name, "flat", &bytes); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(count, usize::from(flags != 2), "{name}"); + let paints = paint_memberships(&reopened); + if flags != 2 && paints != vec![(ObjRef::new(7, 0), expected_membership)] { + lost.push(name); + } + assert_eq!(*reopened.get(ObjRef::new(7, 0)), original_appearance, "{name}: preserve appearance"); + } + assert!(lost.is_empty(), "flattened paint loses its original membership: {lost:?}"); +} + +/// Verify that multiple annotations sharing one appearance stream keep separate memberships +/// without mutating the shared appearance stream itself. +#[test] +fn flatten_shared_appearance_keeps_separate_memberships_and_original_stream() { + let mut doc = fixture("/OC 4 0 R", 4, ""); + let first = ObjRef::new(8, 0); + let ap = ObjRef::new(7, 0); + let original_ap = (*doc.get(ap)).clone(); + let mut second = doc.get(first).as_dict().unwrap().clone(); + second.set(b"OC".to_vec(), Object::Ref(ObjRef::new(5, 0))); + second.set(b"Rect".to_vec(), Object::Array([50, 120, 150, 170].into_iter().map(Object::Int).collect())); + let second = doc.add(Object::Dict(second)); + let mut plain = doc.get(first).as_dict().unwrap().clone(); + plain.remove(b"OC"); + plain.set(b"Rect".to_vec(), Object::Array([0, 0, 20, 10].into_iter().map(Object::Int).collect())); + let plain = doc.add(Object::Dict(plain)); + doc.update_dict(ObjRef::new(3, 0), |page| { + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second), Object::Ref(plain)])) + }) + .unwrap(); + export("shared", "before", &saved(&doc)); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 3); + let bytes = saved(&doc); + export("shared", "flat", &bytes); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(*doc.get(ap), original_ap, "a shared appearance must never acquire either annotation's /OC"); + assert_eq!(paint_memberships(&doc), vec![(ap, Some(Object::Ref(ObjRef::new(4, 0)))), (ap, Some(Object::Ref(ObjRef::new(5, 0)))), (ap, None)]); +} + +/// Associate each `Do` invocation with its active `/OC` marked-content block. +fn paint_memberships(doc: &Document) -> Vec<(ObjRef, Option)> { + let page = pdfcraft_model::pages(doc).remove(0); + let resources = doc.resolve(page.dict.get(b"Resources").unwrap()); + let resources = resources.as_dict().unwrap(); + let properties = resources.get(b"Properties").map(|p| doc.resolve(p)); + let xobjects = resources.get(b"XObject").map(|p| doc.resolve(p)); + let content = doc.resolve(page.dict.get(b"Contents").unwrap()); + let streams = match &*content { + Object::Array(list) => list.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + }; + let mut contexts = Vec::new(); + let mut paints = Vec::new(); + for stream in streams { + let resolved = doc.resolve(&stream); + let Object::Stream(stream) = &*resolved else { panic!("content stream") }; + let parsed = pdfcraft_content::parse(&stream.decoded().unwrap()); + assert_eq!(parsed.skipped, 0); + for op in parsed.ops { + if op.is("BDC") { + let membership = if op.name(0) == Some(b"OC") { + Some(properties.as_ref().unwrap().as_dict().unwrap().get(op.name(1).unwrap()).unwrap().clone()) + } else { + None + }; + contexts.push(membership); + } else if op.is("BMC") { + contexts.push(None); + } else if op.is("EMC") { + assert!(contexts.pop().is_some(), "balanced marked content"); + } else if op.is("Do") { + let reference = xobjects.as_ref().unwrap().as_dict().unwrap().reference(op.name(0).unwrap()).unwrap(); + paints.push((reference, contexts.iter().rev().find_map(Clone::clone))); + } + } + } + assert!(contexts.is_empty(), "every optional-content block must close"); + paints +} + +/// Verify that unsupported optional-content memberships retain the annotation, its transitive +/// replies and pop-ups, and its AcroForm field links. +#[test] +fn unsupported_membership_keeps_annotations_replies_and_field_links() { + use pdfcraft_cos::Stream; + let direct_ocg = Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec())); + d + }); + let wrong_type = Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"Other".to_vec())); + d + }); + for membership in [Object::Null, Object::Int(7), Object::Ref(ObjRef::new(999, 0)), Object::Ref(ObjRef::new(7, 0)), direct_ocg, wrong_type] { + for widget in [false, true] { + let mut doc = fixture("", 4, ""); + let retained = ObjRef::new(8, 0); + let mut successful = doc.get(retained).as_dict().unwrap().clone(); + successful.remove(b"OC"); + let successful = doc.add(Object::Dict(successful)); + let empty_appearance = if widget { + let mut empty = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().clone(); + empty.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])); + let reference = doc.add(Object::Stream(Stream::from_raw(empty, Vec::new()))); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(reference)); + Some(Object::Dict(ap)) + } else { + None + }; + doc.update_dict(retained, |d| { + d.set(b"OC".to_vec(), membership.clone()); + d.set(b"IRT".to_vec(), Object::Ref(successful)); + if widget { + d.set(b"Subtype".to_vec(), Object::Name(b"Widget".to_vec())); + d.set(b"AP".to_vec(), empty_appearance.clone().unwrap()); + } else { + d.remove(b"AP"); + } + }) + .unwrap(); + let mut reply = doc.get(retained).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"Subtype".to_vec(), Object::Name(b"Square".to_vec())); + reply.set(b"IRT".to_vec(), Object::Ref(retained)); + reply.set(b"AP".to_vec(), doc.get(successful).as_dict().unwrap().get(b"AP").unwrap().clone()); + let reply = doc.add(Object::Dict(reply)); + let mut grandchild = doc.get(reply).as_dict().unwrap().clone(); + grandchild.set(b"IRT".to_vec(), Object::Ref(reply)); + let grandchild = doc.add(Object::Dict(grandchild)); + let popup = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + d.set(b"Parent".to_vec(), Object::Ref(retained)); + d + })); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set( + b"Annots".to_vec(), + Object::Array(vec![ + Object::Ref(successful), + Object::Ref(retained), + Object::Ref(reply), + Object::Ref(grandchild), + Object::Ref(popup), + ]), + ) + }) + .unwrap(); + if widget { + let fields = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(retained)])); + d + })); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"AcroForm".to_vec(), Object::Ref(fields))).unwrap(); + } + let originals = [retained, reply, grandchild, popup].map(|r| (*doc.get(r)).clone()); + let original_fields = doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, true).unwrap(), 1, "{membership:?}"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [retained, reply, grandchild, popup].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!( + doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), + &vec![Object::Ref(retained), Object::Ref(reply), Object::Ref(grandchild), Object::Ref(popup)] + ); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"AcroForm").map(|o| (*doc.resolve(o)).clone()), original_fields); + } + } + // A stream that claims Type OCG must not qualify through Object::as_dict(). + let mut doc = fixture("/OC 4 0 R", 4, ""); + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::Name(b"OCG".to_vec())); + doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(d, Vec::new()))); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); + + // Shared-subexpression /VE fan-out (10^16 paths within depth 16) stops at the node budget + // and retains the annotation unchanged instead of hanging. + let mut doc = fixture("/OC 9 0 R", 4, ""); + let mut target = ObjRef::new(4, 0); + for _ in 0..16 { + let mut expr = vec![Object::Name(b"Or".to_vec())]; + expr.extend(std::iter::repeat_n(Object::Ref(target), 10)); + target = doc.add(Object::Array(expr)); + } + let ve = (*doc.get(target)).clone(); + doc.update_dict(ObjRef::new(9, 0), |d| { + d.set(b"VE".to_vec(), ve.clone()); + }) + .unwrap(); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); +} + +/// Preserve a malformed reply that targets a retained popup instead of a markup annotation. +#[test] +fn unsupported_membership_keeps_popup_replies_after_save_and_reopen() { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + let popup = doc.add(Object::Dict({ + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + d.set(b"Parent".to_vec(), Object::Ref(root)); + d.set(b"Private".to_vec(), Object::String(pdfcraft_cos::PdfString::text("keep popup"))); + d + })); + let mut reply = doc.get(root).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"IRT".to_vec(), Object::Ref(popup)); + let reply = doc.add(Object::Dict(reply)); + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array([root, popup, reply, drawable].map(Object::Ref).to_vec())); + }) + .unwrap(); + let originals = [root, popup, reply].map(|r| (*doc.get(r)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the unrelated annotation can flatten"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [root, popup, reply].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original, "the retained popup graph stays unchanged"); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, popup, reply].map(Object::Ref)); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]); +} + +/// Undrawable text in a retained reply must not block an unrelated drawable annotation. +#[test] +fn unsupported_membership_keeps_undrawable_reply_without_refusing_flatten() { + for marked_appearance in [false, true] { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + let mut reply = doc.get(root).as_dict().unwrap().clone(); + reply.remove(b"OC"); + reply.set(b"Subtype".to_vec(), Object::Name(b"FreeText".to_vec())); + reply.set(b"IRT".to_vec(), Object::Ref(root)); + reply.set(b"Contents".to_vec(), Object::String(pdfcraft_cos::PdfString::text("漢"))); + if marked_appearance { + pdfcraft_fonts::set_undrawn_mark(&mut reply, Some('漢')); + } else { + reply.remove(b"AP"); + } + assert_eq!(pdfcraft_annot::appearance::undrawable_text(&reply).map(|(_, c)| c), Some('漢')); + let reply = doc.add(Object::Dict(reply)); + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Annots".to_vec(), Object::Array([root, reply, drawable].map(Object::Ref).to_vec())); + }) + .unwrap(); + // Compare serialized dictionaries before and after flatten, including PDF string syntax. + let mut doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let originals = [root, reply].map(|r| (*doc.get(r)).clone()); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1, "retained text is not baked into page content"); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + for (reference, original) in [root, reply].into_iter().zip(originals) { + assert_eq!(*doc.get(reference), original); + } + let page = pdfcraft_model::pages(&doc).remove(0); + assert_eq!(doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap(), &[root, reply].map(Object::Ref)); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), None)]); + } +} + +/// Verify that shared/inherited `/Resources` and `/Properties` dictionaries are never mutated in +/// place and that consecutive flatten runs allocate distinct `/PCFlOC{n}` property names. +#[test] +fn flatten_preserves_shared_resources_and_uses_new_names_on_each_call() { + for inherited in [false, true] { + for indirect in [false, true] { + let mut doc = fixture("/OC 4 0 R", 4, ""); + let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + let original_properties = resources.get(b"Properties").unwrap().clone(); + if indirect { + let r = doc.add(original_properties.clone()); + resources.set(b"Properties".to_vec(), Object::Ref(r)); + } + let resources = if indirect { Object::Ref(doc.add(Object::Dict(resources))) } else { Object::Dict(resources) }; + let owner = ObjRef::new(if inherited { 2 } else { 3 }, 0); + if inherited { + doc.update_dict(ObjRef::new(3, 0), |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } + doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), resources.clone())).unwrap(); + let original_resource_object = (*doc.resolve(&resources)).clone(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0)))); 2]); + let page = pdfcraft_model::pages(&doc).remove(0); + let effective = doc.resolve(page.dict.get(b"Resources").unwrap()); + let properties = doc.resolve(effective.as_dict().unwrap().get(b"Properties").unwrap()); + let properties = properties.as_dict().unwrap(); + for (key, value) in original_properties.as_dict().unwrap().iter() { + assert_eq!(properties.get(key), Some(value)); + } + assert_eq!(properties.len(), original_properties.as_dict().unwrap().len() + 2); + if inherited || indirect { + assert_eq!(*doc.resolve(&resources), original_resource_object); + } + } + } +} + +/// Verify that malformed `/Resources` or `/Properties` entries retain `/OC` annotations without +/// generating appearances, while inline annotations with valid `/OC` still flatten cleanly. +#[test] +fn malformed_resources_and_direct_ocg_stay_unchanged_before_appearance_generation() { + for malformed_resources in [false, true] { + let mut doc = fixture("/OC 4 0 R", 4, ""); + if malformed_resources { + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Null)).unwrap(); + } else { + let mut resources = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone(); + resources.set(b"Properties".to_vec(), Object::Int(1)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + } + doc.update_dict(ObjRef::new(8, 0), |d| { + d.remove(b"AP"); + }) + .unwrap(); + let before = saved(&doc); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 0); + assert_eq!(saved(&doc), before); + } + let mut doc = fixture("/OC 4 0 R", 4, ""); + let annotation = doc.get(ObjRef::new(8, 0)).as_dict().unwrap().clone(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(annotation)]))).unwrap(); + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + assert_eq!(paint_memberships(&doc), vec![(ObjRef::new(7, 0), Some(Object::Ref(ObjRef::new(4, 0))))]); +} + +/// Inline replies and inline popups pointing to a retained annotation are kept in /Annots. +#[test] +fn unsupported_membership_keeps_inline_replies_and_popups_after_save_and_reopen() { + let mut doc = fixture("/OC null", 4, ""); + let root = ObjRef::new(8, 0); + + let mut inline_reply = doc.get(root).as_dict().unwrap().clone(); + inline_reply.remove(b"OC"); + inline_reply.set(b"IRT".to_vec(), Object::Ref(root)); + + let mut indirect_reply = doc.get(root).as_dict().unwrap().clone(); + indirect_reply.remove(b"OC"); + indirect_reply.set(b"IRT".to_vec(), Object::Ref(root)); + let indirect_reply = doc.add(Object::Dict(indirect_reply)); + + let mut inline_grandchild = doc.get(root).as_dict().unwrap().clone(); + inline_grandchild.remove(b"OC"); + inline_grandchild.set(b"IRT".to_vec(), Object::Ref(indirect_reply)); + + let mut inline_popup = pdfcraft_cos::Dict::new(); + inline_popup.set(b"Subtype".to_vec(), Object::Name(b"Popup".to_vec())); + inline_popup.set(b"Parent".to_vec(), Object::Ref(root)); + + let mut drawable = doc.get(root).as_dict().unwrap().clone(); + drawable.remove(b"OC"); + let drawable = doc.add(Object::Dict(drawable)); + + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set( + b"Annots".to_vec(), + Object::Array(vec![ + Object::Dict(inline_reply.clone()), + Object::Ref(root), + Object::Ref(indirect_reply), + Object::Dict(inline_grandchild.clone()), + Object::Dict(inline_popup.clone()), + Object::Ref(drawable), + ]), + ) + }) + .unwrap(); + + assert_eq!(pdfcraft_edit::flatten(&mut doc, &[0], true, false).unwrap(), 1); + + let doc = Document::open(Arc::new(saved(&doc))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()).as_array().unwrap().clone(); + + assert_eq!(annots.len(), 5); + assert_eq!(annots[0], Object::Dict(inline_reply)); + assert_eq!(annots[1], Object::Ref(root)); + assert_eq!(annots[2], Object::Ref(indirect_reply)); + assert_eq!(annots[3], Object::Dict(inline_grandchild)); + assert_eq!(annots[4], Object::Dict(inline_popup)); +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 728b9e3615..f7d7265684 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -763,8 +763,9 @@ pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render:: } /// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. -pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none, dates shown as +/// [`pdfcraft_render::pretty_date`] shows them. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 8] { let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); [ (&mut info.title, read("Title")), @@ -773,6 +774,8 @@ pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Documen (&mut info.keywords, read("Keywords")), (&mut info.creator, read("Creator")), (&mut info.producer, read("Producer")), + (&mut info.created, read("CreationDate").map(|d| pdfcraft_render::pretty_date(&d))), + (&mut info.modified, read("ModDate").map(|d| pdfcraft_render::pretty_date(&d))), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ce0bb5c784..9be9666590 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -117,7 +117,7 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr /// Comment geometry helpers (text-box line breaking) for frontends. pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; +pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom}; pub use pdfcraft_annot::{ Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 82ac32842e..cff0ec010b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4887,6 +4887,25 @@ fn split_by_size_and_bookmarks_and_page_filters() { assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]); } +/// A zoomed link follows its target page, not its number: deleting a page before the target keeps +/// the link on that page at its zoom. Deleting the target page drops the link's destination, as +/// for any link to a deleted page, and undo brings it back with its zoom. +#[test] +fn a_zoomed_link_follows_its_page_and_loses_it_when_the_page_is_deleted() { + let (mut s, id) = session_with(3); + let zoom = LinkZoom::Percent(200.0); + let rect = [10.0, 10.0, 60.0, 30.0]; + s.apply(id, Edit::AddLink { page: 0, rect, action: LinkAction::Page { page: 2, zoom }, style: LinkStyle::default() }).unwrap(); + let action = |s: &Session| s.get(id).unwrap().links()[0].action.clone(); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(action(&s), LinkAction::Page { page: 1, zoom }, "the link follows its page"); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + // The destination is removed, not left pointing at a page that is gone. + assert_eq!(action(&s), LinkAction::Other("no action".into()), "no destination left"); + s.undo(id).unwrap(); + assert_eq!(action(&s), LinkAction::Page { page: 1, zoom }, "undo restores the destination and its zoom"); +} + #[test] fn links_from_urls_and_link_edits() { // One page whose text contains a web address. @@ -4928,8 +4947,12 @@ fn links_from_urls_and_link_edits() { assert_eq!(s.get(id).unwrap().links().len(), 1); // Link Properties and delete. let l = s.get(id).unwrap().links()[0].clone(); - s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap(); - assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page(0)); + s.apply( + id, + Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }), style: None }, + ) + .unwrap(); + assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page { page: 0, zoom: LinkZoom::FitPage }); s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap(); assert!(s.get(id).unwrap().links().is_empty()); assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 6373d6427f..978b9d11ae 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1402,8 +1402,9 @@ fn glyph_component(part: &str) -> Option { if let Ok(i) = crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)) { return char::from_u32(crate::encodings::NAMES[i].1).map(String::from); } - // `from_str_radix` accepts a leading `+`, which is not a glyph name, so the digits are checked. - let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| b.is_ascii_hexdigit()); + // `from_str_radix` accepts a leading `+` and lowercase digits, and the glyph list + // specification's digits are uppercase only (`uni20ac` names nothing), so they are checked. + let hexish = |h: &str| !h.is_empty() && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'A'..=b'F')); if let Some(hex) = part.strip_prefix("uni").filter(|h| h.len() >= 4 && h.len() % 4 == 0 && hexish(h)) { let mut s = String::new(); for i in (0..hex.len()).step_by(4) { @@ -1464,6 +1465,9 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { out.encoding.insert(code as u32, c.to_string()); } } + if base_name.is_none() && !symbolic_builtin { + out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + } if let Some(Object::Dict(d)) = enc.as_deref() && let Some(diffs) = d.get(b"Differences").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()) { @@ -1527,6 +1531,74 @@ fn identity_to_unicode() -> Vec { cmap.into_bytes() } +/// What each code of an embedded TrueType font that isn't flagged non-symbolic, names no base +/// encoding and doesn't name its glyphs shows when two things say the code is Mac OS Roman: its +/// Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4: +/// single bytes "look up the associated glyph descriptions" there, and a (3, 0) subtable maps "the +/// single-byte codes in the (1, 0) subtable" prepended with 0xF0), and the glyph sits where the +/// standard Macintosh glyph order (the 258 names of a version 1.0 `post` table) puts that Mac OS +/// Roman character. So code 0x88 drawn with glyph 106, `agrave`, is `à`, where Standard has +/// nothing and WinAnsi `ˆ`, while a Windows subset in the same order whose codes are WinAnsi (0x92 +/// drawn with glyph 183, `quoteright`) keeps its reading. The glyph drawn is found the way the +/// renderer finds it: the Windows (3, 0) and (3, 1) subtables first, with the code and the code at +/// 0xF000, 0xF100 and 0xF200, then the Macintosh and Unicode-platform ones. The renderer's text +/// layer reads the same codes the same way (vendored hayro-interpret patch (101)). +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { + use skrifa::GlyphId; + use skrifa::raw::{TableProvider as _, tables::cmap::PlatformId, tables::post::DEFAULT_GLYPH_NAMES, types::Tag}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; + // Flag bit 6, Nonsymbolic (ISO 32000-2 Table 121). + if descriptor_flags(doc, &descriptor) & 32 != 0 { + return Vec::new(); + } + let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; + let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; + // A CFF program in `/FontFile2` selects its glyphs by name, not through the `cmap`. A `post` + // table of version 1.0 or 2.0 (also with its 16-bit halves swapped, as the renderer reads it) + // names the glyphs, and a font that names them needn't keep the order: a Wingdings subset + // draws code 0x83 with glyph 101, `Eacute` in the order, which it names `threesans`. + let named = face + .table_data(Tag::new(b"post")) + .is_some_and(|data| matches!(data.as_bytes().get(..4), Some([0, 1, 0, 0] | [0, 2, 0, 0] | [0, 0, 0, 1] | [0, 0, 0, 2]))); + if face.cff().is_ok() || named { + return Vec::new(); + } + let Ok(cmap) = face.cmap() else { return Vec::new() }; + let records = cmap.encoding_records(); + let lookup = |code: u32, windows: bool| { + records.iter().find_map(|record| { + let offsets: &[u32] = match (record.platform_id(), record.encoding_id()) { + (PlatformId::Windows, 0 | 1) if windows => &[0, 0xF000, 0xF100, 0xF200], + (PlatformId::Macintosh | PlatformId::Unicode, 0) if !windows => &[0], + _ => return None, + }; + let subtable = record.subtable(cmap.offset_data()).ok()?; + offsets.iter().find_map(|o| subtable.map_codepoint(code + o).filter(|g| *g != GlyphId::NOTDEF)) + }) + }; + (0..=255u32) + .filter_map(|code| { + let drawn = lookup(code, true).or_else(|| lookup(code, false))?; + let mac = records.iter().find_map(|record| { + (record.platform_id() == PlatformId::Macintosh && record.encoding_id() == 0) + .then(|| record.subtable(cmap.offset_data()).ok()?.map_codepoint(code)) + .flatten() + })?; + if mac != drawn { + return None; + } + let c = char::from_u32(*crate::encodings::MAC_ROMAN.get(usize::try_from(code).ok()?)?).filter(|c| *c != '\0')?; + let standard = *DEFAULT_GLYPH_NAMES.get(usize::try_from(drawn.to_u32()).ok()?)?; + // Mac OS Roman 0xCA reads as a space, for the reason WinAnsi 0xA0 does (`MAC_ROMAN`). + let at_its_place = glyph_unicode(standard) == Some(c) || (code == 0xCA && standard == "nonbreakingspace"); + at_its_place.then(|| (code, c.to_string())) + }) + .collect() +} + #[cfg(test)] mod tests; @@ -1560,6 +1632,21 @@ mod encoding_tests { Object::Dict(enc) } + /// The glyph list specification's hex forms: `uni` and groups of exactly four uppercase + /// digits, `u` and four to six. + #[test] + fn glyph_names_take_uppercase_hex_in_whole_groups() { + assert_eq!(glyph_text("uni20ac"), None, "the specification's hex digits are uppercase"); + assert_eq!(glyph_text("u1f600"), None); + assert_eq!(glyph_text("uni65"), None, "`uni` takes groups of exactly four digits"); + assert_eq!(glyph_text("u65"), None, "`u` takes four to six digits"); + assert_eq!(glyph_text("u0010FFFF"), None); + assert_eq!(glyph_text("uni0065").as_deref(), Some("e")); + assert_eq!(glyph_text("u0065").as_deref(), Some("e"), "`u` and four digits"); + assert_eq!(glyph_text("u1F600").as_deref(), Some("\u{1f600}"), "`u` and five"); + assert_eq!(glyph_text("u10FFFF").as_deref(), Some("\u{10ffff}"), "`u` and six, the last code point"); + } + #[test] fn glyph_names_follow_the_naming_algorithm() { // Annex D names and the two hex forms, as before. diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index d1b96fe3d0..9a0a32be6a 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -1185,6 +1185,145 @@ fn low_codes_resolve_to_the_covering_range() { assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); } +/// [`cmap_only_sfnt_named`] without a `post` table. +fn cmap_only_sfnt(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)]) -> Vec { + cmap_only_sfnt_named(mac, windows, false) +} + +/// An sfnt holding a `cmap` table with these subtables: (1, 0) format 0 mapping codes to glyphs, +/// and (3, 0) format 4 mapping each code point to its glyph, one segment each; and, if `named`, a +/// version 2.0 `post` table. +fn cmap_only_sfnt_named(mac: Option<&[(u8, u8)]>, windows: &[(u16, u16)], named: bool) -> Vec { + let be16 = |out: &mut Vec, n: u16| out.extend_from_slice(&n.to_be_bytes()); + let mut subtables: Vec<((u16, u16), Vec)> = Vec::new(); + if let Some(map) = mac { + let mut t = Vec::new(); + for n in [0u16, 262, 0] { + be16(&mut t, n); + } + let mut ids = [0u8; 256]; + for &(code, glyph) in map { + ids[usize::from(code)] = glyph; + } + t.extend(ids); + subtables.push(((1, 0), t)); + } + let segments: Vec<(u16, u16)> = windows.iter().copied().chain([(0xFFFF, 0)]).collect(); + let count = segments.len() as u16; + let mut t = Vec::new(); + for n in [4u16, 16 + 8 * count, 0, 2 * count, 2, 0, 0] { + be16(&mut t, n); + } + segments.iter().for_each(|&(c, _)| be16(&mut t, c)); + be16(&mut t, 0); + segments.iter().for_each(|&(c, _)| be16(&mut t, c)); + segments.iter().for_each(|&(c, g)| be16(&mut t, if c == 0xFFFF { 1 } else { g.wrapping_sub(c) })); + segments.iter().for_each(|_| be16(&mut t, 0)); + subtables.push(((3, 0), t)); + + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, subtables.len() as u16); + let mut offset = 4 + 8 * subtables.len() as u32; + for ((platform, encoding), bytes) in &subtables { + be16(&mut cmap, *platform); + be16(&mut cmap, *encoding); + cmap.extend_from_slice(&offset.to_be_bytes()); + offset += bytes.len() as u32; + } + subtables.iter().for_each(|(_, bytes)| cmap.extend(bytes)); + let mut tables = vec![(*b"cmap", cmap)]; + if named { + // Version 2.0 with no glyphs to name. + let mut post = vec![0, 2, 0, 0]; + post.resize(34, 0); + tables.push((*b"post", post)); + } + let mut out = vec![0, 1, 0, 0]; + out.extend_from_slice(&(tables.len() as u16).to_be_bytes()); + out.extend([0; 6]); + let mut offset = 12 + 16 * tables.len() as u32; + for (tag, table) in &tables { + out.extend(tag); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&offset.to_be_bytes()); + out.extend_from_slice(&(table.len() as u32).to_be_bytes()); + offset += table.len() as u32; + } + tables.into_iter().for_each(|(_, table)| out.extend(table)); + out +} + +/// A symbolic TrueType font with no `/Encoding` reads a code as Mac OS Roman when its (1, 0) `cmap` +/// subtable selects the glyph the code draws and the glyph sits where the standard Macintosh glyph +/// order puts that character, as the renderer's text layer reads it (vendored hayro-interpret +/// patch (101), ISO 32000-2 §9.6.5.4). Otherwise the codes read as before (Standard): WinAnsi codes +/// in that order (0x92 drawn with `quoteright`), no (1, 0) subtable, one selecting other glyphs +/// than the (3, 0) one, a font flagged non-symbolic, or one that names its glyphs. +#[test] +fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman() { + let read = |program: Vec, flags: i64, codes: [u32; 2]| { + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(flags)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let u = unicode_map(&doc, &f, false); + codes.map(|c| u.text(c).unwrap_or_default().into_owned()) + }; + // `agrave` and `eacute` are glyphs 106 and 112 of the standard Macintosh order, `endash` and + // `quoteright` 178 and 183. + let mac_order = cmap_only_sfnt(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(mac_order.clone(), 4, [0x88, 0x8E]), ["à", "é"]); + assert_eq!(read(mac_order, 32, [0x88, 0x8E]), ["", ""], "flagged non-symbolic"); + let winansi = cmap_only_sfnt(Some(&[(0x92, 183), (0x96, 178)]), &[(0xF092, 183), (0xF096, 178)]); + assert_eq!(read(winansi, 4, [0x92, 0x96]), ["", ""], "WinAnsi codes in the Macintosh order"); + let windows_only = cmap_only_sfnt(None, &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(windows_only, 4, [0x88, 0x8E]), ["", ""], "no (1, 0) subtable"); + let disagreeing = cmap_only_sfnt(Some(&[(0x88, 112), (0x8E, 106)]), &[(0xF088, 106), (0xF08E, 112)]); + assert_eq!(read(disagreeing, 4, [0x88, 0x8E]), ["", ""], "a (1, 0) subtable selecting other glyphs"); + // A `post` table of version 2.0 names the glyphs; what it calls them doesn't matter here. + let named = cmap_only_sfnt_named(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)], true); + assert_eq!(read(named, 4, [0x88, 0x8E]), ["", ""], "a font that names its glyphs"); +} + +/// The Mac OS Roman reading applies only to a font that names no base encoding and isn't Symbol or +/// ZapfDingbats (ISO 32000-2 §9.6.5.4, §9.6.6.1): a named `/BaseEncoding` or `/Encoding` keeps its +/// own reading, a `/Differences` without a base encoding still gets it, and a Symbol font keeps no +/// implicit mapping. +#[test] +fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { + let read = |entries: Vec<(&str, Object)>| { + let mut doc = Document::new_empty(); + let program = cmap_only_sfnt(Some(&[(0x88, 106), (0x8E, 112)]), &[(0xF088, 106), (0xF08E, 112)]); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &program))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut all = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; + all.extend(entries); + let f = font(&mut doc, all); + let u = unicode_map(&doc, &f, false); + [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) + }; + assert_eq!(read(vec![]), ["à", "é"]); + assert_eq!(read(vec![("Encoding", Object::name("WinAnsiEncoding"))]), ["ˆ", "Ž"], "a named base encoding"); + let mut based = Dict::new(); + based.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + assert_eq!(read(vec![("Encoding", Object::Dict(based))]), ["ˆ", "Ž"], "a /BaseEncoding"); + let mut diffs = Dict::new(); + diffs.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x8E), Object::name("ecircumflex")])); + assert_eq!(read(vec![("Encoding", Object::Dict(diffs))]), ["à", "ê"], "/Differences without a base encoding"); + assert_eq!(read(vec![("BaseFont", Object::name("Symbol"))]), ["", ""], "Symbol"); + assert_eq!(read(vec![("BaseFont", Object::name("ZapfDingbats"))]), ["", ""], "ZapfDingbats"); + // A name it can't read clears the Mac OS Roman reading for its code, as it clears a base + // encoding's. + let mut notdef = Dict::new(); + notdef.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x8E), Object::name(".notdef")])); + assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); +} + #[test] fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5ebe3269cc..6a91220713 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -97,21 +97,13 @@ impl FieldAction { } } -fn texts(doc: &Document, o: &Object) -> Vec { - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(), - Object::String(s) => vec![s.to_text()], - _ => Vec::new(), - } -} - fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), - b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), + b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| crate::read::action_fields(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), b"GoTo" => { let dest = d.get(b"D").map(|x| doc.resolve(x))?; @@ -119,8 +111,8 @@ fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mu FieldAction::GoTo(pages.iter().position(|p| *p == target)?) } b"Hide" => FieldAction::ShowHide { - fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(), - hide: !matches!(d.get(b"H"), Some(Object::Bool(false))), + fields: d.get(b"T").map(|t| crate::read::action_fields(doc, t)).unwrap_or_default(), + hide: !d.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))), }, b"SubmitForm" => FieldAction::Submit( d.get(b"F") @@ -344,3 +336,99 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reset_and_hide_actions_resolve_hierarchical_widget_and_indirect_field_targets() { + let mut doc = Document::new_empty(); + let rect = || Object::Array(vec![0.into(), 0.into(), 50.into(), 20.into()]); + + // 1. Hierarchical child field `address.city` (merged field + widget). + let address = doc.add(Object::Null); + let mut city = Dict::new(); + city.set(b"Parent".to_vec(), Object::Ref(address)); + city.set(b"FT".to_vec(), Object::name("Tx")); + city.set(b"T".to_vec(), PdfString::text("city")); + city.set(b"Subtype".to_vec(), Object::name("Widget")); + city.set(b"Rect".to_vec(), rect()); + let city = doc.add(Object::Dict(city)); + let mut addr_dict = Dict::new(); + addr_dict.set(b"T".to_vec(), PdfString::text("address")); + addr_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(city)])); + doc.set(address, Object::Dict(addr_dict)); + + // 2. Separated field `phone` whose widget dictionary has `/Parent` instead of `/T`. + let phone = doc.add(Object::Null); + let mut phone_widget = Dict::new(); + phone_widget.set(b"Subtype".to_vec(), Object::name("Widget")); + phone_widget.set(b"Parent".to_vec(), Object::Ref(phone)); + phone_widget.set(b"Rect".to_vec(), rect()); + let phone_widget = doc.add(Object::Dict(phone_widget)); + let mut phone_dict = Dict::new(); + phone_dict.set(b"FT".to_vec(), Object::name("Tx")); + phone_dict.set(b"T".to_vec(), PdfString::text("phone")); + phone_dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(phone_widget)])); + doc.set(phone, Object::Dict(phone_dict)); + + // 3. Indirect string target `"email"`. + let email_str = doc.add(Object::String(PdfString::text("email"))); + let targets = Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(email_str)]); + + let mut reset_action = Dict::new(); + reset_action.set(b"S".to_vec(), Object::name("ResetForm")); + reset_action.set(b"Fields".to_vec(), targets.clone()); + let reset_action = doc.add(Object::Dict(reset_action)); + + let indirect_false = doc.add(Object::Bool(false)); + let mut hide_action = Dict::new(); + hide_action.set(b"S".to_vec(), Object::name("Hide")); + hide_action.set(b"T".to_vec(), targets); + hide_action.set(b"H".to_vec(), Object::Ref(indirect_false)); + let hide_action = doc.add(Object::Dict(hide_action)); + + let mut aa = Dict::new(); + aa.set(b"D".to_vec(), Object::Ref(hide_action)); + let mut btn = Dict::new(); + btn.set(b"FT".to_vec(), Object::name("Btn")); + btn.set(b"Ff".to_vec(), Object::Int(1 << 16)); + btn.set(b"T".to_vec(), PdfString::text("btn")); + btn.set(b"Subtype".to_vec(), Object::name("Widget")); + btn.set(b"Rect".to_vec(), rect()); + btn.set(b"A".to_vec(), Object::Ref(reset_action)); + btn.set(b"AA".to_vec(), Object::Dict(aa)); + let btn = doc.add(Object::Dict(btn)); + + let root = doc.root().unwrap(); + let pages_ref = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).unwrap(); + let mut page_dict = Dict::new(); + page_dict.set(b"Type".to_vec(), Object::name("Page")); + page_dict.set(b"Parent".to_vec(), Object::Ref(pages_ref)); + page_dict.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page_dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(city), Object::Ref(phone_widget), Object::Ref(btn)])); + let page = doc.add(Object::Dict(page_dict)); + doc.update_dict(pages_ref, |p| { + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(address), Object::Ref(phone), Object::Ref(btn)])); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + + let expected = vec!["address.city".to_string(), "phone".to_string(), "email".to_string()]; + let btn_field = fields(&doc).into_iter().find(|f| f.name == "btn").unwrap(); + assert_eq!(btn_field.button, Some(crate::af::ButtonAction::Reset { fields: expected.clone(), exclude: false })); + + let acts = field_actions(&doc, "btn").unwrap(); + assert_eq!( + acts, + vec![ + (Trigger::MouseUp, FieldAction::Reset(expected.clone())), + (Trigger::MouseDown, FieldAction::ShowHide { fields: expected, hide: false }), + ] + ); + } +} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 16bdae314d..4cb8da3b0f 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -20,17 +20,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); Some(match a.name(b"S")? { b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); + let fields = a.get(b"Fields").map(|f| action_fields(doc, f)).unwrap_or_default(); let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; af::ButtonAction::Reset { fields, exclude } } @@ -48,18 +38,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) } b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; + let fields = a.get(b"T").map(|t| action_fields(doc, t)).unwrap_or_default(); let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); af::ButtonAction::ShowHide { fields, hide } } @@ -91,6 +70,19 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Dec }) } +/// The field names targeted by a `ResetForm` (`/Fields`) or `Hide` (`/T`) action entry: a field +/// name string, an indirect field or widget dictionary reference, or an array of them. +pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec { + let target = |item: &Object| match item { + Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(item))), + other => text_of(other), + }; + match &*doc.resolve(o) { + Object::Array(items) => items.iter().filter_map(target).collect(), + _ => target(o).into_iter().collect(), + } +} + /// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, /// from the partial names (`/T`) up its `/Parent` chain. fn qualified_name(doc: &Document, r: ObjRef) -> Option { diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 93ed2d7c7a..78dcdcff28 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -198,6 +198,9 @@ pub fn document_scripts(doc: &Document) -> Vec { document_scripts_named(doc).into_iter().map(|(_, js)| js).collect() } +/// Most `/Kids` entries the document JavaScript name tree walk reads in all. +const MAX_QUEUED_KIDS: usize = 1 << 20; + /// [`document_scripts`] with their names. pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(root) = doc.root() else { return Vec::new() }; @@ -206,43 +209,72 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); let mut scripts = crate::script_budget(); - // Kids stay unresolved on the stack, and each node is read once: a node listed many times, - // or among its own kids, must not multiply the work. + // Kids are indirect references (ISO 32000-2 §7.9.6), queued unresolved, each the first time + // it is listed: a node listed many times, or among its own kids, must not multiply the work, + // and the stack holds only references (a direct object there would be copied whole, once + // for every node listing it). Only the root may be direct. Nodes are told apart by object + // number, as `Document::get` finds them, so `5 0 R` and `5 1 R` are one node. + let mut seen: HashSet = tree.as_ref().map(|r| r.num).into_iter().collect(); + // The `/Names` arrays read, by object number: nodes sharing one would otherwise each list + // all its scripts again. + let mut names_read: HashSet = HashSet::new(); let mut stack = vec![tree]; - let mut seen: HashSet = HashSet::new(); let mut visited = 0; + // Every `/Kids` entry read, over the whole walk. Nodes sharing one long `/Kids` array would + // otherwise read all of it once per node. + let mut queued = 0usize; while let Some(raw) = stack.pop() { - if let Object::Ref(r) = raw - && !seen.insert(r) - { - continue; - } visited += 1; if visited > 10_000 { break; } let node = doc.resolve(&raw); let Some(d) = node.as_dict() else { continue }; - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - stack.extend(kids); + if let Some(kids) = d.get(b"Kids") { + in_array(doc, kids, |kids| { + // Past the limit nothing more is read, but the nodes already queued still are. + // Pushed in reverse, so the kids are read in the order listed. + let room = MAX_QUEUED_KIDS.saturating_sub(queued); + queued = queued.saturating_add(kids.len().min(room)); + let new: Vec = kids.iter().take(room).filter_map(Object::as_ref).filter(|r| seen.insert(r.num)).collect(); + stack.extend(new.into_iter().rev().map(Object::Ref)); + }); } - if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { - for pair in pairs.chunks(2) { - let [k, v] = pair else { continue }; - let key = match &*doc.resolve(k) { - Object::String(s) => s.to_text(), - _ => continue, - }; - if let Some(js) = crate::script(doc, v, &mut scripts) { - out.push((key, js)); + if let Some(pairs) = d.get(b"Names") + && pairs.as_ref().is_none_or(|r| names_read.insert(r.num)) + { + in_array(doc, pairs, |pairs| { + for pair in pairs.chunks(2) { + let [k, v] = pair else { continue }; + let key = match &*doc.resolve(k) { + Object::String(s) => s.to_text(), + _ => continue, + }; + if let Some(js) = crate::script(doc, v, &mut scripts) { + out.push((key, js)); + } } - } + }); } } out.sort(); out } +/// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would +/// copy it. +fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { + match o { + Object::Ref(r) => { + if let Some(a) = doc.get(*r).as_array() { + f(a); + } + } + Object::Array(a) => f(a), + _ => {} + } +} + /// Add (or replace) a document-level JavaScript named `name`. pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> { let root = doc.root().ok_or(FormError::NoForm)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 09564c2dee..0dc29632d9 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3609,6 +3609,97 @@ fn document_scripts_read_each_name_tree_node_once() { assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); } +#[test] +fn name_tree_nodes_sharing_one_long_kids_array_queue_it_a_bounded_number_of_times() { + // 10,000 nodes share one /Kids array of 210,001 entries: the leaf, every node, and the leaf + // 200,000 times more. Queuing that array once per node would hold two billion entries. + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let action = doc.add(dict(vec![ + (b"S", Object::Name(b"JavaScript".to_vec())), + (b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))), + ])); + let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))])); + let kids = doc.add(Object::Null); + let nodes: Vec = (0..10_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))).collect(); + let mut shared = vec![Object::Ref(leaf); 200_000]; + shared.extend(nodes); + shared.push(Object::Ref(leaf)); + doc.set(kids, Object::Array(shared)); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); +} + +#[test] +fn a_direct_object_in_a_shared_kids_array_is_not_copied_for_every_node() { + // 1,000 nodes share one /Kids array that also holds a direct dictionary with a 2 MiB + // string. Queued with every node's kids, it would be copied 1,000 times (2 GiB) while the + // entry count stayed under its limit. Kids are references, so it is skipped. + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let action = doc.add(dict(vec![ + (b"S", Object::Name(b"JavaScript".to_vec())), + (b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))), + ])); + let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))])); + let kids = doc.add(Object::Null); + let big = dict(vec![(b"Pad", Object::String(pdfcraft_cos::PdfString::literal("x".repeat(2 << 20))))]); + let mut shared = vec![big]; + shared.extend((0..1_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))]))))); + shared.push(Object::Ref(leaf)); + doc.set(kids, Object::Array(shared)); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); +} + +#[test] +fn a_name_tree_node_listed_under_many_generations_is_read_once() { + // `Document::get` finds a node by object number alone, so `4 0 R` and `4 1 R` are one node. + // The root lists one under 10,000 generations: read for each, it would list its script + // 10,000 times and use up the walk's 10,000 visits before the node listed after it. + let kids: Vec = (0..10_000).map(|g| format!("4 {g} R")).collect(); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Kids [{} 5 0 R] >>", kids.join(" ")), + "<< /Names [(a) 6 0 R] >>".into(), + "<< /Names [(b) 6 0 R] >>".into(), + "<< /S /JavaScript /JS (var ready = true;) >>".into(), + ]); + let scripts = document_scripts_named(&doc); + assert_eq!(scripts.len(), 2, "{} scripts", scripts.len()); + let js = "var ready = true;".to_string(); + assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]); +} + +#[test] +fn name_tree_nodes_sharing_one_names_array_list_its_scripts_once() { + // 9,999 nodes share one /Names array. Read for every node, its scripts would be listed 9,999 + // times: ten million scripts and 1.3 GB when it names 1,000. + let nodes: Vec = (0..9_999).map(|i| format!("{} 0 R", i + 6)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".to_string(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Kids [{}] >>", nodes.join(" ")), + "[(a) 5 0 R (b) 5 0 R]".into(), + "<< /S /JavaScript /JS (var ready = true;) >>".into(), + ]; + objs.extend(nodes.iter().map(|_| "<< /Names 4 0 R >>".to_string())); + let doc = document(&objs); + let scripts = document_scripts_named(&doc); + assert_eq!(scripts.len(), 2, "{} scripts", scripts.len()); + let js = "var ready = true;".to_string(); + assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]); +} + /// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one, /// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list /// box without one, and G only a down appearance (`/D`, no `/N`). diff --git a/crates/optimize/src/audit.rs b/crates/optimize/src/audit.rs index 5aa6669b45..afa95f555e 100644 --- a/crates/optimize/src/audit.rs +++ b/crates/optimize/src/audit.rs @@ -1,14 +1,15 @@ //! PDF Optimizer ▸ Audit space usage: how many bytes each kind of content takes. //! -//! Every object is sized as it would be written (dictionary plus stream data). Resources go by -//! kind first, wherever they are used: a font or image a form field or comment draws with is -//! still a font or image. Every other object goes to the first category that reaches it; what -//! remains (the catalog, page tree, info, metadata, cross-reference data and anything -//! unreachable) is document overhead. +//! Every standalone object is sized as it would be written (dictionary plus stream data), while +//! objects packed in compressed object streams share their object stream's compressed bytes in +//! proportion to their uncompressed size. Resources go by kind first, wherever they are used: a +//! font or image a form field or comment draws with is still a font or image. Every other object +//! goes to the first category that reaches it; what remains (the catalog, page tree, info, +//! metadata, cross-reference data and anything unreachable) is document overhead. use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{Dict, Document, MAX_DECODED, Object, XrefEntry}; /// The categories, in Acrobat's order. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -189,12 +190,27 @@ fn annotation_category(doc: &Document, annot: &Object) -> SpaceCategory { if widget { SpaceCategory::Forms } else { SpaceCategory::Comments } } -fn size(doc: &Document, num: u32) -> u64 { - let obj = doc.get(ObjRef::new(num, doc.generation(num))); +fn serialized_len(doc: &Document, num: u32) -> Option { + let obj = doc.try_get(num).ok()?; let mut out = Vec::new(); pdfcraft_cos::serialize(&obj, &mut out); + Some(out.len() as u64) +} + +fn size(doc: &Document, num: u32) -> u64 { // "n g obj\n … \nendobj\n" and its cross-reference entry. - out.len() as u64 + 20 + 20 + serialized_len(doc, num).map_or(0, |len| len + 20 + 20) +} + +/// The unedited object stream `num` is packed in, when `num` itself has no unsaved edit. +fn object_stream(doc: &Document, num: u32) -> Option { + if doc.is_edited(num) { + return None; + } + match doc.xref_entry(num) { + Some(XrefEntry::InStream { stream, .. }) if !doc.is_edited(stream) => Some(stream), + _ => None, + } } /// Audit the space a document uses; `file_len` is the size of the file on disk (the rest is @@ -253,9 +269,57 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { a.claim(e, SpaceCategory::EmbeddedFiles); } let mut bytes: HashMap = HashMap::new(); + let mut streams: HashMap = HashMap::new(); + for &num in a.owner.keys() { + if let Some(stream) = object_stream(doc, num) + && matches!(doc.try_get(stream).as_deref(), Ok(Object::Stream(_))) + { + streams.entry(stream).or_insert(([0; SpaceCategory::ALL.len()], 0, 0)); + } + } for num in doc.object_numbers() { - if let Some(c) = a.owner.get(&num) { - *bytes.entry(*c).or_default() += size(doc, num); + if let Some(stream) = object_stream(doc, num) + && let Some((by_cat, total_u, count)) = streams.get_mut(&stream) + { + if let Some(len) = serialized_len(doc, num) { + let u = len + 1; + *total_u += u; + *count += 1; + if let Some(&c) = a.owner.get(&num) { + by_cat[c as usize] += u; + } + } + } else if !streams.contains_key(&num) + && let Some(&c) = a.owner.get(&num) + { + *bytes.entry(c).or_default() += size(doc, num); + } + } + for (stream, (by_cat, mut total_u, count)) in streams { + let Ok(stm_obj) = doc.try_get(stream) else { continue }; + let Object::Stream(stm) = &*stm_obj else { continue }; + let declared_n = usize::try_from(stm.dict.int(b"N").unwrap_or(0).max(0)).unwrap_or(0); + if count < declared_n + && let Ok(decoded) = stm.decoded_within(MAX_DECODED) + { + let first = usize::try_from(stm.dict.int(b"First").unwrap_or(0).max(0)).unwrap_or(0); + total_u = total_u.max(decoded.len().saturating_sub(first) as u64); + } + if total_u == 0 { + continue; + } + let raw_len = u128::from(stm.raw.len() as u64); + let total_u = u128::from(total_u); + let mut acc: u128 = 0; + for c in SpaceCategory::ALL { + let cat_u = u128::from(by_cat[c as usize]); + if cat_u == 0 { + continue; + } + let prev = (acc * raw_len) / total_u; + acc += cat_u; + let next = (acc * raw_len) / total_u; + *bytes.entry(c).or_default() += (next - prev) as u64; } } let counted: u64 = bytes.values().sum(); @@ -269,3 +333,220 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, Stream, write_full}; + + use super::*; + + fn one_page_doc() -> (Document, ObjRef) { + let mut doc = Document::new_empty(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT ET"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + doc.update_dict(pages, |d| { + d.set(b"Count".to_vec(), Object::Int(1)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + (doc, page) + } + + #[test] + fn space_audit_sizes_objects_packed_in_compressed_object_streams_as_compressed() { + let (mut doc, page) = one_page_doc(); + let outlines = doc.add(Object::Null); + let mut items = Vec::new(); + for i in 0..80 { + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(PdfString::literal(format!("Chapter {i}: Section heading with repeated text").into_bytes()))); + item.set(b"Parent".to_vec(), Object::Ref(outlines)); + item.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + items.push(doc.add(Object::Dict(item))); + } + let mut root_outlines = Dict::new(); + root_outlines.set(b"First".to_vec(), Object::Ref(items[0])); + root_outlines.set(b"Last".to_vec(), Object::Ref(*items.last().unwrap())); + root_outlines.set(b"Count".to_vec(), Object::Int(items.len() as i64)); + doc.set(outlines, Object::Dict(root_outlines)); + for pair in items.windows(2) { + doc.update_dict(pair[0], |d| d.set(b"Next".to_vec(), Object::Ref(pair[1]))).unwrap(); + doc.update_dict(pair[1], |d| d.set(b"Prev".to_vec(), Object::Ref(pair[0]))).unwrap(); + } + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Ref(outlines))).unwrap(); + + let unpacked = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let packed = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(packed.len() * 3 < unpacked.len(), "{} vs {}", packed.len(), unpacked.len()); + + let unpacked_doc = Document::open(Arc::new(unpacked.clone())).unwrap(); + let unpacked_bookmarks = + audit_space(&unpacked_doc, unpacked.len() as u64).into_iter().find(|u| u.category == SpaceCategory::Bookmarks).unwrap().bytes; + + let reopened = Document::open(Arc::new(packed.clone())).unwrap(); + let audit = audit_space(&reopened, packed.len() as u64); + let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().clone(); + let bookmarks = get(SpaceCategory::Bookmarks); + let overhead = get(SpaceCategory::DocumentOverhead); + + assert!(bookmarks.bytes > 0 && bookmarks.bytes < packed.len() as u64, "{audit:?}"); + assert!(bookmarks.bytes * 3 < unpacked_bookmarks, "{} vs {}", bookmarks.bytes, unpacked_bookmarks); + assert!(overhead.bytes > 0, "packed bookmarks must not swallow the file's overhead: {audit:?}"); + assert_eq!(audit.iter().map(|u| u.bytes).sum::(), packed.len() as u64); + } + + #[test] + fn space_audit_shares_an_object_stream_across_categories_and_survives_freed_or_edited_members() { + let (mut doc, page) = one_page_doc(); + // A small bookmark and a large structure tree packed into the same object stream. + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(PdfString::literal(b"Intro".to_vec()))); + item.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let item = doc.add(Object::Dict(item)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Ref(item)); + outlines.set(b"Last".to_vec(), Object::Ref(item)); + outlines.set(b"Count".to_vec(), Object::Int(1)); + let outlines = doc.add(Object::Dict(outlines)); + doc.update_dict(item, |d| d.set(b"Parent".to_vec(), Object::Ref(outlines))).unwrap(); + + let mut kids = Vec::new(); + for i in 0..40 { + let mut elem = Dict::new(); + elem.set(b"Type".to_vec(), Object::name("StructElem")); + elem.set(b"S".to_vec(), Object::name("P")); + elem.set(b"Alt".to_vec(), Object::String(PdfString::literal(format!("Paragraph {i} with descriptive alternate text").into_bytes()))); + kids.push(Object::Ref(doc.add(Object::Dict(elem)))); + } + let mut struct_root = Dict::new(); + struct_root.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + struct_root.set(b"K".to_vec(), Object::Array(kids.clone())); + let struct_root = doc.add(Object::Dict(struct_root)); + + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + c.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root)); + }) + .unwrap(); + + let packed = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(packed.clone())).unwrap(); + let before = audit_space(&reopened, packed.len() as u64); + let bytes_of = |rows: &[SpaceUse], c: SpaceCategory| rows.iter().find(|u| u.category == c).unwrap().bytes; + let bookmarks_before = bytes_of(&before, SpaceCategory::Bookmarks); + let struct_before = bytes_of(&before, SpaceCategory::StructureInfo); + assert!(bookmarks_before > 0 && struct_before > bookmarks_before * 5, "{before:?}"); + + // Freeing the structure elements in memory leaves their dead bytes in the object stream on + // disk as overhead; the surviving bookmark keeps its own small share. + let mut freed_struct = reopened.clone(); + let cat_ref = freed_struct.root().unwrap(); + let sr = freed_struct.get(cat_ref).as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); + let elem_refs: Vec = + freed_struct.get(sr).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + freed_struct + .update_dict(cat_ref, |c| { + c.remove(b"StructTreeRoot"); + }) + .unwrap(); + freed_struct.free(sr); + for r in elem_refs { + freed_struct.free(r); + } + let after_free = audit_space(&freed_struct, packed.len() as u64); + assert_eq!(bytes_of(&after_free, SpaceCategory::StructureInfo), 0); + assert_eq!(bytes_of(&after_free, SpaceCategory::Bookmarks), bookmarks_before, "bookmark share stays unchanged after freeing other members"); + + // Editing the bookmark in memory moves it out of the object stream and sizes its new value. + let mut edited = reopened; + let outlines_ref = edited.get(cat_ref).as_dict().unwrap().reference(b"Outlines").unwrap(); + let first_ref = edited.get(outlines_ref).as_dict().unwrap().reference(b"First").unwrap(); + edited + .update_dict(first_ref, |d| { + d.set(b"Title".to_vec(), Object::String(PdfString::literal(vec![b'X'; 400]))); + }) + .unwrap(); + let after_edit = audit_space(&edited, packed.len() as u64); + assert!(bytes_of(&after_edit, SpaceCategory::Bookmarks) > bookmarks_before + 400); + assert_eq!(bytes_of(&after_edit, SpaceCategory::StructureInfo), struct_before); + + // A malformed reference pointing directly at the object stream itself must not count the + // stream a second time alongside its members. + let Some(XrefEntry::InStream { stream: stm_num, .. }) = edited.xref_entry(outlines_ref.num) else { + panic!("expected outlines in an object stream"); + }; + let mut self_ref = edited.clone(); + self_ref + .update_dict(outlines_ref, |d| { + d.set(b"Last".to_vec(), Object::Ref(ObjRef::new(stm_num, 0))); + }) + .unwrap(); + let after_self_ref = audit_space(&self_ref, packed.len() as u64); + assert_eq!(bytes_of(&after_self_ref, SpaceCategory::StructureInfo), struct_before); + + // Editing the object stream itself detaches its members from compressed stream sizing. + edited + .update_dict(ObjRef::new(stm_num, 0), |d| { + d.set(b"First".to_vec(), Object::Int(999_999)); + }) + .unwrap(); + let after_stream_edit = audit_space(&edited, 0); + assert!(bytes_of(&after_stream_edit, SpaceCategory::StructureInfo) > struct_before); + } + + #[test] + fn space_audit_skips_corrupted_object_streams() { + let (mut doc, page) = one_page_doc(); + // Fill the first object stream (100 objects) with structure elements so the bookmark item + // lands in the second object stream while the catalog stays readable in the first. + let mut kids = Vec::new(); + for i in 0..105 { + let mut elem = Dict::new(); + elem.set(b"Type".to_vec(), Object::name("StructElem")); + elem.set(b"S".to_vec(), Object::name("P")); + elem.set(b"Alt".to_vec(), Object::String(PdfString::literal(format!("Item {i}").into_bytes()))); + kids.push(Object::Ref(doc.add(Object::Dict(elem)))); + } + let mut struct_root = Dict::new(); + struct_root.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + struct_root.set(b"K".to_vec(), Object::Array(kids)); + let struct_root = doc.add(Object::Dict(struct_root)); + + let mut item = Dict::new(); + item.set(b"Title".to_vec(), Object::String(PdfString::literal(b"Broken".to_vec()))); + item.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let item = doc.add(Object::Dict(item)); + let mut outlines = Dict::new(); + outlines.set(b"First".to_vec(), Object::Ref(item)); + outlines.set(b"Last".to_vec(), Object::Ref(item)); + let outlines = doc.add(Object::Dict(outlines)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root)); + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + }) + .unwrap(); + + let mut packed = write_full(&doc, &SaveOptions::default()).unwrap(); + // Corrupt the last object stream's Flate payload while keeping the catalog's stream intact. + let marker = b"/Type /ObjStm"; + let pos = packed.windows(marker.len()).rposition(|w| w == marker).unwrap(); + let stream_kw = packed[pos..].windows(8).position(|w| w == b"\nstream\n").unwrap() + pos + 8; + for b in &mut packed[stream_kw..stream_kw + 8] { + *b ^= 0xFF; + } + let corrupted = Document::open(Arc::new(packed.clone())).unwrap(); + let audit = audit_space(&corrupted, packed.len() as u64); + assert_eq!(audit.iter().map(|u| u.bytes).sum::(), packed.len() as u64); + } +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 10388b8107..f72579778a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -764,8 +764,18 @@ fn process( if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } - let resize = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0; + let resize = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && settings.target_ppi.is_finite(); let recompress = matches!(settings.compression, Compression::Jpeg(_)) || (matches!(settings.compression, Compression::Flate) && was_dct); + let (nw, nh) = if resize { + // Downsampling never enlarges: a target above the image's own resolution (or a hostile + // settings value) must not size a buffer from the setting. + let k = (settings.target_ppi / ppi).min(1.0); + (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1)) + } else { + (w, h) + }; + // A target at or above the image's own resolution resizes nothing. + let resize = resize && (nw, nh) != (w, h); if !resize && !recompress { return None; } @@ -773,12 +783,6 @@ fn process( if !resize && (w as u64) * (h as u64) < 64 * 64 { return None; } - let (nw, nh) = if resize { - let k = settings.target_ppi / ppi; - (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1)) - } else { - (w, h) - }; let px = pixels(doc, s, n, budget)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; diff --git a/crates/optimize/tests/hostile.rs b/crates/optimize/tests/hostile.rs new file mode 100644 index 0000000000..0600fd4d17 --- /dev/null +++ b/crates/optimize/tests/hostile.rs @@ -0,0 +1,129 @@ +//! The optimizer on absurd image sizes, drawing matrices and settings: it may refuse or leave +//! things alone, but never panics, hangs or allocates without bound. + +use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_optimize::{Compression, ImageSettings, Settings, optimize}; + +fn image_dict(w: i64, h: i64, bpc: i64, cs: &str) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w)); + d.set(b"Height".to_vec(), Object::Int(h)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + d +} + +fn doc_with(img: Stream, content: &str) -> Document { + doc_with_image(img, content).0 +} + +/// Like [`doc_with`], also returning the image's object so a test can read it back. +fn doc_with_image(img: Stream, content: &str) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = Document::new_empty(); + let im = doc.add(Object::Stream(img)); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(b"Im".to_vec(), Object::Ref(im)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(c)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + (doc, im) +} + +fn settings(target: f64, above: f64, compression: Compression) -> Settings { + let s = ImageSettings { downsample: true, target_ppi: target, above_ppi: above, compression }; + Settings { color: s, gray: s, ..Settings::default() } +} + +#[test] +fn absurd_image_sizes_never_panic() { + let dims = + [(0, 0), (-1, -1), (1, 0), (0, 1), (i64::MAX, 1), (1, i64::MAX), (i64::MAX, i64::MAX), (1_000_000_000, 1_000_000_000), (100_000, 100_000)]; + for (w, h) in dims { + for cs in ["DeviceGray", "DeviceRGB", "DeviceCMYK"] { + for bpc in [0, 1, 8, 16, 99, -8] { + let img = Stream::flate(image_dict(w, h, bpc, cs), &[1, 2, 3, 4]); + let mut doc = doc_with(img, "q 72 0 0 72 0 0 cm /Im Do Q"); + let _ = optimize(&mut doc, &Settings::default()); + } + } + } +} + +#[test] +fn absurd_drawing_matrices_never_panic() { + let ctms = [ + "0 0 0 0 0 0 cm", + "1e300 0 0 1e300 0 0 cm", + "1e-300 0 0 1e-300 0 0 cm", + "-1e300 0 0 1e300 1e300 1e300 cm", + "1e999 0 0 1e999 0 0 cm", + "1 1e300 1e300 1 0 0 cm", + "72 0 0 0 0 0 cm", + ]; + for ctm in ctms { + let mut px = Vec::new(); + px.extend(std::iter::repeat_n(7u8, 64 * 64 * 3)); + let img = Stream::flate(image_dict(64, 64, 8, "DeviceRGB"), &px); + let mut doc = doc_with(img, &format!("q {ctm} /Im Do Q")); + let _ = optimize(&mut doc, &Settings::default()); + } +} + +#[test] +fn absurd_settings_never_panic() { + let nums = [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, f64::MIN_POSITIVE]; + let comps = [Compression::Jpeg(0), Compression::Jpeg(255), Compression::Flate, Compression::Retain]; + for t in nums { + for a in nums { + for c in comps { + let img = Stream::flate(image_dict(300, 300, 8, "DeviceRGB"), &vec![9u8; 300 * 300 * 3]); + let mut doc = doc_with(img, "q 72 0 0 72 0 0 cm /Im Do Q"); + let _ = optimize(&mut doc, &settings(t, a, c)); + } + } + } +} + +/// An image drawn 72 pt wide is 300 ppi. Returns its (width, height) and raw bytes after optimizing. +fn optimized_300ppi(target: f64) -> ((i64, i64), Vec) { + let img = Stream::flate(image_dict(300, 300, 8, "DeviceRGB"), &vec![9u8; 300 * 300 * 3]); + let (mut doc, im) = doc_with_image(img, "q 72 0 0 72 0 0 cm /Im Do Q"); + let _ = optimize(&mut doc, &settings(target, 225.0, Compression::Retain)); + let o = doc.get(im); + let Object::Stream(s) = &*o else { panic!("the image is no longer a stream") }; + ((s.dict.int(b"Width").unwrap_or(-1), s.dict.int(b"Height").unwrap_or(-1)), s.raw.to_vec()) +} + +#[test] +fn a_hostile_target_ppi_leaves_the_image_as_it_is() { + let (_, original) = optimized_300ppi(300.0); + for target in [1e9, f64::INFINITY, f64::NAN, -1.0, 0.0] { + let (dims, raw) = optimized_300ppi(target); + assert_eq!(dims, (300, 300), "target_ppi {target}"); + assert_eq!(raw, original, "target_ppi {target} must not re-encode the image"); + } +} + +#[test] +fn a_normal_target_downsamples_and_an_equal_target_changes_nothing() { + let (_, original) = optimized_300ppi(300.0); + assert_eq!(optimized_300ppi(150.0).0, (150, 150)); + let (dims, raw) = optimized_300ppi(300.0); + assert_eq!(dims, (300, 300)); + assert_eq!(raw, original, "a target equal to the image's own resolution must not resize or re-encode it"); +} diff --git a/crates/organize/src/boxes.rs b/crates/organize/src/boxes.rs index c9ccb0f154..ff56c73f67 100644 --- a/crates/organize/src/boxes.rs +++ b/crates/organize/src/boxes.rs @@ -29,7 +29,8 @@ impl PageBox { } pub fn from_name(s: &str) -> Option { - match s.to_ascii_lowercase().trim_end_matches("box") { + let lower = s.trim().to_ascii_lowercase(); + match lower.strip_suffix("box").unwrap_or(&lower) { "media" => Some(Self::Media), "crop" => Some(Self::Crop), "bleed" => Some(Self::Bleed), @@ -51,16 +52,30 @@ pub enum BoxSpec { Margins([f64; 4]), } +/// Parses a 4-number PDF rectangle `[x0, y0, x1, y1]` into `[xmin, ymin, xmax, ymax]` when it has +/// finite coordinates and positive width and height. fn rect_of(o: Option<&Object>, doc: &Document) -> Option<[f64; 4]> { let o = doc.resolve(o?); let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + if v.len() != 4 || !v.iter().all(|x| x.is_finite()) { + return None; + } + let r = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()).then_some(r) } +/// Returns the coordinate-wise intersection of two normalized `[xmin, ymin, xmax, ymax]` rectangles. fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] } +/// Clips rectangle `a` to `b`, returning `None` when their intersection has no positive area. +fn clip(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = intersect(a, b); + (r[2] > r[0] && r[3] > r[1]).then_some(r) +} + /// The effective boxes of every page: media, crop, bleed, trim, art. pub fn page_boxes(doc: &Document) -> Result, OrganizeError> { Ok(walk(doc)? @@ -70,8 +85,8 @@ pub fn page_boxes(doc: &Document) -> Result, OrganizeError> { let d = obj.as_dict().cloned().unwrap_or_default(); let own = |k: &[u8]| rect_of(d.get(k).or_else(|| inherited.get(k)), doc); let media = own(b"MediaBox").unwrap_or([0.0, 0.0, 612.0, 792.0]); - let crop = own(b"CropBox").map(|c| intersect(c, media)).unwrap_or(media); - let child = |k: &[u8]| rect_of(d.get(k), doc).map(|c| intersect(c, media)).unwrap_or(crop); + let crop = own(b"CropBox").and_then(|c| clip(c, media)).unwrap_or(media); + let child = |k: &[u8]| rect_of(d.get(k), doc).and_then(|c| clip(c, media)).unwrap_or(crop); [media, crop, child(b"BleedBox"), child(b"TrimBox"), child(b"ArtBox")] }) .collect()) @@ -106,22 +121,34 @@ pub fn set_page_box(doc: &mut Document, pages: &[usize], which: PageBox, spec: B Some([media[0] + m[0], media[1] + m[1], media[2] - m[2], media[3] - m[3]]) } }; - if let Some(r) = rect - && (r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0) - { - return Err(bad(&format!("page {} would be smaller than a point", i + 1))); + if let Some(r) = rect { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + if !w.is_finite() || !h.is_finite() { + return Err(bad("the rectangle is not a number")); + } + if w < 1.0 || h < 1.0 { + return Err(bad(&format!("page {} would be smaller than a point", i + 1))); + } } - plan.push((all[i].0, all[i].1.contains(which.key()), rect, media)); + plan.push((i, all[i].0, rect, media)); } - for (page, inherited, rect, media) in plan { + for &(_, page, rect, _) in &plan { doc.update_dict(page, |d| match rect { Some(r) => d.set(which.key().to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())), - // An inherited crop box would come back: pin the default explicitly instead. - None if inherited => d.set(which.key().to_vec(), Object::Array(media.iter().map(|v| Object::Real(*v)).collect())), None => { d.remove(which.key()); } })?; } + if spec == BoxSpec::Remove && which == PageBox::Crop { + // An inherited crop box from an ancestor `/Pages` node would come back after removal: + // pin the page's media box explicitly only when an ancestor still supplies a valid CropBox. + let after = walk(doc)?; + for &(i, page, _, media) in &plan { + if after.get(i).and_then(|(_, inh)| rect_of(inh.get(b"CropBox"), doc)).is_some() { + doc.update_dict(page, |d| d.set(b"CropBox".to_vec(), Object::Array(media.iter().map(|v| Object::Real(*v)).collect())))?; + } + } + } Ok(()) } diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 13c02eb662..e9e20bd9fc 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -301,7 +301,9 @@ fn view_of_explicit(doc: &Document, dest: &[Object]) -> Option<(Vec, Vec Option> { if let Some(dest) = d.get(b"Dest").and_then(|dest| explicit_dest(doc, targets, dest, 0)) && page_of_explicit(targets, &dest).is_some() @@ -309,8 +311,8 @@ fn item_dest(doc: &Document, targets: &mut Targets, d: &Dict) -> Option depth && to_parent[..depth] == from[..depth] && to_parent[depth] > from[depth] { to_parent[depth] -= 1; } - let parent = container(doc, &to_parent)?; let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); let at = index.min(kids.len()); kids.insert(at, r); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 593efd2600..6e8f2df4be 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1731,6 +1731,15 @@ fn count_of(doc: &Document, r: ObjRef) -> Option { doc.get(r).as_dict().and_then(|d| d.int(b"Count")) } +#[test] +fn move_bookmark_to_missing_parent_keeps_outline() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "Intro", 0).unwrap(); + crate::add_bookmark(&mut d, &[], 9, "Results", 2).unwrap(); + assert!(crate::move_bookmark(&mut d, &[0], &[7], 0).is_err()); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results"]); +} + #[test] fn bookmarks_add_rename_move_delete() { let mut d = doc_a(); @@ -1956,6 +1965,32 @@ fn bookmarks_name_their_pages_through_every_kind_of_destination() { assert_eq!(listed, ["Named@Some(0)", "GoTo@Some(1)", "Appendix@Some(1)", "Hi@Some(1)", "Padded@None"]); } +/// Only a go-to's `/D` is a page of this document. A remote go-to's names a page or destination of +/// the other file (ISO 32000-2 §12.6.4.3), so `[1 /Fit]` there is not this file's page 2, and a +/// destination name is not looked up here; neither is any other action's `/D`. An action without +/// a type is still read as the go-to its `/D` suggests. +#[test] +fn only_a_go_to_action_names_a_page_here() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R /Names << /Dests 12 0 R >> >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Outlines /First 6 0 R /Last 11 0 R /Count 6 >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Title (GoTo) /Parent 3 0 R /Next 7 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Other file) /Parent 3 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoToR /F (other.pdf) /D [1 /Fit] >> >>".into(), + "<< /Title (Other file, named) /Parent 3 0 R /Prev 7 0 R /Next 9 0 R /A << /S /GoToR /F (other.pdf) /D (intro) >> >>".into(), + "<< /Title (Web) /Parent 3 0 R /Prev 8 0 R /Next 10 0 R /A << /S /URI /URI (https://example.org/) /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Indirect type) /Parent 3 0 R /Prev 9 0 R /Next 11 0 R /A << /S 13 0 R /D [5 0 R /Fit] >> >>".into(), + "<< /Title (No type) /Parent 3 0 R /Prev 10 0 R /A << /D [5 0 R /Fit] >> >>".into(), + "<< /Names [(intro) [4 0 R /Fit]] >>".into(), + "/GoTo".into(), + ]; + let d = open(build(&objs, "/Root 1 0 R")); + let listed: Vec = crate::bookmarks(&d).iter().map(|b| format!("{}@{:?}", b.title, b.page)).collect(); + assert_eq!(listed, ["GoTo@Some(1)", "Other file@None", "Other file, named@None", "Web@None", "Indirect type@Some(1)", "No type@Some(1)"]); +} + // ---- page labels (M4.4) ------------------------------------------------------------------------ #[test] @@ -2334,6 +2369,62 @@ fn page_boxes_default_inherit_and_set() { assert_eq!(set_page_box(&mut doc, &[7], PageBox::Crop, BoxSpec::Remove), Err(OrganizeError::NoSuchPage(7))); } +#[test] +fn page_boxes_remove_uninherited_crop_clip_disjoint_boxes_and_reject_overflow() { + let mut doc = open(fixture()); + set_page_box(&mut doc, &[0], PageBox::Crop, BoxSpec::Margins([10.0, 20.0, 30.0, 40.0])).unwrap(); + set_page_box(&mut doc, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + assert!(!page_dict(&doc, 0).contains(b"CropBox"), "removing an uninherited CropBox deletes the key instead of pinning the old MediaBox"); + set_page_box(&mut doc, &[0], PageBox::Media, BoxSpec::Rect([0.0, 0.0, 400.0, 500.0])).unwrap(); + assert_eq!(page_boxes(&doc).unwrap()[0][1], [0.0, 0.0, 400.0, 500.0], "CropBox tracks the resized MediaBox after removal"); + + // When /Pages itself carries an inherited /CropBox, removing a page's CropBox pins the page's MediaBox + // even when the page also had its own /CropBox and omitted /Parent. + let mut d = open(build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] /CropBox [20 20 280 380] >>".into(), + "<< /Type /Page /CropBox [40 40 260 360] >>".into(), + ], + "/Root 1 0 R", + )); + assert_eq!(page_boxes(&d).unwrap()[0][1], [40.0, 40.0, 260.0, 360.0]); + set_page_box(&mut d, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + assert_eq!(page_boxes(&d).unwrap()[0][1], [0.0, 0.0, 300.0, 400.0]); + + // An inherited /CropBox that is currently disjoint from the page's /MediaBox still causes removal to pin the MediaBox. + let mut d_disjoint_inh = open(build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] /CropBox [350 350 450 450] >>".into(), + "<< /Type /Page /CropBox [40 40 260 360] >>".into(), + ], + "/Root 1 0 R", + )); + set_page_box(&mut d_disjoint_inh, &[0], PageBox::Crop, BoxSpec::Remove).unwrap(); + set_page_box(&mut d_disjoint_inh, &[0], PageBox::Media, BoxSpec::Rect([0.0, 0.0, 500.0, 500.0])).unwrap(); + assert_eq!(page_boxes(&d_disjoint_inh).unwrap()[0][1], [0.0, 0.0, 300.0, 400.0]); + + // Disjoint or zero-area boxes in a file fall back to MediaBox/CropBox instead of producing inverted rectangles. + let disjoint = open(build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /CropBox [500 500 600 600] /TrimBox [700 700 800 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /CropBox [10 20 290 380] /TrimBox [500 500 600 600] /ArtBox [50 50 50 100] >>".into(), + ], + "/Root 1 0 R", + )); + let boxes = page_boxes(&disjoint).unwrap(); + assert_eq!(boxes[0][1], [0.0, 0.0, 300.0, 400.0], "disjoint CropBox falls back to MediaBox"); + assert_eq!(boxes[0][3], [0.0, 0.0, 300.0, 400.0], "disjoint TrimBox falls back to CropBox"); + assert_eq!(boxes[1][3], [10.0, 20.0, 290.0, 380.0], "disjoint TrimBox falls back to page's CropBox"); + assert_eq!(boxes[1][4], [10.0, 20.0, 290.0, 380.0], "zero-width ArtBox falls back to page's CropBox"); + + // Finite coordinates whose span overflows f64 are rejected. + assert!(matches!(set_page_box(&mut doc, &[0], PageBox::Media, BoxSpec::Rect([-1e308, 0.0, 1e308, 100.0])), Err(OrganizeError::InvalidBox(_)))); +} + #[test] fn duplicate_pages_inserts_copies_after_the_last() { let mut doc = open(fixture()); @@ -2467,6 +2558,56 @@ fn an_unchanged_opening_destination_keeps_its_position_and_name() { } } +/// A percentage is written as the `/XYZ` zoom factor (ISO 32000-2 Table 149), not the percentage: +/// 25% as 0.25 and 100% as 1, and each survives a save. +#[test] +fn initial_view_percentages_are_written_as_zoom_factors() { + use crate::view::Magnification; + for (percent, factor) in [(25.0, 0.25), (100.0, 1.0), (150.0, 1.5)] { + let mut doc = open(fixture()); + let v = crate::initial_view(&doc); + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::Percent(percent), ..v }).unwrap(); + let doc = save_and_reopen(&doc); + let root = doc.root().unwrap(); + let open_action = doc.get(root).as_dict().and_then(|c| c.get(b"OpenAction").cloned()).map(|o| doc.resolve(&o).as_ref().clone()); + let zoom = open_action.as_ref().and_then(Object::as_array).and_then(|a| a.get(4)).and_then(Object::as_f64); + assert_eq!(zoom, Some(factor), "{percent}%: {open_action:?}"); + } +} + +#[test] +fn choosing_100_percent_keeps_an_actual_size_opening_destination() { + use crate::view::Magnification; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(1)]); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"S".to_vec(), Object::name("GoTo")); + named.set(b"D".to_vec(), Object::name("there")); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(1)])); + for action in [xyz, Object::Dict(named)] { + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action.clone()); + c.set(b"Dests".to_vec(), Object::Dict(dests.clone())); + }) + .unwrap(); + let v = crate::initial_view(&doc); + assert_eq!(v.magnification, Magnification::ActualSize); + // 100% is the same zoom: the action keeps its position or name. + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::Percent(100.0), ..v }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_eq!(catalog.get(b"OpenAction"), Some(&action)); + } + // Without one, 100% writes zoom 1, which reads back as Actual Size. + let mut doc = open(fixture()); + let v = crate::initial_view(&doc); + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::Percent(100.0), ..v }).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::ActualSize); +} + /// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at /// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree. fn doc_opening_at(action: Object) -> Document { diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index 899293b980..bb39dcdf9a 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -163,13 +163,23 @@ pub fn displays_doc_title(doc: &Document) -> bool { .unwrap_or(false) } +/// Whether `a` and `b` open at the same magnification. 100% writes the zoom Actual Size writes, +/// which [`initial_view`] reads back as Actual Size. +fn same_magnification(a: Magnification, b: Magnification) -> bool { + let actual = |m: Magnification| match m { + Magnification::Percent(p) if (p / 100.0 - 1.0).abs() < 1e-6 => Magnification::ActualSize, + m => m, + }; + actual(a) == actual(b) +} + /// Write the initial view. An opening action that isn't a plain destination (a script, say) is /// kept unless the page or magnification asks for a destination, and an opening action whose page /// and magnification `v` leaves as [`initial_view`] reads them is kept as it is. pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { let same_destination = { let now = initial_view(doc); - now.page == v.page && now.magnification == v.magnification + now.page == v.page && same_magnification(now.magnification, v.magnification) }; let (root, c) = catalog(doc)?; let pages = crate::walk(doc)?; diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 5b6f3930c8..adda9ab862 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -195,8 +195,9 @@ fn unescape(text: &str) -> String { _ => entity .strip_prefix("#x") .or_else(|| entity.strip_prefix("#X")) + .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())) .and_then(|h| u32::from_str_radix(h, 16).ok()) - .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .or_else(|| entity.strip_prefix('#').filter(|d| d.bytes().all(|b| b.is_ascii_digit())).and_then(|d| d.parse().ok())) .and_then(char::from_u32), }; match c { @@ -294,24 +295,31 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { /// start and end of its value, and its quote character. Only matches inside a start tag. fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { let mut from = 0; - // The last `<` and `>` before the match, found by one pass over the packet rather than by - // searching back from every match. - let (mut scanned, mut last_open, mut last_close) = (0, None, None); + // Track tag and quote state in one pass rather than searching back from every match. + let (mut scanned, mut in_tag, mut quote) = (0, false, None); while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { from = i + name.len(); let before = xmp.get(..i)?; let gap = xmp.get(scanned..i)?; #[cfg(test)] crate::work(gap.len()); - if let Some(at) = gap.rfind('<') { - last_open = Some(scanned + at); - } - if let Some(at) = gap.rfind('>') { - last_close = Some(scanned + at); + for c in gap.chars() { + if let Some(q) = quote { + if c == q { + quote = None; + } + } else { + match c { + '<' => in_tag = true, + '>' if in_tag => in_tag = false, + '\'' | '\"' if in_tag => quote = Some(c), + _ => {} + } + } } scanned = i; - // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). - if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() { + // Preceded by whitespace, and inside a start tag. + if !before.ends_with(char::is_whitespace) || !in_tag || quote.is_some() { continue; } let rest = xmp.get(from..)?; @@ -341,6 +349,13 @@ mod tests { Old subject\n\ \n \n\n"; + #[test] + fn unescape_rejects_signs_in_numeric_references() { + assert_eq!(unescape("a&#x+41;b&#+66;"), "a&#x+41;b&#+66;"); + assert_eq!(unescape("&#x;&#;"), "&#x;&#;"); + assert_eq!(unescape("AA"), "AA"); + } + #[test] fn values_are_read_back_unescaped() { assert_eq!(unescape("a & b <c> AB &bogus; � & end"), "a & b AB &bogus; � & end"); @@ -417,6 +432,19 @@ mod tests { assert!(out.contains("New"), "{out}"); } + #[test] + fn attribute_like_text_inside_another_quoted_attribute_is_not_a_property() { + let packet = ""; + assert_eq!(attribute_span(packet, "dc:title"), None); + } + + #[test] + fn apostrophe_in_text_does_not_hide_a_later_attribute() { + let packet = "don't"; + let found = attribute_span(packet, "dc:title").and_then(|(_, start, end, quote)| packet.get(start..end).map(|value| (value, quote))); + assert_eq!(found, Some(("real", '\''))); + } + #[test] fn hostile_packets_do_not_panic() { for packet in ["", "<", "", ") -> std::io::Result<()> { + #[cfg(windows)] + { + let zone = source.and_then(read_zone).unwrap_or_else(|| INTERNET_ZONE.as_bytes().to_vec()); + std::fs::write(zone_stream(target), zone) + } + #[cfg(not(windows))] + { + let _ = (target, source); + Ok(()) + } +} + +/// `path`'s `Zone.Identifier` stream, when it has one that reads as a zone. +#[cfg(windows)] +pub fn read_zone(path: &Path) -> Option> { + use std::io::Read; + let file = std::fs::File::open(zone_stream(path)).ok()?; + let mut zone = Vec::new(); + file.take(MAX_ZONE_STREAM.saturating_add(1)).read_to_end(&mut zone).ok()?; + let fits = u64::try_from(zone.len()).is_ok_and(|n| n <= MAX_ZONE_STREAM); + (fits && has_zone_id(&zone)).then_some(zone) +} + +/// Whether `stream` has a `ZoneId=` entry inside its `[ZoneTransfer]` section. Text that +/// only mentions those words elsewhere (say a `HostUrl` ending in `?ZoneId=3`) is not a zone. +#[cfg(windows)] +fn has_zone_id(stream: &[u8]) -> bool { + let mut in_section = false; + for line in stream.split(|&b| b == b'\n').map(<[u8]>::trim_ascii) { + if line.starts_with(b"[") { + in_section = line.eq_ignore_ascii_case(b"[ZoneTransfer]"); + continue; + } + let Some(eq) = line.iter().position(|&b| b == b'=') else { continue }; + let (key, value) = line.split_at(eq); + let value = value.get(1..).unwrap_or_default().trim_ascii(); + if in_section && key.trim_ascii().eq_ignore_ascii_case(b"ZoneId") && (1..=10).contains(&value.len()) && value.iter().all(u8::is_ascii_digit) { + return true; + } + } + false +} + +/// The path of `path`'s `Zone.Identifier` alternate data stream. +#[cfg(windows)] +fn zone_stream(path: &Path) -> std::path::PathBuf { + let mut stream = path.as_os_str().to_owned(); + stream.push(":Zone.Identifier"); + stream.into() +} + +#[cfg(test)] +#[cfg(windows)] +mod tests { + use super::*; + + /// A fresh folder in the temp directory, removed on drop. + struct Dir(std::path::PathBuf); + + impl Dir { + fn new(name: &str) -> Dir { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!("pdfcraft-zone-{name}-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Dir(path) + } + } + + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn a_file_from_a_document_without_a_zone_gets_the_internet_zone() { + let dir = Dir::new("none"); + let target = dir.0.join("invoice.exe"); + std::fs::write(&target, b"MZ").unwrap(); + mark_from(&target, None).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + let source = dir.0.join("local.pdf"); + std::fs::write(&source, b"%PDF").unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + assert_eq!(std::fs::read(&target).unwrap(), b"MZ", "the file's own bytes are untouched"); + } + + #[test] + fn a_file_from_a_document_gets_that_documents_zone() { + let dir = Dir::new("copy"); + let source = dir.0.join("from mail.pdf"); + std::fs::write(&source, b"%PDF").unwrap(); + let zone = "[ZoneTransfer]\r\nZoneId=3\r\nReferrerUrl=https://mail.example/\r\nHostUrl=https://mail.example/a.pdf\r\n"; + std::fs::write(zone_stream(&source), zone).unwrap(); + let target = dir.0.join("macro.docm"); + std::fs::write(&target, b"PK").unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), zone.as_bytes()); + } + + #[test] + fn a_stream_that_isnt_a_zone_is_not_copied() { + let dir = Dir::new("junk"); + let source = dir.0.join("odd.pdf"); + std::fs::write(&source, b"%PDF").unwrap(); + std::fs::write(zone_stream(&source), b"not a zone").unwrap(); + let target = dir.0.join("notes.txt"); + std::fs::write(&target, b"hello").unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + for odd in [ + "[ZoneTransfer]\r\nHostUrl=https://example.test/?ZoneId=3\r\n", + "[Other]\r\nZoneId=3\r\n[ZoneTransfer]\r\nReferrerUrl=x\r\n", + "[ZoneTransfer]\r\nZoneId=\r\n", + "[ZoneTransfer]\r\nZoneId=three\r\n", + ] { + std::fs::write(zone_stream(&source), odd).unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes(), "{odd:?}"); + } + let huge = [INTERNET_ZONE.as_bytes(), &vec![b'x'; 70 * 1024]].concat(); + std::fs::write(zone_stream(&source), huge).unwrap(); + mark_from(&target, Some(&source)).unwrap(); + assert_eq!(read_zone(&target).unwrap(), INTERNET_ZONE.as_bytes()); + } +} diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index fffab083af..027158df01 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -51,6 +51,10 @@ fn page_of(tok: &str, count: usize, labels: &Labels) -> Result() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), Ok(n) => Err(PrintError::OutOfRange(n, count)), @@ -176,6 +180,13 @@ mod tests { assert!(select_pages(0, Some("-"), &[], Subset::All, false).is_err()); } + #[test] + fn plus_sign_is_not_a_page() { + for r in ["+3", "+2-4", "2-+4"] { + assert!(matches!(select_pages(5, Some(r), &[], Subset::All, false), Err(PrintError::NotAPage(_))), "accepted {r}"); + } + } + #[test] fn open_ended_range_covers_all_pages() { assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..1c329da080 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -53,6 +53,10 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// `/Info /CreationDate` and `/ModDate` for display ("2026-10-01 12:30", see [`pretty_date`]); + /// a value that isn't a PDF date is shown as written. + pub created: Option, + pub modified: Option, /// Custom document properties: `/Info` entries with a key the spec does not name and a text /// value, as (name, text), in the dictionary's order (at most 256). pub custom: Vec<(String, String)>, diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 1609ad7e75..6cd6b32cf4 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -15,11 +15,11 @@ const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; /// are not listed: real comments stay far below, many small ones sharing one long string do not. pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20; -/// Web-address text the link listing, and name, description and embedded-file key text the -/// attachment listing, keep together. Many links or attachments can share one long string, which -/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its -/// address, an attachment under its generic name without a description, and an embedded file whose -/// key does not fit is not listed. +/// Web-address text the bookmark and link listings, and name, description and embedded-file key +/// text the attachment listing, keep together. Many bookmarks, links or attachments can share one +/// long string, which each would otherwise copy. Past it, a bookmark or link to a web address lists +/// as a URI action without its address, an attachment under its generic name without a +/// description, and an embedded file whose key does not fit is not listed. pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; /// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color` @@ -234,35 +234,44 @@ impl<'a> Inspector<'a> { link_replies(&mut info.annotations); } + /// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest` + /// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no + /// rectangle, or a destination or action that leads nowhere. fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; LinkTarget::Page(page, view) } else { - let a = d.get(b"A").ok().and_then(|a| self.dict(a))?; - match self.name(a, b"S").as_deref() { - Some("GoTo") => { - let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; - LinkTarget::Page(page, view) - } - Some("URI") => { - let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; - match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { - Some(uri) => LinkTarget::Uri(uri), - None => LinkTarget::Other("URI".into()), - } - } - Some("SetOCGState") => self.layer_state(a), - Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), - Some("Launch") => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), - Some(other) => LinkTarget::Other(other.to_string()), - None => return None, - } + self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? }; Some(Link { page, index, rect, target }) } + /// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in + /// this document; a remote go-to's names a page or destination of another file (ISO 32000-2 + /// §12.6.4.3). `None` for an action without a type, or a go-to whose destination is not here. + /// A web address is spent from `text_budget` (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]). + pub(super) fn action_target(&self, a: &Dictionary, text_budget: &mut usize) -> Option { + Some(match self.name(a, b"S").as_deref()? { + "GoTo" => { + let (page, view) = self.dest(a.get(b"D").ok()?, 0)?; + LinkTarget::Page(page, view) + } + "URI" => { + let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?; + match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) { + Some(uri) => LinkTarget::Uri(uri), + None => LinkTarget::Other("URI".into()), + } + } + "SetOCGState" => self.layer_state(a), + "GoToR" => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())), + "Launch" => self.launch_pdf(a).unwrap_or_else(|| LinkTarget::Other("Launch".into())), + other => LinkTarget::Other(other.to_string()), + }) + } + fn remote_go_to(&self, action: &Dictionary) -> Option { let file = self.action_file(action)?; let destination = match action.get(b"D") { diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 8eb4ae9d29..46e3406391 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -175,6 +175,9 @@ impl<'a> Inspector<'a> { me } + /// Reads what `info` holds beyond the page list: encryption, document information, tagging, + /// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments, + /// JavaScript and the opening destination. pub(super) fn fill(&self, info: &mut DocInfo) { // lopdf drops /Encrypt from the trailer once it has decrypted the file. info.encrypted = self.doc.encrypted(); @@ -185,6 +188,14 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + // Dates are text strings only (ISO 32000-2 §7.9.4), as the engine reads them after an + // edit (`pdfcraft_organize::info`): a name value is no date. + let date = |key: &[u8]| match self.resolve(d.get(key).ok()?) { + Object::String(..) => self.text(d, key).map(|s| pretty_date(&s)), + _ => None, + }; + info.created = date(b"CreationDate"); + info.modified = date(b"ModDate"); const STANDARD: [&[u8]; 9] = [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, @@ -218,10 +229,10 @@ impl<'a> Inspector<'a> { Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), } - if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - (info.outline, info.outline_more) = self.outline_window(first); - } let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { + (info.outline, info.outline_more) = self.outline_window(first, &mut text_budget); + } self.annotations(info, &mut text_budget); self.transitions(info); let mut walk = FieldWalk::default(); diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 490897b60f..981de248ae 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -287,6 +287,11 @@ pub struct OutlineItem { pub page: Option, /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). pub view: DestView, + /// What the bookmark does instead when its action (`/A`, ISO 32000-2 §12.3.3 Table 151) goes + /// nowhere in this document: a web address, another PDF, a layer change, or an action named + /// but not run ([`LinkTarget::Other`]), read as a link's is. Never [`LinkTarget::Page`]: a + /// go-to is `page` and `view`. + pub action: Option, pub children: Vec, /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 4000a74135..fb19526875 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -1,7 +1,7 @@ #[cfg(test)] use super::core::inspect; use super::core::spend; -use super::model::{DestView, OutlineItem, PageInfo}; +use super::model::{DestView, LinkTarget, OutlineItem, PageInfo}; use super::{ BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, @@ -185,8 +185,9 @@ impl<'a> Inspector<'a> { /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and - /// each bookmark is read once, so the work and memory stay within the window. - pub(super) fn outline_window(&self, first: &'a Object) -> (Vec, bool) { + /// each bookmark is read once, so the work and memory stay within the window. Web addresses are + /// spent from `text_budget`, as links' are. + pub(super) fn outline_window(&self, first: &'a Object, text_budget: &mut usize) -> (Vec, bool) { let mut seen = HashSet::new(); // The listed bookmarks in outline order, with their depth. let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); @@ -210,12 +211,20 @@ impl<'a> Inspector<'a> { break; } *slot = d.get(b"Next").ok(); - let dest = d - .get(b"Dest") - .ok() - .and_then(|dest| self.dest(dest, 0)) - .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); - let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); + // A bookmark goes to its `/Dest`, else does what its action `/A` does, as a link would. + // Only a go-to's `/D` is a page here: a remote go-to's names a page of the other file. + // An action without a type is still read as the go-to its `/D` suggests. + let action = match d.get(b"Dest").ok().and_then(|dest| self.dest(dest, 0)) { + Some((page, view)) => Some(LinkTarget::Page(page, view)), + None => d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| match a.get(b"S") { + Ok(_) => self.action_target(a, text_budget), + Err(_) => a.get(b"D").ok().and_then(|dest| self.dest(dest, 0)).map(|(page, view)| LinkTarget::Page(page, view)), + }), + }; + let ((page, view), action) = match action { + Some(LinkTarget::Page(page, view)) => ((Some(page), view), None), + other => ((None, DestView::Top), other), + }; match d.get(b"First").ok() { Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), Some(_) => more = true, @@ -229,7 +238,7 @@ impl<'a> Inspector<'a> { }; let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); + listed.push((depth, OutlineItem { title, page, view, action, children: Vec::new(), open, color, italic, bold })); } (nest_outline(listed), more) } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index 68c6af2e77..f67d78366d 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -729,6 +729,31 @@ const CUSTOM_INFO: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R /Info 4 0 R >> %%EOF"; +/// One page whose `/Info` is `info` (the dictionary's contents). +fn with_info(info: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << {info} >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF" + ) + .into_bytes() +} + +#[test] +fn creation_and_modification_dates_are_read_for_document_properties() { + let info = inspect(Arc::new(with_info("/CreationDate (D:20240105093000+01'00') /ModDate (D:20261031174500Z)")), None).expect("opens"); + assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("2024-01-05 09:30"), Some("2026-10-31 17:45"))); + // Not a PDF date: shown as written. Empty, a number, a name and absent: no date. + let info = inspect(Arc::new(with_info("/CreationDate (last Tuesday) /ModDate ()")), None).expect("opens"); + assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("last Tuesday"), None)); + let info = inspect(Arc::new(with_info("/CreationDate 20240105 /ModDate /D:20240105093000Z")), None).expect("opens"); + assert_eq!((info.created, info.modified), (None, None)); +} + #[test] fn custom_document_properties_are_listed_apart_from_the_standard_ones() { let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); @@ -1715,6 +1740,57 @@ fn outline_items_carry_their_colour_and_style() { ); } +/// A bookmark's action (`/A`, ISO 32000-2 §12.3.3 Table 151) is read as a link's is. Only a +/// go-to's `/D` is a page of this document: a remote go-to's names a page or destination of the +/// other file (§12.6.4.3), so `[0 /FitH 200]` there is not this file's first page. A web address, +/// another PDF and actions PdfCraft doesn't run are kept for the bookmark to run or report. An +/// action without a type is still read as the go-to its `/D` suggests. +#[test] +fn bookmark_actions_are_read_as_links_read_them() { + use DestView::*; + let actions = [ + ("Go to", "/A << /S /GoTo /D [3 0 R /FitH 300] >>"), + ("Web", "/A << /S /URI /URI (https://example.org/manual) >>"), + ("Other file", "/A << /S /GoToR /F (other.pdf) /D [0 /FitH 200] /NewWindow true >>"), + ("Other file, named", "/A << /S /GoToR /F (other.pdf) /D (sec) >>"), + ("Launch", "/A << /S /Launch /F (other.pdf) >>"), + ("Menu item", "/A << /S /Named /N /NextPage >>"), + ("Script", "/A << /S /JavaScript /JS (app.alert\\(1\\)) >>"), + ("No type", "/A << /D [3 0 R /Fit] >>"), + ]; + let n = actions.len(); + let items: Vec = actions + .iter() + .enumerate() + .map(|(k, (title, action))| { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} {action} >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + let got: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view, o.action.clone())).collect(); + let remote = |destination, new_window| Some(LinkTarget::RemoteGoTo { launch: false, file: "other.pdf".into(), destination, new_window }); + assert_eq!( + got, + [ + ("Go to", Some(0), FitH { top: Some(300.0) }, None), + ("Web", None, Top, Some(LinkTarget::Uri("https://example.org/manual".into()))), + ("Other file", None, Top, remote(RemoteDestination::Page { page: 0, view: FitH { top: Some(200.0) } }, Some(true))), + ("Other file, named", None, Top, remote(RemoteDestination::Named("sec".into()), None)), + ( + "Launch", + None, + Top, + Some(LinkTarget::RemoteGoTo { launch: true, file: "other.pdf".into(), destination: RemoteDestination::Default, new_window: None }) + ), + ("Menu item", None, Top, Some(LinkTarget::Other("Named".into()))), + ("Script", None, Top, Some(LinkTarget::Other("JavaScript".into()))), + ("No type", Some(0), Fit, None), + ] + ); +} + #[test] fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { const COUNT: usize = OUTLINE_WINDOW + 5; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 94f48eb098..333464779f 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -270,6 +270,9 @@ pub enum RenderWarning { /// A colour could not be computed (a colour space that cannot be read, or a Separation or /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. ColourFallback, + /// A Type 3 font without the `/Widths` it requires (ISO 32000-2 Table 110) was used; its + /// glyphs were spaced by the widths their glyph procedures give. + Type3WidthsMissing, } /// Counters for work performed by a renderer or render pool. @@ -478,6 +481,7 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc RenderWarning::ContentTruncated, InterpreterWarning::ColorFallback => RenderWarning::ColourFallback, + InterpreterWarning::Type3WidthsMissing => RenderWarning::Type3WidthsMissing, InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return, }; let mut warnings = lock(&target); diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 3835669338..679e42cd5e 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5570,6 +5570,22 @@ fn a_font_whose_post_version_has_swapped_halves_is_still_used() { assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves"); } +/// A font dictionary whose `/Subtype` is missing or the empty name is read as a Type 1 font, +/// so its `/BaseFont` picks the face (vendored hayro-interpret patch 94). `/Subtype` is required +/// (ISO 32000-2 Table 109), but such a `/Courier-Bold` was drawn in the Helvetica fallback, +/// shifting its columns (PDFBOX-1988). +#[test] +fn a_font_without_a_subtype_is_drawn_by_its_base_font() { + let render = |subtype: &str, base_font: &str| { + let resources = format!("<< /Font << /F1 << /Type /Font {subtype} /BaseFont /{base_font} >> >> >>"); + render_first_page(image_cache_pdf("BT /F1 20 Tf 5 25 Td (iiiiii) Tj ET", &resources, &[])).rgba.to_vec() + }; + let courier = render("/Subtype /Type1", "Courier-Bold"); + assert_ne!(courier, render("/Subtype /Type1", "Helvetica"), "Courier Bold and Helvetica differ"); + assert_eq!(render("/Subtype /", "Courier-Bold"), courier, "an empty /Subtype"); + assert_eq!(render("", "Courier-Bold"), courier, "no /Subtype"); +} + /// A page with no `/MediaBox`, its own or inherited, is US Letter (612 × 792 pt), as other /// readers draw it and as the page model and inspector already sized it; it was A4 (vendored /// hayro-syntax patch 44; ISO 32000-2 Table 31 makes the box required and gives no default; @@ -6485,6 +6501,94 @@ trailer << /Root 1 0 R >> assert!(p != [255, 255, 255, 255] && p[3] == 255, "the triangles are drawn: {p:?}"); } +/// A rainbow of tensor-product patches (two quarter rings per band) painted as a pattern +/// through a rectangle that is just the rainbow's box. Every patch is 722 triangles, and +/// across a narrow curved band they are thin and lie at every angle, so their boxes hold +/// many times their area: walking those boxes spent the visit budget (eight per pixel of the +/// rectangle, four per triangle) before the last patches, and the inner bands were left white +/// (through the page-sized rectangle, or with `sh`, the same mesh drew whole). The sampler +/// now walks only the columns of each row a triangle can reach. Vendored hayro-interpret +/// patch 99. +#[test] +fn thin_patches_of_a_mesh_in_a_tight_path_are_all_painted() { + const K: f64 = 0.5523; // a quarter circle's Bézier handles, as a fraction of the radius + const BANDS: [[u8; 3]; 6] = [[255, 0, 0], [255, 128, 0], [255, 255, 0], [0, 200, 0], [0, 0, 255], [128, 0, 255]]; + // The stream order of a tensor patch's points (column i, row j), ISO 32000-2 8.7.4.5.8. + const ORDER: [(usize, usize); 16] = + [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0), (1, 1), (1, 2), (2, 2), (2, 1)]; + // Bands 20/6 pt wide from radius 50 inwards, centred on (100, 100) by the pattern matrix; + // 16-bit coordinates decoded onto -64..64. + let width = 20.0 / BANDS.len() as f64; + let mut data = Vec::new(); + for (band, colour) in BANDS.iter().enumerate() { + let (outer, inner) = (50.0 - band as f64 * width, 50.0 - (band + 1) as f64 * width); + for side in [1.0, -1.0] { + data.push(0u8); + for (i, j) in ORDER { + let rho = inner + (outer - inner) * j as f64 / 3.0; + let (x, y) = [(side * rho, 0.0), (side * rho, K * rho), (side * K * rho, rho), (0.0, rho)][i]; + for v in [x, y] { + data.extend_from_slice(&(((v + 64.0) / 128.0 * 65535.0).round() as u16).to_be_bytes()); + } + } + for _ in 0..4 { + data.extend_from_slice(colour); + } + } + } + let render = |rect: &str| { + let content = format!("/Pattern cs /P0 scn {rect} re f"); + let mut pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Pattern << /P0 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 2 /Shading 5 0 R /Matrix [1 0 0 1 100 100] >> endobj +5 0 obj << /ShadingType 7 /ColorSpace /DeviceRGB /BitsPerCoordinate 16 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [-64 64 -64 64 0 1 0 1 0 1] /Length {} >> stream +", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(&data); + pdf.extend_from_slice( + b" +endstream endobj +trailer << /Root 1 0 R >> +%%EOF +", + ); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (300, 300)); + page + }; + // At 45° on either side, the middle of the outer and of the inner band, in device pixels + // (scale 1.5, y down). + let at = |rho: f64, side: f64| { + let d = rho * std::f64::consts::FRAC_1_SQRT_2; + (((100.0 + side * d) * 1.5) as usize, ((100.0 - d) * 1.5) as usize) + }; + for (what, rect) in [("the rainbow's box", "50 100 100 50"), ("the page", "0 0 200 200")] { + let page = render(rect); + for (band, rho) in [(0, 50.0 - width / 2.0), (BANDS.len() - 1, 30.0 + width / 2.0)] { + for side in [1.0, -1.0] { + let (x, y) = at(rho, side); + let got = rgb_at(&page.rgba, 300, x, y); + let want = BANDS[band]; + assert!( + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 8), + "through {what}, band {band} at ({x}, {y}): got {got:?}, want {want:?}" + ); + } + } + } +} + /// Two triangles of a mesh that share an edge cover it together. The sampler tested each /// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share /// was left white wherever it ran through the samples, and so was a pixel row or column @@ -7844,6 +7948,329 @@ trailer << /Root 1 0 R >> } } +/// A page of `/ImageMask` stencils painted with a tiling pattern: the 4 × 4 stencil inks a ring +/// (its centre 2 × 2 samples are `1`, unpainted under the default `/Decode [0 1]`), filled with +/// a red coloured pattern. +fn patterned_stencil_page(media: (u32, u32), content: &str) -> RenderedPage { + let tile = "1 0 0 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /Msk 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00606000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + media.0, + media.1, + content.len(), + tile.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + page +} + +/// A stencil mask filled with a pattern shows the pattern where the stencil inks and nothing +/// elsewhere: not in its unpainted samples, and not around it, also when it is drawn smaller +/// than its samples and lands on whole device pixels. +#[test] +fn a_patterned_stencil_mask_paints_only_where_it_inks() { + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[(((200 - y) * p.width + x) * 4) as usize..][..3].to_vec(); + let page = patterned_stencil_page((200, 200), "/Pattern cs /P scn q 40 0 0 40 20 20 cm /Msk Do Q q 3 0 0 3 100.4 100.4 cm /Msk Do Q"); + assert_eq!(px(&page, 25, 25), [255, 0, 0], "the ring is red"); + assert_eq!(px(&page, 40, 40), [255, 255, 255], "the stencil's unpainted centre stays white"); + for (x, y) in [(15, 40), (65, 40), (40, 15), (40, 65)] { + assert_eq!(px(&page, x, y), [255, 255, 255], "outside the stencil at ({x}, {y})"); + } + assert!((100..104).any(|x| px(&page, x, 101) != [255, 255, 255]), "the shrunk stencil is drawn"); + for edge in [97, 98, 106, 107] { + for along in 97..=107 { + assert_eq!(px(&page, edge, along), [255, 255, 255], "beside the shrunk stencil at ({edge}, {along})"); + assert_eq!(px(&page, along, edge), [255, 255, 255], "above or below the shrunk stencil at ({along}, {edge})"); + } + } +} + +/// A patterned stencil mask under a soft mask is drawn at the soft mask's opacity once, as a +/// rectangle filled with the pattern beside it is: the stencil's coverage, which cuts the pattern +/// to the stencil's shape, is not masked a second time. +#[test] +fn a_patterned_stencil_mask_under_a_soft_mask_is_masked_once() { + let content = "/S gs /Pattern cs /P scn q 40 0 0 40 20 20 cm /Msk Do Q 120 20 40 40 re f"; + let tile = "1 0 0 rg 0 0 10 10 re f"; + let group = "0.5 g 0 0 200 200 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Msk 5 0 R >> /Pattern << /P 6 0 R >> /ExtGState << /S << /SMask << /S /Luminosity /G 7 0 R >> >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00606000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceGray >> /Length {} >> stream +{group} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len(), + group.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |x: u32, y: u32| page.rgba[(((200 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + let (ring, rect) = (px(25, 25), px(140, 40)); + assert!(rect != [255, 255, 255] && rect != [255, 0, 0], "the rectangle is half masked: {rect:?}"); + assert!(ring.iter().zip(&rect).all(|(a, b)| a.abs_diff(*b) <= 2), "stencil {ring:?}, rectangle {rect:?}"); + assert_eq!(px(40, 40), [255, 255, 255], "the stencil's unpainted centre stays white"); +} + +/// A patterned stencil mask in a Type 3 glyph is shrunk by area averaging, as it is outside +/// one (#4800): the glyph's own image rules are for its images, not for the coverage that cuts +/// the pattern. Here a one-column stroke of an 8-column stencil, drawn 4 pixels wide, is a +/// half-covered red; nearest-neighbour would read columns 1, 3, 5 and 7 and miss it. +#[test] +fn a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes() { + let content = "BT /Pattern cs /P scn /T3 1 Tf 0 0 Td (a) Tj ET"; + let glyph = "400 0 0 0 400 100 d1 q 400 0 0 100 0 0 cm /Im0 Do Q"; + let tile = "1 0 0 rg 0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /Font << /T3 6 0 R >> /Pattern << /P 8 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream +DF> +endstream endobj +6 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 400 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /a 7 0 R >> /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [400] /Resources << /XObject << /Im0 5 0 R >> >> >> endobj +7 0 obj << /Length {} >> stream +{glyph} +endstream endobj +8 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.len(), + tile.len() + ); + let page = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (4, 1)); + let px = page.rgba.as_chunks::<4>().0.to_vec(); + let [r, g, b, _] = px.get(1).copied().unwrap_or_default(); + assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}"); + assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}"); +} + +/// pdf.js issue 8078 paints 73,047 small stencil masks with one tiling pattern. Each was drawn +/// through a canvas-sized luminance mask (a full-page render and composite per stencil), which +/// took minutes for the page; a stencil now costs what its rectangle covers. Here 5,000 +/// patterned stencils on a 1000 × 2000 page. What the page draws offscreen is counted (hayro +/// patch 30) rather than timed, so the check is the same on every machine: the one pattern cell, +/// and not a page-sized pixmap per stencil. +#[test] +fn many_patterned_stencil_masks_draw_no_page_sized_masks() { + let mut content = String::from("/Pattern cs /P scn "); + for i in 0..5000u32 { + content.push_str(&format!("q 16 0 0 16 {} {} cm /Msk Do Q ", (i % 50) * 20, (i / 50) * 20)); + } + let before = hayro::offscreen_draws(); + let page = patterned_stencil_page((1000, 2000), &content); + let after = hayro::offscreen_draws(); + assert_eq!(after.tiling_cells - before.tiling_cells, 1, "one cell for the pattern's one setting"); + let pixels = after.pixels - before.pixels; + assert!(pixels < 1000 * 2000, "5,000 patterned stencils drew {pixels} pixels offscreen"); + // The last stencil sits at (980, 1980): its ring is red, its centre white. + let px = |x: u32, y: u32| page.rgba[(((2000 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + assert_eq!(px(981, 1981), [255, 0, 0]); + assert_eq!(px(988, 1988), [255, 255, 255]); +} + +/// A coloured tiling pattern's cell is drawn once for all the fills of one setting (`scn`), as +/// counted by hayro patch 30: here 3,000 small squares filled with one pattern. Drawn again for +/// each fill, each cell was kept until the page was composited (pdf.js issue 8078 ran out of +/// memory that way, its 3.5 MB cell drawn for each of 73,047 stencils). The squares all show the +/// pattern in place. The same holds for a step so large that the fills show one cell, which is +/// drawn with a transparent border instead of repeated. +#[test] +fn fills_of_one_pattern_setting_share_its_cell() { + let mut content = String::from("/Pattern cs /P scn "); + for i in 0..3000u32 { + content.push_str(&format!("{} {} 4 4 re f ", (i % 60) * 5, (i / 60) * 5)); + } + // The cell is blue with a red lower-left quarter; at `/Matrix` scale 10 a cell is 100 units, + // so with a step of 10 red shows where both coordinates, modulo 100, are below 50. With a + // step of 100000 the one cell shows and nothing past it. + let cell = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f"; + for (step, past_the_first_cell) in [("10", [0, 0, 255]), ("100000", [255, 255, 255])] { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ); + let before = hayro::offscreen_draws(); + let page = render_first_page(pdf.into_bytes()); + let cells = hayro::offscreen_draws().tiling_cells - before.tiling_cells; + assert_eq!(cells, 1, "step {step}: cells drawn for 3,000 fills of one setting"); + // Pixel rows run top down: PDF (x, y) is pixel (x, 300 - y). + let px = |x: u32, y: u32| page.rgba[(((300 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + // The first square, at (0, 0), and the last, at (295, 245). + assert_eq!(px(1, 1), [255, 0, 0], "step {step}"); + assert_eq!(px(296, 246), past_the_first_cell, "step {step}"); + } +} + +/// Fills interleaving the settings of two coloured patterns with cells of one size, and of one +/// of them at two cell scales, each show their own pattern in place: a cell kept for one setting +/// is not taken for another (hayro patch 30). `/P` is blue with a red lower-left quarter and `/Q` +/// green with a yellow one, both at `/Matrix` scale 10, so a cell is 100 units and its quarter +/// shows where both coordinates, modulo 100, are below 50. The form `/X`, drawn at scale 2, sets +/// `/P` in its own space, where a cell is 200 units, red where both page coordinates, modulo +/// 200, are below 100. Each of the six settings draws its cell once, for all its fills. +#[test] +fn fills_of_interleaved_pattern_settings_each_show_their_own_cell() { + let content = "/Pattern cs /P scn 60 60 4 4 re f 10 10 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \ + /Q scn 110 10 4 4 re f 160 160 4 4 re f /P scn 210 210 4 4 re f q 2 0 0 2 0 0 cm /X Do Q \ + /Q scn 260 260 4 4 re f 110 110 4 4 re f"; + // In page space, squares at (60, 260) and (260, 60), where `/P` at scale 1 is blue. + let form = "/Pattern cs /P scn 30 130 2 2 re f 130 30 2 2 re f"; + let p = "0 0 1 rg 0 0 10 10 re f 1 0 0 rg 0 0 5 5 re f"; + let q = "0 1 0 rg 0 0 10 10 re f 1 1 0 rg 0 0 5 5 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Pattern << /P 5 0 R /Q 6 0 R >> /XObject << /X 7 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{p} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Matrix [10 0 0 10 0 0] /Resources << >> /Length {} >> stream +{q} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 150 150] /Resources << /Pattern << /P 5 0 R >> >> /Length {} >> stream +{form} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + p.len(), + q.len(), + form.len() + ); + let before = hayro::offscreen_draws(); + let page = render_first_page(pdf.into_bytes()); + assert_eq!(hayro::offscreen_draws().tiling_cells - before.tiling_cells, 6, "one cell per setting"); + // The middle of the 4 × 4 square at PDF (x, y); pixel rows run top down. + let px = |x: u32, y: u32| page.rgba[(((300 - 2 - y) * page.width + x + 2) * 4) as usize..][..3].to_vec(); + let (red, blue, green, yellow) = ([255, 0, 0], [0, 0, 255], [0, 255, 0], [255, 255, 0]); + for (x, y, colour, what) in [ + (60, 60, blue, "/P"), + (10, 10, red, "/P"), + (60, 260, red, "/P at scale 2"), + (260, 60, red, "/P at scale 2"), + (110, 10, yellow, "/Q"), + (160, 160, green, "/Q"), + (210, 210, red, "/P set again"), + (260, 260, green, "/Q set again"), + (110, 110, yellow, "/Q set again"), + ] { + assert_eq!(px(x, y), colour, "{what} at ({x}, {y})"); + } +} + +/// An uncoloured tiling pattern (`/PaintType 2`) takes the colour of each setting, so fills that +/// alternate red and green settings of it show both, each in place. Its cell also takes the +/// opacity the setting captured, which a patterned stencil mask's copy of the setting leaves out +/// (its transparency group applies it, hayro-interpret patch 46), so the cell drawn for one fill +/// of a setting is not the other's (hayro patch 30 keeps only coloured cells): a square and a +/// stencil painted with one half-opaque blue setting show the same half blue. +#[test] +fn an_uncoloured_pattern_shows_each_settings_colour() { + let content = "/C cs 1 0 0 /U scn 10 10 20 20 re f 0 1 0 /U scn 40 10 20 20 re f \ + 1 0 0 /U scn 70 10 20 20 re f 0 1 0 /U scn 100 10 20 20 re f \ + /H gs 0 0 1 /U scn 10 50 20 20 re f q 20 0 0 20 40 50 cm /Msk Do Q"; + let tile = "0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 100] /Contents 4 0 R /Resources << /ColorSpace << /C [/Pattern /DeviceRGB] >> /Pattern << /U 6 0 R >> /XObject << /Msk 5 0 R >> /ExtGState << /H << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ImageMask true /Filter /ASCIIHexDecode /Length 9 >> stream +00000000> +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len() + ); + let page = render_first_page(pdf.into_bytes()); + // Pixel rows run top down: PDF (x, y) is pixel (x, 100 - y). + let px = |x: u32, y: u32| page.rgba[(((100 - y) * page.width + x) * 4) as usize..][..3].to_vec(); + for (x, colour) in [(20, [255, 0, 0]), (50, [0, 255, 0]), (80, [255, 0, 0]), (110, [0, 255, 0])] { + assert_eq!(px(x, 20), colour, "the square at x {x}"); + } + let (square, stencil) = (px(20, 60), px(50, 60)); + assert!(square[2] == 255 && square[0].abs_diff(128) <= 1 && square[1] == square[0], "half blue: {square:?}"); + assert!(square.iter().zip(&stencil).all(|(a, b)| a.abs_diff(*b) <= 2), "square {square:?}, stencil {stencil:?}"); +} + /// #2971: in a knockout transparency group (`/K true`) each element composites with the /// group's initial backdrop, not with the elements drawn before it in the group, over the /// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue @@ -8516,21 +8943,232 @@ fn decoded_images_serve_every_size_they_are_drawn_at() { let mut device = Draws::default(); interpret_page(page, &mut ctx, &mut device); assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels"); - cache.decoded_image_decodes() + // (image decodes, mask decodes) + let masks = cache.decoded_mask_decodes(); + (cache.decoded_image_decodes() - masks, masks) }; - assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size"); - assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); + assert_eq!(decodes("A"), (1, 0), "a Flate image decodes the same at every size"); + // The image decodes once for each of the five sizes, its JPX soft mask within it: only a mask + // averaged down (hayro-interpret patch 82) has a cache entry of its own. + assert_eq!(decodes("B"), (5, 0), "an image with a JPX soft mask decodes for the size it is drawn at"); +} + +/// A soft mask far larger than where its image is drawn is averaged down to that size and cached +/// there (vendored hayro-interpret patch 82): a 2 × 2 image whose mask is 34,862 × 4,332, drawn 40 +/// times on one slide (pdf.js `issue16263.pdf`), was decoded and drawn at full size each time and +/// the page took minutes. Drawn ten times at one size, the mask is now decoded once and handed +/// over at that size rounded up to a power of two; a mask no larger than twice its drawn size +/// keeps its own. +#[test] +fn a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + /// Asks for every raster image at the size it is drawn at, as the renderer does, and keeps + /// the size of each alpha channel it was handed. + #[derive(Default)] + struct Draws(Vec<(u32, u32)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.push(alpha.map_or((0, 0), |a| (a.width, a.height))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + // `side` × `side` mask samples, drawn ten times at 20 × 20 points (device pixels at 72 dpi). + let draws = |side: u32| { + let alpha = flate(&(0..side * side).map(|i| (i % 256) as u8).collect::>()); + let mask = format!( + "<< /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + alpha.len() + ); + let content = (0..10).map(|i| format!("q 20 0 0 20 {} 0 cm /A Do Q", i * 22)).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 230.0, 30.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) + }; + let (alphas, decodes) = draws(1000); + // Drawn 20 pixels square, averaged down to that rounded up to a power of two. + assert_eq!(alphas, vec![(32, 32); 10], "averaged down to the size it is drawn at"); + // One decode for the image and one for its mask (here no larger than the intermediate the + // averaged-down sizes are made from, so that is the mask itself), plus the averaged-down mask, + // all shared by the ten draws. + assert_eq!(decodes, 3, "decoded once"); + let (alphas, _) = draws(40); + assert_eq!(alphas, vec![(40, 40); 10], "no more than twice the drawn size: kept as it is"); +} + +/// The soft masks a small image is handed, drawn at each of `sizes` (width × height points at 72 +/// dpi): its `mask` (`width`, `height`, 8-bit samples) is larger than the 2 × 1 image, so it may +/// be averaged down (vendored hayro-interpret patch 82). Each mask is given as its size and +/// samples. +fn soft_masks_handed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)]) -> (Vec<(u32, u32, Vec)>, usize) { + soft_masks_handed_keyed(width, height, mask, sizes, ("Width", "Height")) +} + +/// `soft_masks_handed`, with the mask's dimensions under `keys` (`/W` and `/H`, say). +fn soft_masks_handed_keyed(width: u32, height: u32, mask: &[u8], sizes: &[(u32, u32)], keys: (&str, &str)) -> (Vec<(u32, u32, Vec)>, usize) { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::io::Write; + + #[derive(Default)] + struct Draws(Vec<(u32, u32, Vec)>); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, alpha| self.0.extend(alpha.map(|a| (a.width, a.height, a.data))), Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let rgb = flate(&[0u8; 2 * 3]); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /SMask 6 0 R /Length {} >>", + rgb.len() + ); + let alpha = flate(mask); + let mask_dict = format!( + "<< /Type /XObject /Subtype /Image /{} {width} /{} {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >>", + keys.0, + keys.1, + alpha.len() + ); + let content = sizes.iter().map(|(w, h)| format!("q {w} 0 0 {h} 0 0 cm /A Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf(&content, "<< /XObject << /A 5 0 R >> >>", &[(&image, &rgb), (&mask_dict, &alpha)]); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 400.0, 400.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.decoded_image_decodes()) +} + +/// An averaged-down soft mask (vendored hayro-interpret patch 82): +/// - takes each source sample into the target sample its centre falls in: seven columns +/// `0 0 0 255 0 0 0`, drawn three wide and so averaged to four, give `0 0 127 0` (by left edges, +/// `0 127 0 0`); +/// - is never sized past the samples a truncated stream decodes: a mask that says it is 100 rows +/// but holds 10, drawn 20 high, is handed no rows that no sample covers; +/// - is cached per drawn size, rounded up to a power of two: the same image drawn 20 and then 200 +/// points wide is handed its mask at 32 and then at its own 100, not the first draw's 32 again, +/// and drawn 20 and 21 wide it is handed one mask of 32 both times, whether the mask's +/// dimensions are under `/Width` and `/Height` or `/W` and `/H`; +/// - is made, at every size, from one intermediate mask at most 4096 on a side, so a mask wider +/// than that, drawn at four sizes, is decoded in full once and not four times. +#[test] +fn an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size() { + let (handed, _) = soft_masks_handed(7, 1, &[0, 0, 0, 255, 0, 0, 0], &[(3, 1)]); + assert_eq!(handed, vec![(4, 1, vec![0, 0, 127, 0])], "centres, not left edges"); + let (handed, _) = soft_masks_handed(100, 100, &[255; 100 * 10], &[(20, 20)]); + // 32 wide (20 rounded up to a power of two), and 10 rows, the ones decoded, not 32: all opaque. + assert_eq!(handed, vec![(32, 10, vec![255; 32 * 10])], "no uncovered rows"); + let sizes = |draws: &[(u32, u32)]| -> Vec<(u32, u32)> { + soft_masks_handed(100, 100, &[255; 100 * 100], draws).0.iter().map(|(w, h, _)| (*w, *h)).collect() + }; + assert_eq!(sizes(&[(20, 20), (200, 200)]), vec![(32, 32), (100, 100)], "each draw at its own size"); + assert_eq!(sizes(&[(20, 20), (21, 21)]), vec![(32, 32), (32, 32)], "draws a pixel apart share one"); + // The same with the mask's dimensions under the abbreviated `/W` and `/H`, which an image is + // read with too. + let (handed, _) = soft_masks_handed_keyed(100, 100, &[255; 100 * 100], &[(20, 20), (200, 200)], ("W", "H")); + let abbreviated: Vec<(u32, u32)> = handed.iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(abbreviated, vec![(32, 32), (100, 100)], "each draw at its own size, under /W and /H"); + // Columns alternating 0 and 255, so every averaged-down sample is about half: within 8, as a + // sample covering 19 or 20 columns, say, holds 9 or 10 opaque ones. + let wide: Vec = (0..5000 * 2).map(|i| if i % 2 == 0 { 0 } else { 255 }).collect(); + let (handed, decodes) = soft_masks_handed(5000, 2, &wide, &[(20, 1), (40, 1), (80, 1), (160, 1)]); + let sizes: Vec<(u32, u32)> = handed.iter().map(|(w, h, _)| (*w, *h)).collect(); + assert_eq!(sizes, vec![(32, 1), (64, 1), (128, 1), (256, 1)]); + assert!(handed.iter().all(|(_, _, data)| data.iter().all(|&a| a.abs_diff(127) <= 8)), "{handed:?}"); + // The image at each of the four sizes, the 4096-wide intermediate once, and the mask at each + // of the four sizes made from it. Without the intermediate there are 8, each mask size a full + // decode. + assert_eq!(decodes, 9, "the full mask decoded once"); } // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; +// A Type 3 glyph shown again decodes its procedure once (vendored hayro-interpret patch 87). +mod type3_proc_cache; + +// A picture that doesn't decode is decoded once however often it is drawn (vendored +// hayro-interpret patch 91). +mod failed_image_decodes; + // A page past its rasterizer coverage leaves the rest out and says so (vendored vello_cpu patch 1). mod coverage_budget; // A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). mod symbolic_truetype_cmap; +// A TrueType font whose `head.indexToLocFormat` is out of range reads `loca` in the format its +// length gives (vendored hayro-interpret patch 83). +mod truetype_loca_format; + +// An Arial (#5889) or Times New Roman (#6449) the PDF doesn't embed draws the glyphs its codes +// name. +mod non_embedded_arial; + +// A Type 3 font without /Widths advances by its glyphs' d0/d1 widths (PDFBOX-2943). +mod type3_proc_widths; + +// A symbolic TrueType font in the standard Macintosh glyph order reads its codes as Mac OS Roman +// (vendored hayro-interpret patch 101). +mod symbolic_truetype_mac_roman_text; + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); diff --git a/crates/render/src/raster/tests/failed_image_decodes.rs b/crates/render/src/raster/tests/failed_image_decodes.rs new file mode 100644 index 0000000000..bd0329ed31 --- /dev/null +++ b/crates/render/src/raster/tests/failed_image_decodes.rs @@ -0,0 +1,239 @@ +//! A picture that doesn't decode is decoded once however often a page draws it (vendored +//! hayro-interpret patch 91). The decoded-image cache kept only decodes that succeeded, so a +//! damaged picture, or a JPX or JBIG2 stream its decoder refuses, was decoded again at every +//! draw: a page drawing it a thousand times decoded it a thousand times. The failure is kept for +//! the same key now, and every draw still reports it as it did. + +use super::image_cache_pdf; +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, TransformExt, interpret_page, +}; +use kurbo::{Affine, BezPath, Rect}; +use std::io::Write; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +/// What a page handed its device: the raster images with their pixels, those of them with a +/// mask, and the stencil masks with theirs. Every image is asked for at the size it is drawn +/// at, as the renderer does. +#[derive(Default)] +struct Draws { + rasters: usize, + masked: usize, + stencils: usize, +} + +impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + match image { + Image::Raster(r) => r.with_rgba( + |_, alpha| { + self.rasters += 1; + self.masked += usize::from(alpha.is_some()); + }, + Some(target), + ), + Image::Stencil(s) => s.with_stencil(|_, _| self.stencils += 1, Some(target)), + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +/// What drawing `/name` five times at one size, and then the picture that decodes (`/G`) once +/// on the right, did: the decodes the cache ran, the image decode failures reported, and what +/// the device was handed. +struct Outcome { + decodes: usize, + failures: usize, + draws: Draws, +} + +fn draw_five_times(name: &str) -> Outcome { + draw_five_times_at(name, [20; 5]) +} + +/// [`draw_five_times`], drawing `/name` the five times at `sizes` (units square). +fn draw_five_times_at(name: &str, sizes: [u32; 5]) -> Outcome { + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::>()); + let image = |filter: &str, extra: &str, len: usize| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /{filter} {extra}/Length {len} >>" + ) + }; + let good = image("FlateDecode", "", pixels.len()); + // A JPEG 2000 picture whose codestream is four zero bytes. + let bad_jpx = image("JPXDecode", "", 4); + // A picture whose soft mask is such a JPEG 2000 stream. + let bad_mask = image("FlateDecode", "/SMask 9 0 R ", pixels.len()); + // A stencil mask whose JBIG2 data is four zero bytes. + let bad_stencil = "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ImageMask true /Filter /JBIG2Decode /Length 4 >>"; + let jpx_mask = + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>"; + let content = [0, 24, 48, 72, 96].iter().zip(sizes).map(|(x, s)| format!("q {s} 0 0 {s} {x} 10 cm /{name} Do Q")).collect::>().join(" ") + + " q 10 0 0 10 118 10 cm /G Do Q"; + let bytes = image_cache_pdf( + &content, + "<< /XObject << /G 5 0 R /J 6 0 R /S 7 0 R /M 8 0 R >> >>", + &[(&good, &pixels), (&bad_jpx, b"\0\0\0\0"), (&bad_mask, &pixels), (bad_stencil, b"\0\0\0\0"), (jpx_mask, b"\0\0\0\0")], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let failures = Arc::new(AtomicUsize::new(0)); + let others = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let (failures, others) = (failures.clone(), others.clone()); + Arc::new(move |warning| match warning { + InterpreterWarning::ImageDecodeFailure => { + failures.fetch_add(1, Ordering::Relaxed); + } + other => others.lock().unwrap().push(format!("{other:?}")), + }) + }, + ..InterpreterSettings::default() + }; + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), settings); + let mut draws = Draws::default(); + interpret_page(page, &mut ctx, &mut draws); + assert!(others.lock().unwrap().is_empty(), "/{name} reported {:?}", others.lock().unwrap()); + Outcome { decodes: cache.decoded_image_decodes(), failures: failures.load(Ordering::Relaxed), draws } +} + +/// A JPEG 2000 picture that doesn't decode, drawn five times at one size, is decoded once, and +/// each of the five draws still reports the failure. +#[test] +fn a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn() { + let out = draw_five_times("J"); + assert_eq!(out.draws.rasters, 1, "only the picture after it drew"); + assert_eq!(out.decodes, 2, "decoded again at every draw"); + assert_eq!(out.failures, 5, "every draw reports the failure, as before"); +} + +/// A JPEG 2000 picture decodes at a reduced resolution for the size it is drawn at, so its +/// failure is kept for that size: drawn at two sizes, it is decoded once at each, also when it +/// comes back to the first size after the second. +#[test] +fn a_picture_that_does_not_decode_is_decoded_once_at_each_size() { + let out = draw_five_times_at("J", [20, 20, 10, 10, 20]); + assert_eq!(out.decodes, 3, "once at each of its two sizes, and the picture after it"); + assert_eq!(out.failures, 5, "every draw reports the failure"); +} + +/// A JBIG2 stencil mask that doesn't decode, drawn five times, is decoded once, and each draw +/// still reports the failure: it isn't the page's JBIG2 allowance that refused it, which a +/// later page would have afresh. +#[test] +fn a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn() { + let out = draw_five_times("M"); + assert_eq!((out.draws.stencils, out.draws.rasters), (0, 1), "only the picture after it drew"); + assert_eq!(out.decodes, 2, "decoded again at every draw"); + assert_eq!(out.failures, 5, "every draw reports the failure, as before"); +} + +/// A picture whose soft mask doesn't decode draws without it at every draw, decoded once, and +/// the mask's failure is reported once, as it was (the picture decodes; only its mask fails). +#[test] +fn a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once() { + let out = draw_five_times("S"); + assert_eq!((out.draws.rasters, out.draws.masked), (6, 0), "drawn five times without its mask, and the picture after it"); + assert_eq!(out.decodes, 2); + assert_eq!(out.failures, 1); +} + +/// A picture that decodes is still decoded once and handed to every draw. +#[test] +fn a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time() { + let out = draw_five_times("G"); + assert_eq!(out.draws.rasters, 6); + assert_eq!(out.decodes, 1); + assert_eq!(out.failures, 0); +} + +/// A JBIG2 picture the page's allowance left out isn't kept as a failure: the next page drawing +/// it starts with an allowance of its own and draws it, from the same cache. +#[test] +fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() { + use super::{RenderWarning, black_jbig2_page, jbig2_segment}; + use crate::raster::{parse, render_page, take_warnings, warning_settings}; + use crate::{RenderConfig, RenderRequest, RequestKind}; + use hayro::RenderCache; + + let image = |number: u8, id: u8| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 62, &[], &[0, 0, 0, 0, id]); + let mut object = format!( + "{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + stream.len() + ) + .into_bytes(); + object.extend_from_slice(&stream); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let page = |number: u8, content: u8| { + format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << /A 7 0 R /B 8 0 R >> >> >> endobj\n").into_bytes() + }; + let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes(); + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec(); + pdf.extend(page(3, 5)); + pdf.extend(page(4, 6)); + // The first page spends its allowance on A and has nothing left for B; the second draws A, + // decoded already, and then B. + pdf.extend(content(5, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(content(6, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(image(7, 1)); + pdf.extend(image(8, 2)); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = parse(&Arc::new(pdf), None).expect("parses"); + /// A page's pixels and the warnings it reported. + type Drawn = (Vec, Vec); + let black = |(rgba, _): &Drawn, x: usize| match &rgba[((20 * 40 + x) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither an image nor the page: {other:?}"), + }; + // Both pages, or the first alone, through one cache, as a render worker keeps it. + let pages = |work: u64, count: usize| { + let config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() }; + let cache = RenderCache::new(); + (0..count) + .map(|page| { + let warnings = Arc::new(Mutex::new(Vec::new())); + let settings = warning_settings(&config.settings(), warnings.clone()); + let req = RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }; + let (_, _, rgba, _) = render_page(&pdf, &cache, &settings, &config, req).expect("renders"); + (rgba[..].to_vec(), take_warnings(&warnings)) + }) + .collect::>() + }; + // The smallest allowance that draws A on the first page. A larger allowance draws at least as + // much, so a binary search over the first page alone finds it in about ten draws. + let draws_a = |work: u64| pages(work, 1).first().is_some_and(|first| black(first, 10)); + let candidates: Vec = (1..1_000).collect(); + let one = candidates.get(candidates.partition_point(|work| !draws_a(*work))).copied().expect("a small image is cheap"); + let Ok([first, second]) = <[Drawn; 2]>::try_from(pages(one, 2)) else { panic!("two pages") }; + assert_eq!((black(&first, 10), black(&first, 30)), (true, false), "the first page had enough for A alone"); + assert!(first.1.contains(&RenderWarning::ContentTruncated), "{:?}", first.1); + assert!(black(&second, 10) && black(&second, 30), "A, and B left out of the first page, draw on the second"); + assert!(second.1.is_empty(), "{:?}", second.1); +} diff --git a/crates/render/src/raster/tests/non_embedded_arial.rs b/crates/render/src/raster/tests/non_embedded_arial.rs new file mode 100644 index 0000000000..42a990638a --- /dev/null +++ b/crates/render/src/raster/tests/non_embedded_arial.rs @@ -0,0 +1,150 @@ +//! An Arial or a Times New Roman the PDF doesn't embed draws the glyphs its codes name (vendored +//! hayro-interpret patches 76 and 88, #5889, #6449). Word and PDFMaker write such a font as a +//! Type 0 `CIDFontType2` with `Identity-H`, often without `/ToUnicode`, so each code is a glyph +//! index in the author's font. Both number their first 258 glyphs in the standard Macintosh order +//! of the TrueType `post` table (`L` is 47, `o` 82, `w` 90). The standard substitute numbers its +//! glyphs another way: "Low Power" drew as "@ck 'Dck Yf", and "Salarié(e) du régime général" as +//! "Salari²*g+f w't²i lo g'ì²p²tcn". +//! +//! Each document is drawn twice, once as such a file has it and once with a `/ToUnicode` that +//! names each code's character, which draws through the substitute's glyph for that character. +//! The two must be the same pixels. + +use super::render_first_page; + +/// "Low É" as Arial's glyph indexes: L, o, w, space, Eacute. +const LOW_E_ACUTE: [(u16, char); 5] = [(47, 'L'), (82, 'o'), (90, 'w'), (3, ' '), (101, 'É')]; + +struct Font<'a> { + /// The `/BaseFont` of both the Type 0 font and its descendant. + base_font: &'a str, + /// The descendant's `/Subtype`. + subtype: &'a str, + /// `true`: the codes are 1, 2, … and a `/CIDToGIDMap` stream gives each its glyph in the author's font. + /// `false`: `/CIDToGIDMap /Identity`, and the codes are the glyph indexes themselves. + mapped: bool, + to_unicode: bool, +} + +/// A page showing [`LOW_E_ACUTE`] at 40 pt in `font`, which has no font program. +fn pdf(font: &Font<'_>) -> Vec { + let codes: Vec = if font.mapped { (1..=LOW_E_ACUTE.len() as u16).collect() } else { LOW_E_ACUTE.iter().map(|(gid, _)| *gid).collect() }; + let hex: String = codes.iter().map(|code| format!("{code:04X}")).collect(); + let content = format!("BT /F1 40 Tf 5 15 Td <{hex}> Tj ET"); + let bfchar: String = codes.iter().zip(LOW_E_ACUTE).map(|(code, (_, c))| format!("<{code:04X}> <{:04X}>\n", u32::from(c))).collect(); + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def +/CMapName /Adobe-Identity-UCS def /CMapType 2 def +1 begincodespacerange <0000> endcodespacerange +{} beginbfchar +{bfchar}endbfchar +endcmap CMapName currentdict /CMap defineresource pop end end", + codes.len() + ); + // CID 0 is .notdef; CIDs 1… give the glyphs in order. + let cid_to_gid: Vec = std::iter::once(0).chain(LOW_E_ACUTE.iter().map(|(gid, _)| *gid)).flat_map(u16::to_be_bytes).collect(); + let Font { base_font, subtype, .. } = font; + let to_unicode = if font.to_unicode { " /ToUnicode 8 0 R" } else { "" }; + let map = if font.mapped { "9 0 R" } else { "/Identity" }; + + let mut out = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /Identity-H /DescendantFonts [6 0 R]{to_unicode} >> endobj +6 0 obj << /Type /Font /Subtype /{subtype} /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> + /FontDescriptor 7 0 R /CIDToGIDMap {map} /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 32 /FontBBox [-665 -325 2000 1040] /ItalicAngle 0 /Ascent 728 /Descent -210 /CapHeight 716 /StemV 80 >> endobj +8 0 obj << /Length {} >> stream +{cmap} +endstream endobj +9 0 obj << /Length {} >> stream +", + content.len(), + cmap.len(), + cid_to_gid.len() + ) + .into_bytes(); + out.extend_from_slice(&cid_to_gid); + out.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + out +} + +/// `font` drawn without and with a `/ToUnicode`; the second must draw ink. +fn without_and_with_unicode(font: Font<'_>) -> (Vec, Vec) { + let without = render_first_page(pdf(&Font { to_unicode: false, ..font })); + let with = render_first_page(pdf(&Font { to_unicode: true, ..font })); + assert!(with.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "{}: the control must draw ink", font.base_font); + (without.rgba.to_vec(), with.rgba.to_vec()) +} + +/// Each Arial face, as Windows exporters name it and as Word names a face it doesn't embed, draws +/// "Low É" from its glyph indexes, with `/CIDToGIDMap /Identity` and with a map stream. The name +/// is read without regard to case, `MT` included. +#[test] +fn an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name() { + for base_font in [ + "ArialMT", + "Arial-BoldMT", + "Arial-ItalicMT", + "Arial-BoldItalicMT", + "Arial", + "Arial,Bold", + "Arial,BoldItalic", + "ABCDEF+Arial-BoldMT", + "arialmt", + "Arial-Boldmt", + ] { + for mapped in [false, true] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype: "CIDFontType2", mapped, to_unicode: false }); + assert!(without == with, "{base_font} (CIDToGIDMap {}): the codes draw \"Low É\"", if mapped { "stream" } else { "/Identity" }); + } + } +} + +/// Each Times New Roman face, as Windows exporters name it and as Word names a face it doesn't +/// embed (`TimesNewRoman,Bold` in #6449), draws "Low É" from its glyph indexes. +#[test] +fn a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name() { + for base_font in [ + "TimesNewRomanPSMT", + "TimesNewRomanPS-BoldMT", + "TimesNewRomanPS-ItalicMT", + "TimesNewRomanPS-BoldItalicMT", + "TimesNewRoman", + "TimesNewRoman,Bold", + "TimesNewRoman,Italic", + "TimesNewRoman,BoldItalic", + "WFXLUF+TimesNewRoman,Bold", + ] { + for mapped in [false, true] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype: "CIDFontType2", mapped, to_unicode: false }); + assert!(without == with, "{base_font} (CIDToGIDMap {}): the codes draw \"Low É\"", if mapped { "stream" } else { "/Identity" }); + } + } +} + +/// Only the faces of Arial and Times New Roman are read in that order: Arial Narrow, Arial Black, +/// Arial Unicode MS, Times and Times New Roman MT Std are other fonts, and a `CIDFontType0` font's +/// CIDs aren't TrueType glyph indexes. +#[test] +fn codes_of_other_fonts_are_not_read_in_the_macintosh_order() { + for (base_font, subtype) in [ + ("ArialNarrow", "CIDFontType2"), + ("ArialNarrow-Bold", "CIDFontType2"), + ("Arial-Black", "CIDFontType2"), + ("ArialUnicodeMS", "CIDFontType2"), + ("ArialMT", "CIDFontType0"), + ("Times-Bold", "CIDFontType2"), + ("TimesNewRomanMTStd", "CIDFontType2"), + ("TimesNewRoman,Bold", "CIDFontType0"), + ] { + let (without, with) = without_and_with_unicode(Font { base_font, subtype, mapped: false, to_unicode: false }); + assert!(without != with, "{base_font} ({subtype}) is not read in the Macintosh order"); + } +} diff --git a/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs b/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs new file mode 100644 index 0000000000..87bcd6e4e0 --- /dev/null +++ b/crates/render/src/raster/tests/symbolic_truetype_mac_roman_text.rs @@ -0,0 +1,281 @@ +//! A symbolic TrueType font with no `/Encoding`, no `/ToUnicode` and no glyph names reads a code +//! as Mac OS Roman when its (1, 0) `cmap` subtable selects the glyph the code draws and that glyph +//! sits where the standard Macintosh glyph order puts the code's Mac OS Roman character (vendored +//! hayro-interpret patch 101; ISO 32000-2 §9.6.5.4: single bytes "look up the associated glyph +//! descriptions" in the (1, 0) subtable, and a (3, 0) subtable maps "the single-byte codes in the +//! (1, 0) subtable" prepended with 0xF0). pdf.js's issue2017r.pdf has such a font; its text read as +//! WinAnsi, `ˆ` for `à`. +//! +//! The fonts are built here, in memory: [`GLYPHS`] glyphs, those the tests show filled squares, no +//! `post` table, and the `cmap` subtables each test asks for. No font file is involved. + +use super::*; + +/// Glyphs in each font: past the standard Macintosh order's `quoteright` (183). +const GLYPHS: u16 = 200; + +/// The standard Macintosh order's glyphs for `agrave`, `eacute`, `endash` and `quoteright`. +const AGRAVE: u8 = 106; +const EACUTE: u8 = 112; +const ENDASH: u8 = 178; +const QUOTERIGHT: u8 = 183; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A (1, 0) format 0 subtable mapping each code to its glyph. +fn mac_subtable(map: &[(u8, u8)]) -> Vec { + let mut out = Vec::new(); + for n in [0u16, 262, 0] { + be16(&mut out, n); // format, length, language + } + let mut ids = [0u8; 256]; + for &(code, glyph) in map { + ids[usize::from(code)] = glyph; + } + out.extend(ids); + out +} + +/// A (3, 0) format 4 subtable mapping each code at 0xF000 to its glyph, one segment each. +fn windows_subtable(map: &[(u8, u8)]) -> Vec { + let segments: Vec<(u16, u16)> = map.iter().map(|&(code, glyph)| (0xF000 | u16::from(code), u16::from(glyph))).chain([(0xFFFF, 0)]).collect(); + let count = segments.len() as u16; + let mut out = Vec::new(); + for n in [4u16, 16 + 8 * count, 0, 2 * count, 2, 0, 0] { + be16(&mut out, n); // format, length, language, segCountX2, searchRange, entrySelector, rangeShift + } + for &(code, _) in &segments { + be16(&mut out, code); // endCode + } + be16(&mut out, 0); // reservedPad + for &(code, _) in &segments { + be16(&mut out, code); // startCode + } + for &(code, glyph) in &segments { + be16(&mut out, if code == 0xFFFF { 1 } else { glyph.wrapping_sub(code) }); // idDelta + } + for _ in &segments { + be16(&mut out, 0); // idRangeOffset + } + out +} + +/// A TrueType font of [`GLYPHS`] glyphs, the `inked` ones squares, whose (3, 0) subtable maps +/// `windows` and whose (1, 0) subtable, if any, maps `mac`. A `named` font has a version 2.0 `post` +/// table naming its `inked` glyphs `bullet0`, `bullet1` and so on. +fn font(inked: &[u8], mac: Option<&[(u8, u8)]>, windows: &[(u8, u8)], named: bool) -> Vec { + // One contour, the square 0..1000 on both axes. + let mut square = Vec::new(); + for n in [1u16, 0, 0, 1000, 1000] { + be16(&mut square, n); + } + be16(&mut square, 3); // the contour's last point + be16(&mut square, 0); // no instructions + square.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + square.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 0, 1000, 0] { + square.extend_from_slice(&dy.to_be_bytes()); + } + let mut glyf = Vec::new(); + let mut loca = Vec::new(); + for glyph in 0..GLYPHS { + be16(&mut loca, (glyf.len() / 2) as u16); + if inked.iter().any(|g| u16::from(*g) == glyph) { + glyf.extend_from_slice(&square); + } + } + be16(&mut loca, (glyf.len() / 2) as u16); + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&GLYPHS.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..GLYPHS { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, GLYPHS); + maxp.resize(32, 0); + + let subtables: Vec<((u16, u16), Vec)> = + mac.map(|m| ((1, 0), mac_subtable(m))).into_iter().chain([((3, 0), windows_subtable(windows))]).collect(); + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, subtables.len() as u16); + let mut offset = 4 + 8 * subtables.len() as u32; + for ((platform, encoding), bytes) in &subtables { + be16(&mut cmap, *platform); + be16(&mut cmap, *encoding); + be32(&mut cmap, offset); + offset += bytes.len() as u32; + } + for (_, bytes) in &subtables { + cmap.extend(bytes); + } + + let mut tables = + vec![(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + if named { + let mut post = vec![0, 2, 0, 0]; + post.resize(32, 0); + be16(&mut post, GLYPHS); + for glyph in 0..GLYPHS { + // Index 258 and on are the table's own names, in order. + let own = inked.iter().position(|g| u16::from(*g) == glyph); + be16(&mut post, own.map_or(0, |i| 258 + i as u16)); + } + for i in 0..inked.len() { + let name = format!("bullet{i}"); + post.push(name.len() as u8); + post.extend(name.bytes()); + } + tables.push((*b"post", post)); + } + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +/// The text extracted from a page that shows `codes` in `font`, marked symbolic and with no +/// `/Encoding` and no `/ToUnicode`. +fn extracted(font: &[u8], codes: &[u8]) -> String { + extracted_with(font, codes, "AAAAAA+Sym", "") +} + +/// [`extracted`] with the font dictionary's `/BaseFont` named `base_font` and the `extra` entries. +fn extracted_with(font: &[u8], codes: &[u8], base_font: &str, extra: &str) -> String { + let hex: String = codes.iter().map(|c| format!("{c:02X}")).collect(); + let content = format!("BT /F1 30 Tf 5 5 Td <{hex}> Tj ET").into_bytes(); + let widths = vec!["1000"; 256].join(" "); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 80 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + format!("<< /Type /Font /Subtype /TrueType /BaseFont /{base_font} /FirstChar 0 /LastChar 255 /Widths [{widths}] /FontDescriptor 6 0 R {extra}>>").into_bytes(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sym /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.text.map(|t| t.plain_text()).unwrap_or_default() +} + +#[test] +fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman() { + // As issue2017r.pdf's font: code c and 0xF000 + c select the glyph the standard Macintosh + // order gives Mac OS Roman c. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "àé", "Mac OS Roman 0x88 is à and 0x8E é"); +} + +#[test] +fn winansi_codes_of_a_font_in_the_macintosh_order_read_as_before() { + // A Windows subset in the same order: code 0x92 draws `quoteright`, WinAnsi 0x92, where Mac OS + // Roman 0x92 is `iacute`. + let map = [(0x92, QUOTERIGHT), (0x96, ENDASH)]; + let font = font(&[ENDASH, QUOTERIGHT], Some(&map), &map, false); + assert_eq!(extracted(&font, &[0x92, 0x96]), "\u{2019}\u{2013}", "WinAnsi, not Mac OS Roman's íñ"); +} + +#[test] +fn a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before() { + let font = font(&[AGRAVE, EACUTE], None, &[(0x88, AGRAVE), (0x8E, EACUTE)], false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "with no (1, 0) subtable the codes read as WinAnsi"); +} + +#[test] +fn a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read() { + // The (3, 0) subtable is used first (#4156); a (1, 0) one that disagrees doesn't say what the + // drawn glyphs are. + let font = font(&[AGRAVE, EACUTE], Some(&[(0x88, EACUTE), (0x8E, AGRAVE)]), &[(0x88, AGRAVE), (0x8E, EACUTE)], false); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "the codes read as WinAnsi, as before"); +} + +#[test] +fn a_font_that_names_its_glyphs_is_not_read_by_their_order() { + // A Wingdings subset draws code 0x83 with glyph 101, `Eacute` in the order, and names it + // `threesans`: a font with glyph names needn't keep the order. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, true); + assert_eq!(extracted(&font, &[0x88, 0x8E]), "ˆŽ", "the codes read as WinAnsi, as before"); +} + +#[test] +fn a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading() { + // Symbol and ZapfDingbats have built-in encodings that aren't Latin (ISO 32000-2 §9.6.6.1), so their codes get + // no implicit Mac OS Roman reading, as the editor's `unicode_map` gives them none. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + for name in ["ZapfDingbats", "AAAAAA+Symbol"] { + let text = extracted_with(&font, &[0x88, 0x8E], name, ""); + assert_eq!(text, "ˆŽ", "{name}: the codes read as WinAnsi, as before"); + } +} + +#[test] +fn differences_naming_notdef_clear_the_mac_roman_reading_for_its_code() { + // `/Differences` without a base encoding: code 0x8E names `.notdef`, and code 0x88 keeps the Mac OS Roman reading. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + let text = extracted_with(&font, &[0x88, 0x8E], "AAAAAA+Sym", "/Encoding << /Differences [142 /.notdef] >> "); + assert_eq!(text, "à", "0x8E reads as nothing"); +} + +#[test] +fn a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off() { + // An unknown `/Encoding` name is read as the built-in encoding, but it names a base encoding all the same, so the + // editor gives it no Mac OS Roman reading and neither does the renderer: the codes read as WinAnsi, as before. + let map = [(0x88, AGRAVE), (0x8E, EACUTE)]; + let font = font(&[AGRAVE, EACUTE], Some(&map), &map, false); + assert_eq!(extracted_with(&font, &[0x88, 0x8E], "AAAAAA+Sym", "/Encoding /NoSuchEncoding "), "ˆŽ"); +} diff --git a/crates/render/src/raster/tests/truetype_loca_format.rs b/crates/render/src/raster/tests/truetype_loca_format.rs new file mode 100644 index 0000000000..aa668317ff --- /dev/null +++ b/crates/render/src/raster/tests/truetype_loca_format.rs @@ -0,0 +1,147 @@ +//! A TrueType font whose `head.indexToLocFormat` holds neither 0 nor 1 draws through the `loca` +//! format its table is in (vendored hayro-interpret patch 83). The OpenType `head` table allows +//! only 0 (short offsets) and 1 (long); the glyph reader took anything but 1 as short, so a font +//! whose field reads 256 (pdf.js `issue2537r.pdf`) had its long offsets read as short ones, and +//! "LINE UP" drew as boxes. ISO 32000-2 §9.9 has an embedded TrueType program drawn as the +//! font program it is. +//! +//! The font is built here, in memory: two glyphs (`.notdef`, empty, and a filled square) and a +//! (1, 0) `cmap` subtable mapping `A` to the square. No font file is involved. + +use super::render_first_page; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// The font, its `loca` offsets long when `long` and followed by `padding` zero bytes, its +/// `head.indexToLocFormat` set to `format`. +fn square_font(long: bool, padding: usize, format: u16) -> Vec { + // Glyph 1: one contour, the square 0..1000 on both axes (glyph 0 is empty). + let mut glyf = Vec::new(); + for n in [1u16, 0, 0, 1000, 1000] { + be16(&mut glyf, n); + } + be16(&mut glyf, 3); // the contour's last point + be16(&mut glyf, 0); // no instructions + glyf.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + glyf.extend_from_slice(&dx.to_be_bytes()); + } + for dy in [0i16, 0, 1000, 0] { + glyf.extend_from_slice(&dy.to_be_bytes()); + } + let mut loca = Vec::new(); + for offset in [0u32, 0, glyf.len() as u32] { + if long { be32(&mut loca, offset) } else { be16(&mut loca, (offset / 2) as u16) } + } + loca.resize(loca.len() + padding, 0); + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + head[50..52].copy_from_slice(&format.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&2u16.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..2 { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, 2); + maxp.resize(32, 0); + + // (1, 0), format 0: `A` → glyph 1 (the square). + let mut mac = Vec::new(); + be16(&mut mac, 0); + be16(&mut mac, 262); + be16(&mut mac, 0); + let mut ids = [0u8; 256]; + ids[usize::from(b'A')] = 1; + mac.extend(ids); + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, 1); + be16(&mut cmap, 1); + be16(&mut cmap, 0); + be32(&mut cmap, 12); + cmap.extend(mac); + + let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +/// A 40 × 40 pt page that shows `A` at 30 pt from (5, 5) in the font. +fn page_with_font(font: &[u8]) -> Vec { + let content = b"BT /F1 30 Tf 5 5 Td (A) Tj ET".to_vec(); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sq /FirstChar 65 /LastChar 65 /Widths [1000] /FontDescriptor 6 0 R >>".to_vec(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sq /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +#[test] +fn a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs() { + // The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it. + let square_drawn = |long: bool, padding: usize, format: u16| { + let page = render_first_page(page_with_font(&square_font(long, padding, format))); + let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255); + assert_eq!(at(2, 2), 255, "nothing is drawn outside the square"); + at(20, 20) < 128 + }; + assert!(square_drawn(true, 0, 1), "long offsets, marked long"); + assert!(square_drawn(false, 0, 0), "short offsets, marked short"); + assert!(square_drawn(true, 0, 0x0100), "long offsets marked 256: the table's length says long"); + assert!(square_drawn(false, 0, 0x0100), "short offsets marked 256: the table's length says short"); + // Three short offsets are 6 bytes and three long ones 12: 8 bytes is short ones and padding. + assert!(square_drawn(false, 2, 0x0100), "short offsets and padding marked 256: still short"); +} diff --git a/crates/render/src/raster/tests/type3_proc_cache.rs b/crates/render/src/raster/tests/type3_proc_cache.rs new file mode 100644 index 0000000000..350877e6f8 --- /dev/null +++ b/crates/render/src/raster/tests/type3_proc_cache.rs @@ -0,0 +1,152 @@ +//! A Type 3 glyph shown many times decodes its glyph procedure once (vendored hayro-interpret +//! patch 87). hayro inflated the procedure again for every copy of the glyph, so a font used as +//! a fill pattern, one glyph drawn hundreds of thousands of times, took seconds a page. + +use super::image_cache_pdf; +use hayro::hayro_interpret::font::Glyph; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + interpret_page, +}; +use kurbo::{Affine, BezPath, Rect, Shape}; +use std::io::Write; +use std::sync::Arc; + +/// Records the width in device pixels of each path drawn, and draws each Type 3 glyph through its +/// procedure, as the renderer does. +#[derive(Default)] +struct Paths(Vec); + +impl<'a> Device<'a> for Paths { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) { + self.0.push((transform * path.clone()).bounding_box().width()); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, _: &GlyphDrawMode) { + if let Glyph::Type3(glyph) = glyph { + glyph.interpret(self, transform, glyph_transform, paint); + } + } + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +fn flate(data: &[u8]) -> Vec { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() +} + +/// A Type 3 font on an 8 × 8 grid whose glyphs, named `names`, take the codes from 33 on and +/// have their procedures in the objects from `first_object` on. +fn font(names: &[String], first_object: usize) -> String { + let procs: String = names.iter().enumerate().map(|(i, n)| format!("/{n} {} 0 R ", first_object + i)).collect(); + let names_list: String = names.iter().map(|n| format!("/{n} ")).collect(); + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 8 8] /FontMatrix [0.125 0 0 0.125 0 0] /CharProcs << {procs}>> \ + /Encoding << /Type /Encoding /Differences [33 {names_list}] >> /FirstChar 33 /LastChar {} /Widths [{}] >>", + 32 + names.len(), + "8 ".repeat(names.len()) + ) +} + +/// Renders the page of `bytes` through a new cache; the widths of the paths drawn, and how many +/// glyph procedures were decoded. +fn render(bytes: Vec) -> (Vec, usize) { + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let initial = page.initial_transform(true).to_kurbo(); + let cache = InterpreterCache::new(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Paths::default(); + interpret_page(page, &mut ctx, &mut device); + (device.0, cache.type3_proc_decodes()) +} + +/// A Type 3 font whose one glyph, `!`, is a Flate-compressed procedure filling its 8 × 8 box, +/// shown 200 times on one page and rendered twice through one cache: every copy draws, and the +/// procedure is decoded once, on the first copy. +#[test] +fn a_type3_glyph_shown_again_decodes_its_procedure_once() { + let proc_bytes = flate(b"8 0 0 0 8 8 d1 0 0 8 8 re f"); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let content = format!("BT /F1 0.5 Tf 1 0 0 1 2 30 Tm ({}) Tj ET", "!".repeat(200)); + let resources = format!("<< /Font << /F1 {} >> >>", font(&["dot".into()], 5)); + let parsed = Pdf::new(Arc::new(image_cache_pdf(&content, &resources, &[(&proc_dict, &proc_bytes)]))).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + for render in ["first", "second"] { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Paths::default(); + interpret_page(page, &mut ctx, &mut device); + assert_eq!(device.0.len(), 200, "the {render} render draws every copy of the glyph"); + assert_eq!(cache.type3_proc_decodes(), 1, "after the {render} render: the procedure is decoded once"); + } +} + +/// A procedure that decodes to more than 64 KiB isn't kept: shown three times, it is decoded three +/// times, and draws each time. +#[test] +fn a_procedure_over_64_kib_is_decoded_for_each_copy() { + let mut program = b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(); + program.resize((64 << 10) + 1, b' '); + let proc_bytes = flate(&program); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let resources = format!("<< /Font << /F1 {} >> >>", font(&["big".into()], 5)); + let bytes = image_cache_pdf("BT /F1 0.5 Tf 1 0 0 1 2 30 Tm (!!!) Tj ET", &resources, &[(&proc_dict, &proc_bytes)]); + let (widths, decodes) = render(bytes); + assert_eq!(widths.len(), 3); + assert_eq!(decodes, 3, "a procedure over the size kept is decoded for each copy"); +} + +/// The fonts of one cache keep at most 32 MiB of procedures together: 600 glyphs of 60,000 bytes +/// each, over three fonts (36 MB), each shown twice, all draw; the procedures kept are decoded +/// once and the rest twice, so there are more decodes than glyphs and fewer than copies. +#[test] +fn the_procedures_one_cache_keeps_stop_at_32_mib() { + let mut program = b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(); + program.resize(60_000, b' '); + let proc_bytes = flate(&program); + let proc_dict = format!("<< /Filter /FlateDecode /Length {} >>", proc_bytes.len()); + let (fonts, per_font) = (3, 200); + let mut resources = String::from("<< /Font << "); + let mut content = String::new(); + for f in 0..fonts { + let names: Vec = (0..per_font).map(|g| format!("g{f}x{g}")).collect(); + resources.push_str(&format!("/F{f} {} ", font(&names, 5 + f * per_font))); + let codes: String = (0..per_font).map(|g| format!("{:02x}", 33 + g)).collect(); + content.push_str(&format!("BT /F{f} 0.05 Tf 1 0 0 1 2 {} Tm <{codes}{codes}> Tj ET ", 10 + 20 * f)); + } + resources.push_str(">> >>"); + let objects: Vec<(&str, &[u8])> = (0..fonts * per_font).map(|_| (proc_dict.as_str(), proc_bytes.as_slice())).collect(); + let bytes = image_cache_pdf(&content, &resources, &objects); + let (widths, decodes) = render(bytes); + assert_eq!(widths.len(), 2 * fonts * per_font, "every copy draws"); + assert!(decodes > fonts * per_font && decodes < 2 * fonts * per_font, "{decodes} decodes: some procedures kept, not all"); +} + +/// Two fonts each with a glyph named `dot`, one filling 8 × 8 and the other 4 × 4, draw their own +/// procedures: what one font keeps isn't looked up by another with the same glyph name. +#[test] +fn fonts_with_the_same_glyph_name_keep_their_own_procedures() { + let (big, small) = (b"8 0 0 0 8 8 d1 0 0 8 8 re f".to_vec(), b"8 0 0 0 8 8 d1 0 0 4 4 re f".to_vec()); + let dict = |data: &[u8]| format!("<< /Length {} >>", data.len()); + let (big_dict, small_dict) = (dict(&big), dict(&small)); + let resources = format!("<< /Font << /A {} /B {} >> >>", font(&["dot".into()], 5), font(&["dot".into()], 6)); + let bytes = image_cache_pdf( + "BT /A 8 Tf 1 0 0 1 2 30 Tm (!!) Tj /B 8 Tf (!!) Tj ET", + &resources, + &[(big_dict.as_str(), big.as_slice()), (small_dict.as_str(), small.as_slice())], + ); + let (widths, _) = render(bytes); + assert_eq!(widths.len(), 4); + let (a, b) = (widths[0], widths[3]); + assert!((a - 2.0 * b).abs() < 0.01, "font A's glyph is twice as wide as font B's: {widths:?}"); + assert_eq!((widths[1], widths[2]), (a, b), "each font draws its own glyph again: {widths:?}"); +} diff --git a/crates/render/src/raster/tests/type3_proc_widths.rs b/crates/render/src/raster/tests/type3_proc_widths.rs new file mode 100644 index 0000000000..bf11775af9 --- /dev/null +++ b/crates/render/src/raster/tests/type3_proc_widths.rs @@ -0,0 +1,130 @@ +//! A Type 3 font without `/Widths` advances each glyph by the width its glyph procedure sets +//! with `d0` or `d1` (vendored hayro-interpret patch 90). ISO 32000-2 Table 110 requires +//! `/Widths` in a Type 3 font, and §9.6.4 has the procedure state the same width: "wx … shall +//! be consistent with the corresponding width in the font's Widths array". A font whose +//! `/Widths` key was misspelt (PDFBOX-2943) gave every glyph no advance, so its text was drawn +//! as one blot and read as characters stacked on one spot. PDFium takes the procedure's width +//! here. A font that has `/Widths` is unchanged: Table 110 gives a code outside `/FirstChar` to +//! `/LastChar` width 0, as Poppler and MuPDF draw it. +//! +//! The glyphs are squares 400 units wide (`a`, set by `d1`, advance 500) and 600 units wide +//! (`b`, set by `d0`, advance 700), shown at 100 pt from x = 20. + +use super::render_first_page; +use crate::RenderWarning; +use crate::text::extract_page; +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_interpret::hayro_syntax::Pdf; +use std::sync::Arc; + +/// A page showing `aabb` in a Type 3 font whose dictionary ends with `widths`. +fn type3_page(widths: &str) -> Vec { + type3_page_with(widths, "aabb", "700 0 d0 0 0 600 600 re f") +} + +/// A page showing `text` in a Type 3 font whose dictionary ends with `widths` and whose `b` +/// glyph is `glyph_b`. +fn type3_page_with(widths: &str, text: &str, glyph_b: &str) -> Vec { + let content = format!("BT /F1 100 Tf 20 100 Td ({text}) Tj ET"); + let glyph_a = "500 0 0 0 400 400 d1 0 0 400 400 re f"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << /a 6 0 R /b 7 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> {widths} >>" + ), + format!("<< /Length {} >>\nstream\n{glyph_a}\nendstream", glyph_a.len()), + format!("<< /Length {} >>\nstream\n{glyph_b}\nendstream", glyph_b.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n{object}\nendobj\n", i + 1).into_bytes()); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +/// The columns (start, end) of the rendered page that hold ink, as runs. +fn ink_runs(pdf: Vec) -> Vec<(u32, u32)> { + let page = render_first_page(pdf); + let inked = |x: u32| (0..page.height).any(|y| page.rgba.get(((y * page.width + x) * 4) as usize).is_some_and(|v| *v < 128)); + let mut runs = Vec::new(); + let mut start = None; + for x in 0..=page.width { + match (x < page.width && inked(x), start) { + (true, None) => start = Some(x), + (false, Some(s)) => { + runs.push((s, x)); + start = None; + } + _ => {} + } + } + runs +} + +/// The left edge of each extracted glyph, rounded to a point. +fn glyph_lefts(pdf: Vec) -> Vec { + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).expect("text"); + text.glyphs.iter().map(|g| g.rect[0].round() as i32).collect() +} + +#[test] +fn a_type3_font_without_widths_advances_by_its_glyph_procedures() { + // Each glyph in its own place: a at 20 and 70 (d1 width 500), b at 120 and 190 (d0 700). + for widths in ["", "/FirstChar 97 /LastChar 98 /WXdths [300 300]"] { + assert_eq!(ink_runs(type3_page(widths)), [(20, 60), (70, 110), (120, 180), (190, 250)], "{widths:?}"); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 70, 120, 190], "{widths:?}"); + } +} + +#[test] +fn a_code_outside_first_char_to_last_char_still_has_width_zero() { + // a takes /Widths (300); b, past /LastChar, has width 0 (Table 110), whatever d0 says. + let widths = "/FirstChar 97 /LastChar 97 /Widths [300]"; + assert_eq!(ink_runs(type3_page(widths)), [(20, 140)]); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 50, 80]); +} + +#[test] +fn widths_the_font_gives_are_kept() { + // /Widths says 300 for both, whatever the procedures say: the glyphs overlap, as written. + let widths = "/FirstChar 97 /LastChar 98 /Widths [300 300]"; + assert_eq!(ink_runs(type3_page(widths)), [(20, 170)]); + assert_eq!(glyph_lefts(type3_page(widths)), [20, 50, 80, 110]); + // An explicit 0 is an entry too: every glyph stays at x = 20 (text extraction keeps one + // of each glyph drawn again on the same spot). + let zero = "/FirstChar 97 /LastChar 98 /Widths [0 0]"; + assert_eq!(glyph_lefts(type3_page(zero)), [20, 20]); +} + +#[test] +fn a_code_without_a_usable_procedure_width_still_advances_nothing() { + // b's d0 has a name for its width and c has no procedure: neither advances, and neither + // panics. + assert_eq!(glyph_lefts(type3_page_with("", "bcb", "/x 0 d0 0 0 600 600 re f")), [20, 20]); +} + +#[test] +fn a_type3_font_without_widths_is_reported() { + // The page draws, and says once that a font's /Widths was missing; a font with /Widths + // (even one that leaves `b` out) says nothing. + for widths in ["", "/FirstChar 97 /LastChar 98 /WXdths [300 300]"] { + let page = render_first_page(type3_page(widths)); + assert_eq!(page.warnings, [RenderWarning::Type3WidthsMissing], "{widths:?}"); + } + for widths in ["/FirstChar 97 /LastChar 98 /Widths [300 300]", "/FirstChar 97 /LastChar 97 /Widths [300]"] { + assert!(render_first_page(type3_page(widths)).warnings.is_empty(), "{widths:?}"); + } +} diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index 6c14325400..a04e380788 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -458,9 +458,17 @@ impl<'a> Device<'a> for TextDevice { eprintln!("{kind} {text:?} transform={:?} glyph_transform={:?}", transform.as_coeffs(), glyph_transform.as_coeffs()); } let advance = match glyph { - Glyph::Outline(g) => { - g.advance_width().filter(|a| *a > 0.0).map(f64::from).unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)) - } + // A vertical font's advance runs down the column; across it, the glyph is as wide as + // its horizontal width w0, centred on the column by default (ISO 32000-2 §9.7.4.3). Taking + // the outline's width instead gave a column of Latin letters boxes of uneven widths, + // off its centre, and a narrow letter cut the line's gap allowance below the spacing + // of widely tracked text, so the column fell apart into lines. + Glyph::Outline(g) => g + .vertical_glyph_width() + .or_else(|| g.advance_width()) + .filter(|a| a.is_finite() && *a > 0.0) + .map(f64::from) + .unwrap_or_else(|| g.outline().bounding_box().width().max(500.0)), Glyph::Type3(g) => g.advance_width().filter(|a| a.is_finite() && *a > 0.0).map(f64::from).unwrap_or(600.0), }; // The baseline direction in view space (y down). diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 6012672680..3b06ec7f14 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -195,9 +195,10 @@ fn redraws_of_a_long_replacement_share_a_text_budget() { fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() { // Everyday content both ways: a string with a byte above 0x7F, and a later word holding // a capital "EI". The end-of-data look-ahead used to read the string's bytes as image - // data, so the image swallowed the rest of the page. + // data, so the image swallowed the rest of the page. The image is filtered, so its + // length isn't known up front and the look-ahead decides. let pdf = marked_pages(&[ - "BI /W 1 /H 1 /BPC 8 /CS /DeviceGray ID \0 EI BT /F1 20 Tf 1 0 0 1 20 200 Tm (Gr\u{fc}\u{df}e) Tj 1 0 0 1 20 150 Tm (EINGANG) Tj ET", + "BI /W 1 /H 1 /BPC 8 /CS /DeviceGray /F /AHx ID 00> EI BT /F1 20 Tf 1 0 0 1 20 200 Tm (Gr\u{fc}\u{df}e) Tj 1 0 0 1 20 150 Tm (EINGANG) Tj ET", ]); assert!(marked_text(&pdf, 0).plain_text().contains("EINGANG"), "the text after the inline image was not extracted"); @@ -663,10 +664,12 @@ fn the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space() { /// #3169, #3170: glyph names outside the Adobe Glyph List still read as text. Ligature /// (`f_i`) and suffixed (`hyphen.case`) names go by the glyph list specification's full -/// mapping. A name that only numbers the glyph (`c251` small caps, `C39`, `C174`, `C39.sc`) -/// reads as nothing, not as a guess from the code (review on #3239: with `/C1` at 233, -/// StandardEncoding's 233 would read `Ø` for a glyph drawn as `é`), and so do a ZapfDingbats -/// name (`a12`) and a name with no meaning left (`foo`). +/// mapping. A name that only numbers the glyph reads as nothing, not as a guess from the code +/// (review on #3239: with `/C1` at 233, StandardEncoding's 233 would read `Ø` for a glyph drawn +/// as `é`). `C39` at 39 and `C174` at 174 name their own codes, but this font's other numbered +/// names (`c251` at 97, `C39.sc` at 61, `C1` at 233, `a12` at 88) don't, so they read as +/// nothing too (see `numbered_glyph_names_read_as_their_own_code`). A name with no meaning +/// left (`foo`) reads as nothing. #[test] fn glyph_names_outside_the_glyph_list_still_read_as_text() { use lopdf::{Object, dictionary}; @@ -695,11 +698,145 @@ fn glyph_names_outside_the_glyph_list_still_read_as_text() { assert_eq!(lines, ["fi", "fl", "-", "fi"], "{text:?}"); } +/// A glyph name that only numbers its glyph, with no `/ToUnicode` (ISO 32000-2 §9.10.2 has no +/// route for it), reads as its code's Latin-1 character when the number is that very code, in +/// decimal (`C70` at 70, `C0071` at 71, `MT72` at 72, `C39` at 39, `C174` at 174) or +/// hexadecimal (`G49` at 0x49, `GE9` at 0xE9), as pdftotext, PDFium and MuPDF read +/// it: Windows printer drivers and typesetters name subset glyphs so. Only in a font where every +/// numbered name names its own code: one more name numbering another code (`/C1` at 233, review +/// on #3239) turns the whole font back to nothing, and so does a font whose names number +/// something else, where a name can match its code by coincidence (`/MT40` at 0x40). A subset +/// that counts its glyphs in order of first use from code 1 (`/Differences [1 /C1 /C2 … /C80]`) +/// names its own codes too, but `/C65` there is the 65th glyph used, not `A`: a numbered name at +/// a control code (`C1` at 1, `C9` at 9) gives the counter away and the font reads nothing. +/// Code 32 is the first code a numbered name may name: `C32` at 32 reads as a space. +#[test] +fn numbered_glyph_names_read_as_their_own_code() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let encoding = |differences: Vec| Object::Dictionary(dictionary! { "Type" => "Encoding", "Differences" => differences }); + // One glyph per line, as in `glyph_names_outside_the_glyph_list_still_read_as_text`. + let one_per_line = |codes: &[u8]| { + let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec(); + for code in codes { + content.extend_from_slice(format!(" <{code:02x}> Tj 0 -25 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + content + }; + let own = encoding(vec![ + Object::Integer(70), + name("C70"), + name("C0071"), + name("MT72"), + name("G49"), + Object::Integer(233), + name("GE9"), + Object::Integer(39), + name("C39"), + Object::Integer(174), + name("C174"), + ]); + let one_other = encoding(vec![Object::Integer(70), name("C70"), Object::Integer(233), name("C1")]); + let numbered_otherwise = encoding(vec![Object::Integer(1), name("C70"), name("C105"), Object::Integer(64), name("MT40")]); + let counter = encoding(std::iter::once(Object::Integer(1)).chain((1..=80).map(|n| name(&format!("C{n}")))).collect()); + let own_and_a_control = encoding(vec![Object::Integer(9), name("C9"), Object::Integer(70), name("C70"), name("C0071")]); + // Code 32 is the first code a numbered name may name: `/C32` at 32 is a space, not a counter. + let own_from_32 = encoding(vec![Object::Integer(32), name("C32"), Object::Integer(70), name("C70"), name("C0071")]); + let pages = [ + one_per_line(b"FGHI\xe9'\xae"), + one_per_line(b"F\xe9"), + one_per_line(b"\x01\x02@"), + one_per_line(b"\x01\x20\x41\x42\x50"), + one_per_line(b"\x09FG"), + b"BT /F1 20 Tf 20 290 Td <462047> Tj ET".to_vec(), + ]; + let pdf = encoded_pages(&[ + (own, &pages[0][..]), + (one_other, &pages[1][..]), + (numbered_otherwise, &pages[2][..]), + (counter, &pages[3][..]), + (own_and_a_control, &pages[4][..]), + (own_from_32, &pages[5][..]), + ]); + let lines = |page| { + let text = extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().plain_text(); + text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect::>() + }; + assert_eq!(lines(0), ["F", "G", "H", "I", "\u{e9}", "'", "\u{ae}"]); + assert!(lines(1).is_empty(), "/C1 at 233 beside /C70 at 70: {:?}", lines(1)); + assert!(lines(2).is_empty(), "/MT40 at 0x40 among names numbering other codes: {:?}", lines(2)); + assert!(lines(3).is_empty(), "a first-use counter /C1 … /C80 from code 1: {:?}", lines(3)); + assert!(lines(4).is_empty(), "/C9 at 9 beside /C70 at 70: {:?}", lines(4)); + let page5 = extract_page(&pdf, 5, &InterpreterSettings::default()).unwrap().plain_text(); + assert_eq!(page5.trim_matches('\n'), "F G", "/C32 at 32 beside /C70 at 70 reads as a space"); +} + +/// `numbered_glyph_names_read_as_their_own_code` in a Type 3 font, which already reads codes +/// 32-126 by the code: above 126, `C174` at 174, `C176` at 176 and `GE9` at 0xE9 read as the +/// code's Latin-1 character, unless one numbered name in the font numbers another code +/// (`/C1` at 233) or sits at a control code, as a first-use counter's `/C1` at 1 does. +#[test] +fn numbered_type3_glyph_names_read_as_their_own_code() { + use lopdf::{Document, Object, Stream, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + // Each font's /Differences as (code, glyph name) pairs, and the codes its page shows. + type Names<'a> = &'a [(i64, &'a str)]; + let fonts: [(Names, &[u8]); 3] = [ + (&[(70, "C70"), (174, "C174"), (176, "C176"), (233, "GE9")], b"F\xae\xb0\xe9"), + (&[(174, "C174"), (176, "C176"), (233, "C1")], b"\xae\xb0\xe9"), + (&[(1, "C1"), (2, "C2"), (174, "C174"), (176, "C176")], b"\xae\xb0"), + ]; + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut kids = Vec::new(); + for (names, codes) in fonts { + let glyph = doc.add_object(Stream::new(dictionary! {}, b"600 0 0 0 500 700 d1 0 0 500 700 re f".to_vec())); + let mut differences = Vec::new(); + let mut procs = lopdf::Dictionary::new(); + for &(code, glyph_name) in names { + differences.extend([Object::Integer(code), name(glyph_name)]); + procs.set(glyph_name, glyph); + } + let font = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "Type3", + "FontBBox" => vec![0.into(), 0.into(), 1000.into(), 1000.into()], + "FontMatrix" => vec![0.001.into(), 0.into(), 0.into(), 0.001.into(), 0.into(), 0.into()], + "CharProcs" => procs, + "Encoding" => dictionary! { "Type" => "Encoding", "Differences" => differences }, + "FirstChar" => 0, "LastChar" => 255, "Widths" => vec![Object::Integer(600); 256], + }); + // One glyph per line, as in `glyph_names_outside_the_glyph_list_still_read_as_text`. + let mut content = b"BT /T3 20 Tf 20 290 Td".to_vec(); + for code in codes { + content.extend_from_slice(format!(" <{code:02x}> Tj 0 -25 Td").as_bytes()); + } + content.extend_from_slice(b" ET"); + let content = doc.add_object(Stream::new(dictionary! {}, content)); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages_id, "MediaBox" => vec![0.into(), 0.into(), 300.into(), 300.into()], "Resources" => dictionary! {"Font" => dictionary! {"T3" => font}}, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages_id, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages_id}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let lines = |page| { + let text = extract_page(&pdf, page, &InterpreterSettings::default()).unwrap().plain_text(); + text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect::>() + }; + assert_eq!(lines(0), ["F", "\u{ae}", "\u{b0}", "\u{e9}"]); + assert!(lines(1).is_empty(), "/C1 at 233 beside /C174 at 174: {:?}", lines(1)); + assert!(lines(2).is_empty(), "a counter's /C1 at 1 beside /C174 at 174: {:?}", lines(2)); +} + /// Review on #3239: the glyph list specification's `uni` and `u` forms take uppercase hex /// digits only, and map a name with lowercase ones to nothing ("uni20ac" in its examples). /// Names upstream's own lookup can't read (several groups, a suffix) show it: lowercase reads -/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` is still read by -/// upstream's lenient `unicode_from_name`, outside this mapping. +/// as nothing, uppercase as its characters. A bare `uni20ac` or `u1040c` reads as nothing too +/// (`agl_unicode_from_name_rule_reads_text`). #[test] fn glyph_names_with_lowercase_hex_read_as_nothing() { use lopdf::{Object, dictionary}; @@ -719,6 +856,34 @@ fn glyph_names_with_lowercase_hex_read_as_nothing() { assert_eq!(lines, ["\u{20ac}\u{308}", "\u{1040c}", "f", "f\u{20ac}"], "{text:?}"); } +/// The glyph list specification's `uni` form is `uni` and groups of exactly four uppercase hex +/// digits, its `u` form `u` and four to six, outside the surrogate range: `uni65`, `u65`, +/// `uni20ac`, `u1f600`, `uni+065` and `uniD800` name no character, and their glyphs read as +/// nothing rather than as `e`, `€` or an emoji. The well-formed names read as before. +#[test] +fn agl_unicode_from_name_rule_reads_text() { + use lopdf::{Object, dictionary}; + let name = |n: &str| Object::Name(n.as_bytes().to_vec()); + let names = ["uni65", "u65", "uni20ac", "u1f600", "uni+065", "uniD800", "u10FFFF0", "uni0065", "u0066", "u1F600", "uni20AC"]; + let differences = Object::Dictionary(dictionary! { + "Type" => "Encoding", + "Differences" => std::iter::once(Object::Integer(65)).chain(names.iter().map(|n| name(n))).collect::>(), + }); + // One glyph per line, so each name's text stays its own (and a name read as nothing + // leaves no line). + let mut content = b"BT /F1 20 Tf 20 290 Td".to_vec(); + for code in (b'A'..).take(names.len()) { + content.extend_from_slice(b" ("); + content.push(code); + content.extend_from_slice(b") Tj 0 -25 Td"); + } + content.extend_from_slice(b" ET"); + let pdf = encoded_pages(&[(differences, &content[..])]); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text(); + let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty()).collect(); + assert_eq!(lines, ["e", "f", "\u{1f600}", "\u{20ac}"], "{text:?}"); +} + #[test] fn narrow_ligatures_keep_every_letter() { // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a @@ -770,6 +935,113 @@ fn vertical_ligatures_stay_in_their_column() { assert_eq!(text.line_rects(0..3).len(), 1, "one column"); } +/// A column of Latin capitals in a vertical font, tracked out with negative character spacing +/// (pdf.js issue 20930: `-40 Tc` puts 40 pt between 24 pt letters). Across the column each glyph +/// is as wide as its `/W` width w0 and centred on the column (ISO 32000-2 §9.7.4.3), so the +/// letters stay one line and Find matches the word. Boxes as wide as each outline made a narrow +/// letter's line allowance smaller than the tracking, and the column broke into lines. +#[test] +fn tracked_vertical_latin_stays_one_line() { + tracked_vertical_latin(-40.0, 100.0); +} + +#[test] +fn horizontally_scaled_vertical_latin_box_is_scaled_across_the_column() { + // Horizontal scaling Th = Tz / 100 scales the glyph's width w0 across the column (ISO 32000-2 + // §9.4.4, the text rendering matrix), and not the advance down it. The tracking is 20 pt here: + // the line's gap allowance is three times the narrower box across it, 3 × 8 pt at 50 Tz. + tracked_vertical_latin(-20.0, 50.0); +} + +/// `ABCDE` down one Identity-V column at 24 pt with `tc Tc` and `tz Tz`. +fn tracked_vertical_latin(tc: f32, tz: f32) { + let text = vertical_latin( + &[(36, 667), (37, 667), (38, 722), (39, 722), (40, 667)], + None, + &format!("BT /F1 24 Tf {tc} Tc {tz} Tz 1 0 0 1 100 340 Tm <00240025002600270028> Tj ET"), + ); + assert_eq!(text.plain_text(), "ABCDE"); + assert_eq!(text.find("ABCDE"), vec![0..5]); + // Each box spans w0 (here 24 pt × 0.667 or 0.722, times Th) centred on the column at x = 100. + assert_eq!(text.glyphs.len(), 5); + for (g, w0) in text.glyphs.iter().zip([667.0, 667.0, 722.0, 722.0, 667.0]) { + let [x0, _, x1, _] = g.rect; + assert!((x1 - x0 - 24.0 * w0 / 1000.0 * tz / 100.0).abs() < 0.01, "{:?} is w0 wide: {:?}", g.text, g.rect); + assert!(((x0 + x1) / 2.0 - 100.0).abs() < 0.01, "{:?} is centred on the column: {:?}", g.text, g.rect); + } +} + +/// The text of a page showing `content` in a vertical CIDFontType2 `/F1` with `/DW 1000`, the +/// `widths` (CID, w0) in `/W`, and CIDs 36 to 41 reading `A` to `F`. `cmap` is the body of an +/// embedded vertical CMap mapping codes to CIDs (`begincidchar` … `endcidchar`); `None` uses +/// Identity-V. +fn vertical_latin(widths: &[(i64, i64)], cmap: Option<&str>, content: &str) -> crate::text::PageText { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let to_unicode = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0024> <0041> <0025> <0042> <0026> <0043> <0027> <0044> <0028> <0045> <0029> <0046> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let unicode = doc.add_object(Stream::new(dictionary! {}, to_unicode.to_vec())); + let widths: Vec = widths.iter().flat_map(|&(cid, w)| [Object::Integer(cid), Object::Array(vec![Object::Integer(w)])]).collect(); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestVerticalLatin", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 1000, "W" => widths, "DW2" => vec![Object::Integer(880), Object::Integer(-1000)], "CIDToGIDMap" => "Identity" + }); + let encoding = match cmap { + Some(body) => { + let stream = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> def /CMapName /TestCodes-V def /CMapType 1 def /WMode 1 def 1 begincodespacerange <0000> endcodespacerange {body} endcmap CMapName currentdict /CMap defineresource pop end end" + ); + Object::Reference(doc.add_object(Stream::new( + dictionary! { "Type" => "CMap", "CMapName" => "TestCodes-V", "WMode" => 1, "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 } }, + stream.into_bytes(), + ))) + } + None => Object::Name(b"Identity-V".to_vec()), + }; + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVerticalLatin", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let content = doc.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(500)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap() +} + +/// A CID that `/W` doesn't list is `/DW` wide across the column (ISO 32000-2 §9.7.4.3), and the +/// widths are looked up by CID, not by character code: an embedded vertical CMap maps the codes +/// `<0024>`… (which `/W` lists 500 wide as CIDs) to CIDs 50…. The tracked column stays one line +/// that Find matches. +#[test] +fn vertical_latin_widths_come_from_dw_and_are_looked_up_by_cid() { + let cmap = "5 begincidchar <0024> 50 <0025> 51 <0026> 52 <0027> 53 <0028> 54 endcidchar"; + // CID 52 (`C`) has no /W entry: it is /DW = 1000 wide. + let widths = [(36, 500), (37, 500), (38, 500), (39, 500), (40, 500), (50, 667), (51, 667), (53, 722), (54, 667)]; + let text = vertical_latin(&widths, Some(cmap), "BT /F1 24 Tf -40 Tc 1 0 0 1 100 340 Tm <00240025002600270028> Tj ET"); + assert_eq!(text.plain_text(), "ABCDE"); + assert_eq!(text.find("ABCDE"), vec![0..5]); + let got: Vec = text.glyphs.iter().map(|g| g.rect[2] - g.rect[0]).collect(); + assert_eq!(got.len(), 5); + for (w, w0) in got.iter().zip([667.0, 667.0, 1000.0, 722.0, 667.0]) { + assert!((w - 24.0 * w0 / 1000.0).abs() < 0.01, "{got:?}"); + } +} + +/// Two vertical columns of `/DW`-wide letters set 13.5 pt apart at 20 pt overlap across the +/// column (each box is 20 pt wide), and still read as two lines, in order, with Find matching +/// each column and not a word spanning both. +#[test] +fn close_vertical_columns_of_dw_wide_letters_stay_apart() { + let text = vertical_latin(&[], None, "BT /F1 20 Tf 1 0 0 1 113.5 340 Tm <002400250026> Tj 1 0 0 1 100 340 Tm <002700280029> Tj ET"); + assert_eq!(text.plain_text().lines().map(str::trim).collect::>(), ["ABC", "DEF"]); + assert_eq!(text.find("ABC").len(), 1); + assert_eq!(text.find("DEF").len(), 1); + assert!(text.find("CD").is_empty() && text.find("ABCDEF").is_empty(), "{:?}", text.plain_text()); +} + #[test] fn upright_mapping_round_trips() { let r = [10.0, 20.0, 30.0, 25.0]; @@ -2175,6 +2447,161 @@ fn text_outside_the_crop_box_is_clipped() { } } +/// A one-page document showing `codes` in a Type 0 font with `encoding` over an embedded +/// `descendant` (`CIDFontType0` takes the bundled CFF program, `CIDFontType2` a TrueType one) +/// of the Adobe-Japan1 collection, with `to_unicode` as its `/ToUnicode` CMap when given. +fn japan1_page(encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String { + collection_page(("Adobe", "Japan1"), encoding, descendant, to_unicode, codes) +} + +/// [`japan1_page`] for the character collection `(registry, ordering)`. +fn collection_page(collection: (&str, &str), encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let (registry, ordering) = collection; + let mut doc = Document::with_version("1.7"); + let (key, program): (&str, &[u8]) = if descendant == "CIDFontType0" { + ("FontFile3", include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb")) + } else { + ("FontFile2", include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")) + }; + let mut file = dictionary! {}; + if descendant == "CIDFontType0" { + file.set("Subtype", "CIDFontType0C"); + } + let program = doc.add_object(Stream::new(file, program.to_vec())); + let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "TestJapan1", "Flags" => 4, key => program }); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => descendant, "BaseFont" => "TestJapan1", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal(registry), "Ordering" => Object::string_literal(ordering), "Supplement" => 6 }, + "FontDescriptor" => descriptor, "DW" => 1000, "CIDToGIDMap" => "Identity" + }); + let mut font = dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestJapan1", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)] }; + if let Some(cmap) = to_unicode { + font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec()))); + } + let font = doc.add_object(font); + let content = doc.add_object(Stream::new(dictionary! {}, format!("BT /F1 20 Tf 1 0 0 1 20 250 Tm <{codes}> Tj ET").into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() +} + +/// An embedded font of an Adobe character collection reads as text through the collection's +/// UCS2 CMap, code to CID to Unicode (ISO 32000-2 §9.10.2), where its `/ToUnicode` is missing or +/// has nothing for a code: with Identity-H or a predefined CMap such as `/H`, CFF or TrueType. +/// Only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts +/// are embedded without a `/ToUnicode` had no text to select, find or copy. `/ToUnicode` still +/// comes first where it maps a code. +#[test] +fn an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap() { + // Adobe-Japan1 CIDs 34, 1125 and 843: A, 亜 and あ. + assert_eq!(japan1_page("Identity-H", "CIDFontType0", None, "00220465034B"), "A亜あ", "Identity-H, CFF, no /ToUnicode"); + assert_eq!(japan1_page("Identity-H", "CIDFontType2", None, "00220465034B"), "A亜あ", "Identity-H, TrueType, no /ToUnicode"); + // `/H` maps JIS X 0208 codes to CIDs: 3021 亜, 2422 あ, 2341 A. + assert_eq!(japan1_page("H", "CIDFontType2", None, "302124222341"), "亜あA", "predefined /H"); + let empty = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Empty def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(japan1_page("Identity-H", "CIDFontType2", Some(empty), "00220465034B"), "A亜あ", "a /ToUnicode with no mappings"); + let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /One def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 1 beginbfchar <0022> <005A> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(japan1_page("Identity-H", "CIDFontType0", Some(one), "00220465034B"), "Z亜あ", "/ToUnicode first, the UCS2 CMap for the rest"); +} + +/// The same route for the other Adobe collections, each through its own UCS2 CMap +/// (Adobe-GB1-UCS2, Adobe-CNS1-UCS2, Adobe-Korea1-UCS2), and for vertical writing (Identity-V) +/// and a predefined CMap over a CFF program. The pairs are read from the bundled CMaps. +#[test] +fn every_adobe_collection_reads_through_its_own_ucs2_cmap() { + for descendant in ["CIDFontType0", "CIDFontType2"] { + // Adobe-GB1 CID 4000 is 姓, Adobe-CNS1 CID 4000 is 團, Adobe-Korea1 CID 2000 is 밧; CID 34 + // is A in each. + for (ordering, codes, text) in [("GB1", "00220FA0", "A姓"), ("CNS1", "00220FA0", "A團"), ("Korea1", "002207D0", "A밧")] { + assert_eq!(collection_page(("Adobe", ordering), "Identity-H", descendant, None, codes), text, "Adobe-{ordering}, {descendant}"); + } + assert_eq!(japan1_page("Identity-V", descendant, None, "00220465034B"), "A亜あ", "Identity-V, {descendant}"); + } + assert_eq!(japan1_page("H", "CIDFontType0", None, "302124222341"), "亜あA", "predefined /H, CFF"); +} + +/// A Type 0 font outside the Adobe collections has no UCS2 CMap: without a `/ToUnicode` its codes +/// give no text through this route (the CIDs mean nothing outside their collection). +#[test] +fn a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap() { + for descendant in ["CIDFontType0", "CIDFontType2"] { + assert_eq!(collection_page(("Example", "Custom"), "Identity-H", descendant, None, "00220465034B"), "", "{descendant}"); + } +} + +/// A Type 0 font whose `/ToUnicode` is written as a CID CMap (`begincidchar`, destinations as +/// numbers, as Word writes it) reads each number as the code's Unicode value (vendored +/// hayro-interpret patch 85), as glyph lookup already did and as pdftotext and PDFium extract it. +/// Read as a bfchar map only, such text extracted nothing (pdf.js `issue9367`). +#[test] +fn a_tounicode_written_as_cid_ranges_gives_text() { + let two = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 begincidchar <0001> 84 <0002> 101 <0004> 116 endcidchar 1 begincidrange <0003> <0003> 120 endcidrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(two), "Text", "two-byte entries"); + // Entries written with four-byte codes are found too (review on #6359). + let four = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <00000000> endcodespacerange 2 begincidchar <00000001> 84 <00000002> 101 endcidchar 1 begincidrange <00000003> <00000004> 120 endcidrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(four), "Texy", "four-byte entries"); +} + +/// When a `/ToUnicode` gives one code both a bf entry and a CID entry, the bf entry is the +/// code's text (ISO 32000-2 §9.10.3 defines `/ToUnicode` by `beginbfchar` / `beginbfrange`); the +/// CID number is read only for codes no bf entry covers (vendored hayro-interpret patch 85). +#[test] +fn a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code() { + // Codes 1 and 3 have both kinds of entry: bf says `T` / `x`, CID says `X` / `Y`. Codes 2 and 4 + // have CID entries only. + let two = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 begincidchar <0001> 88 <0002> 101 <0004> 116 endcidchar 1 begincidrange <0003> <0003> 89 endcidrange 1 beginbfchar <0001> <0054> endbfchar 1 beginbfrange <0003> <0003> <0078> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(two), "Text", "two-byte entries"); + // The same with one-byte entries. For two-byte entries the CID lookup, tried at one byte + // first, already falls back to the bf entries at that length; one-byte entries are where + // reading CID entries before bf entries would show, as `XeYt`. + let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Words def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 3 begincidchar <01> 88 <02> 101 <04> 116 endcidchar 1 begincidrange <03> <03> 89 endcidrange 1 beginbfchar <01> <0054> endbfchar 1 beginbfrange <03> <03> <0078> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end"; + assert_eq!(cid_range_to_unicode_text(one), "Text", "one-byte entries"); +} + +/// The page [`a_tounicode_written_as_cid_ranges_gives_text`] and +/// [`a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code`] read: four codes in a Type 0 font +/// (Inter, Identity-H) whose `/ToUnicode` is `cmap`. +fn cid_range_to_unicode_text(cmap: &[u8]) -> String { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let program = doc.add_object(Stream::new(dictionary! {}, include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec())); + let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "Inter-Regular", "Flags" => 4, "FontFile2" => program }); + let cid_font = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "CIDFontType2", + "BaseFont" => "Inter-Regular", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "FontDescriptor" => descriptor, + "CIDToGIDMap" => "Identity", + }); + let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())); + let font = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type0", + "BaseFont" => "Inter-Regular", + "Encoding" => "Identity-H", + "DescendantFonts" => vec![Object::Reference(cid_font)], + "ToUnicode" => to_unicode, + }); + let content = doc.add_object(Stream::new(dictionary! {}, b"BT /F1 20 Tf 20 200 Td <0001000200030004> Tj ET".to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text().trim().to_string() +} + /// A page box that encloses no area is read as a missing one (vendored hayro-syntax patch 38, /// ISO 32000-2 §14.11.2): a `/MediaBox [0 0 0 0]` as the default page, a `/CropBox [0 0 0 0]`, /// or one with nothing in common with the media box, as the media box. The page's visible region diff --git a/crates/render/tests/it/cid_cff_unusable_font_dict.rs b/crates/render/tests/it/cid_cff_unusable_font_dict.rs new file mode 100644 index 0000000000..9b40b5277c --- /dev/null +++ b/crates/render/tests/it/cid_cff_unusable_font_dict.rs @@ -0,0 +1,167 @@ +//! A CID-keyed CFF font (`FontFile3` of subtype `CIDFontType0C`, ISO 32000-2 §9.9 Table 124) +//! selects a font dict for each glyph through its FDSelect, and a glyph's charstring needs the +//! Private DICT of that font dict (Adobe TN #5176). A subsetter can empty the font dicts no kept +//! glyph uses, leaving them without a Private DICT. Those font dicts make only their own glyphs +//! missing; the glyphs of the other font dicts still draw (hayro-interpret patch (93)). +//! +//! The font is built here: `.notdef` and two 500-unit squares, CID 1 in the first font dict and +//! CID 2 in the second, which is empty. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A CFF INDEX with one-byte offsets. +fn index(items: &[&[u8]]) -> Vec { + let mut out = u16::try_from(items.len()).unwrap().to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(1); + let mut offset = 1u8; + out.push(offset); + for item in items { + offset += u8::try_from(item.len()).unwrap(); + out.push(offset); + } + for item in items { + out.extend_from_slice(item); + } + out +} + +/// A DICT integer operand in the 5-byte form, so every DICT has a fixed size. +fn int(value: usize) -> Vec { + let mut out = vec![29]; + out.extend_from_slice(&u32::try_from(value).unwrap().to_be_bytes()); + out +} + +fn top_dict(charset: usize, fd_select: usize, char_strings: usize, fd_array: usize) -> Vec { + let mut dict = Vec::new(); + // ROS comes first in a CID font's Top DICT: (Adobe) (Identity) 0. + for operand in [391, 392, 0] { + dict.extend(int(operand)); + } + dict.extend([12, 30]); + dict.extend(int(charset)); + dict.push(15); + dict.extend(int(char_strings)); + dict.push(17); + dict.extend(int(fd_array)); + dict.extend([12, 36]); + dict.extend(int(fd_select)); + dict.extend([12, 37]); + dict +} + +fn font_dict(private: Option<(usize, usize)>) -> Vec { + let mut dict = Vec::new(); + if let Some((size, offset)) = private { + dict.extend(int(size)); + dict.extend(int(offset)); + dict.push(18); + } + dict +} + +/// The CID-keyed CFF program. The second font dict is always empty; the first has a Private +/// DICT only when `first_has_private`. +fn cid_cff(first_has_private: bool) -> Vec { + let header = vec![1, 0, 4, 1]; + let names = index(&[b"T"]); + let strings = index(&[b"Adobe", b"Identity"]); + let global_subrs = index(&[]); + // Format 0: the CIDs of glyphs 1 and 2. + let charset = vec![0, 0, 1, 0, 2]; + // Format 0: the font dict of each glyph. + let fd_select = vec![0, 0, 0, 1]; + // 0 0 rmoveto 500 0 rlineto 0 500 rlineto -500 0 rlineto endchar. + let square: &[u8] = &[139, 139, 21, 28, 1, 244, 139, 5, 139, 28, 1, 244, 5, 28, 254, 12, 139, 5, 14]; + let char_strings = index(&[&[14], square, square]); + // defaultWidthX 0. + let mut private = int(0); + private.push(20); + + let top_len = index(&[&top_dict(0, 0, 0, 0)]).len(); + let charset_at = header.len() + names.len() + top_len + strings.len() + global_subrs.len(); + let fd_select_at = charset_at + charset.len(); + let char_strings_at = fd_select_at + fd_select.len(); + let fd_array_at = char_strings_at + char_strings.len(); + let first_len = font_dict(first_has_private.then_some((0, 0))).len(); + let private_at = fd_array_at + index(&[&vec![0; first_len], &[]]).len(); + let first = font_dict(first_has_private.then_some((private.len(), private_at))); + let fd_array = index(&[&first, &[]]); + let top = index(&[&top_dict(charset_at, fd_select_at, char_strings_at, fd_array_at)]); + + let mut out = header; + for part in [names, top, strings, global_subrs, charset, fd_select, char_strings, fd_array, private] { + out.extend(part); + } + out +} + +/// A page showing the hex string `codes` at 60 pt in an Identity-H Type0 font over `program` +/// (no `/FontFile3` when `program` is empty). +fn page(program: &[u8], codes: &str) -> Vec { + let font_file = if program.is_empty() { "" } else { " /FontFile3 8 0 R" }; + let body = format!("BT /F1 60 Tf 20 30 Td <{codes}> Tj ET"); + let mut stream = format!("<< /Subtype /CIDFontType0C /Length {} >>\nstream\n", program.len()).into_bytes(); + stream.extend_from_slice(program); + stream.extend_from_slice(b"\nendstream"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEF+Synthetic /Encoding /Identity-H /DescendantFonts [6 0 R] >>".to_vec(), + b"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /ABCDEF+Synthetic /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 1000 >>".to_vec(), + format!("<< /Type /FontDescriptor /FontName /ABCDEF+Synthetic /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80{font_file} >>").into_bytes(), + stream, + ]; + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +#[test] +fn glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one() { + let first = pixels(page(&cid_cff(true), "0001")); + // The square is 30 pt on a side at 60 pt: 900 pixels at scale 1. + assert!(inked(&first) >= 800, "the glyph of the usable font dict drew {} pixels", inked(&first)); + let both = pixels(page(&cid_cff(true), "00010002")); + assert!(both == first, "the glyph of the empty font dict drew something, or the first did not draw"); +} + +/// With no usable font dict the program is not read, as before, and the page still renders. +#[test] +fn a_program_with_no_usable_font_dict_draws_no_square() { + let rgba = pixels(page(&cid_cff(false), "00010002")); + let square = pixels(page(&cid_cff(true), "0001")); + assert!(rgba != square, "a font dict without a Private DICT drew its glyph"); + // The page renders exactly as it does with no embedded program at all. + assert!(rgba == pixels(page(&[], "00010002")), "the program was kept although no font dict is usable"); +} diff --git a/crates/render/tests/it/fill_stroke_knockout.rs b/crates/render/tests/it/fill_stroke_knockout.rs new file mode 100644 index 0000000000..7c65dd81d4 --- /dev/null +++ b/crates/render/tests/it/fill_stroke_knockout.rs @@ -0,0 +1,166 @@ +//! Contributor-original synthetic pages for combined fill-and-stroke operators (`B`, `B*`, `b`, +//! `b*`) and text rendering modes 2 and 6 (`FillStroke`, `FillAndStrokeAndClip`) under +//! transparency, blend modes, and soft masks (ISO 32000-2 §11.7.4.4). + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +fn pdf_with_content(content: &str) -> Vec { + let mask_stream = b"0.5 g 0 0 200 200 re f"; + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] \ + /Group << /S /Transparency /CS /DeviceRGB >> \ + /Contents 4 0 R \ + /Resources << \ + /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >> \ + /ExtGState << \ + /GS50 << /Type /ExtGState /ca 0.5 /CA 0.5 >> \ + /GSStroke50 << /Type /ExtGState /ca 1.0 /CA 0.5 >> \ + /GSMul << /Type /ExtGState /BM /Multiply /ca 1.0 /CA 1.0 >> \ + /GSMask << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 5 0 R >> >> \ + >> \ + >> >>" + .to_vec(), + stream(b"", content.as_bytes()), + stream( + b"/Type /XObject /Subtype /Form /BBox [0 0 200 200] \ + /Group << /S /Transparency /CS /DeviceGray /I true >> ", + mask_stream, + ), + ]; + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(object); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict_entries: &[u8], body: &[u8]) -> Vec { + let mut s = b"<< ".to_vec(); + s.extend_from_slice(dict_entries); + s.extend_from_slice(format!("/Length {} >>\nstream\n", body.len()).as_bytes()); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +struct Page(Vec); + +impl Page { + fn render(content: &str) -> Self { + let mut renderer = PageRenderer::new(Arc::new(pdf_with_content(content)), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{content}: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 200), "{content}"); + Self(page.rgba.to_vec()) + } + + fn at(&self, x: usize, y: usize) -> [u8; 3] { + let i = ((199 - y) * 200 + x) * 4; + match self.0.get(i..i + 3) { + Some([r, g, b]) => [*r, *g, *b], + _ => panic!("pixel ({x}, {y}) is out of bounds"), + } + } + + fn assert_close(&self, x: usize, y: usize, want: [u8; 3], label: &str) { + let got = self.at(x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{label} at ({x}, {y}): got {got:?}, expected {want:?}"); + } +} + +#[test] +fn fill_stroke_path_knocks_out_fill_under_stroke() { + // 50% red fill (`ca = 0.5`) and 50% blue 20 pt stroke (`CA = 0.5`) on a white page. + // The stroke spans x = 30..50 along the left edge of the 40..160 square: + // - outer half of stroke at (35, 100) is over white -> [128, 128, 255] + // - inner half of stroke at (45, 100) knocks out the fill -> [128, 128, 255] + // - interior fill at (100, 100) is 50% red over white -> [255, 128, 128] + for op in ["40 40 120 120 re B", "40 40 120 120 re B*", "40 40 m 160 40 l 160 160 l 40 160 l b", "40 40 m 160 40 l 160 160 l 40 160 l b*"] { + let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w {op} Q")); + page.assert_close(100, 100, [255, 128, 128], &format!("{op} fill center")); + page.assert_close(35, 100, [128, 128, 255], &format!("{op} outer stroke")); + page.assert_close(45, 100, [128, 128, 255], &format!("{op} inner stroke")); + } + + // Even when fill alpha is 1.0 (`ca = 1.0`) and only stroke alpha is 0.5 (`CA = 0.5`), + // the semi-transparent stroke knocks out the opaque fill beneath it and composites + // directly with the white backdrop. + let page = Page::render("q /GSStroke50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q"); + page.assert_close(100, 100, [255, 0, 0], "opaque fill center"); + page.assert_close(35, 100, [128, 128, 255], "CA=0.5 outer stroke"); + page.assert_close(45, 100, [128, 128, 255], "CA=0.5 inner stroke knocks out opaque fill"); + + // Control: separate `f` followed by `S` are two distinct graphics objects, so the stroke + // composites over the already-painted fill: 0.5 * (0, 0, 255) + 0.5 * (255, 128, 128) = (128, 64, 191). + let separate = Page::render("q /GS50 gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re f 40 40 120 120 re S Q"); + separate.assert_close(35, 100, [128, 128, 255], "separate outer stroke"); + separate.assert_close(45, 100, [128, 64, 191], "separate inner stroke over fill"); +} + +#[test] +fn fill_stroke_path_applies_blend_mode_and_soft_mask_once() { + // Under `/BM /Multiply` on a (0.8, 0.8, 0.5) backdrop, `B` knocks out the yellow fill + // (1.0, 0.8, 0.0) under the cyan stroke (0.0, 0.8, 1.0) before multiplying with the backdrop: + // - fill center: (1.0 * 0.8, 0.8 * 0.8, 0.0 * 0.5) = [204, 163, 0] + // - both halves of the stroke: (0.0 * 0.8, 0.8 * 0.8, 1.0 * 0.5) = [0, 163, 128] + let mul = Page::render( + "0.8 0.8 0.5 rg 0 0 200 200 re f \ + q /GSMul gs 1 0.8 0 rg 0 0.8 1 RG 20 w 40 40 120 120 re B Q", + ); + mul.assert_close(100, 100, [204, 163, 0], "Multiply fill center"); + mul.assert_close(35, 100, [0, 163, 128], "Multiply outer stroke"); + mul.assert_close(45, 100, [0, 163, 128], "Multiply inner stroke"); + + // Under a 50% luminosity `/SMask`, `B` masks the combined knockout group once: + // - fill center: 50% red over white = [255, 128, 128] + // - both halves of the stroke: 50% blue over white = [128, 128, 255] + let masked = Page::render("q /GSMask gs 1 0 0 rg 0 0 1 RG 20 w 40 40 120 120 re B Q"); + masked.assert_close(100, 100, [255, 128, 128], "SMask fill center"); + masked.assert_close(35, 100, [128, 128, 255], "SMask outer stroke"); + masked.assert_close(45, 100, [128, 128, 255], "SMask inner stroke"); +} + +#[test] +fn text_rendering_modes_2_and_6_knock_out_fill_under_stroke() { + for mode in [2, 6] { + let page = Page::render(&format!("q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td {mode} Tr (I) Tj ET Q")); + // Both the 50% red fill ([255, 128, 128]) and the 50% blue stroke ([128, 128, 255]) over + // white have G >= 128. With knockout, every pixel (fill, inner stroke, outer stroke, and + // anti-aliased edges) stays at G >= 126; without knockout, the inner half of the stroke + // composites over the fill to [128, 64, 191] (G = 64). + let mut stroke_pixels = 0usize; + let mut fill_pixels = 0usize; + for &[r, g, b, _] in page.0.as_chunks::<4>().0 { + assert!(g >= 126, "mode {mode} Tr pixel [{r}, {g}, {b}] composited stroke over fill"); + if b >= 253 && r <= 130 { + stroke_pixels += 1; + } else if r >= 253 && b <= 130 { + fill_pixels += 1; + } + } + assert!(stroke_pixels > 200, "mode {mode} Tr should paint stroke pixels"); + assert!(fill_pixels > 100, "mode {mode} Tr should paint interior fill pixels"); + } + + // Mode 6 (`FillAndStrokeAndClip`) also clips subsequent drawing after `ET` to the glyph outline. + let clipped = Page::render( + "q /GS50 gs 1 0 0 rg 0 0 1 RG 12 w BT /F1 120 Tf 30 50 Td 6 Tr (I) Tj ET \ + 1.0 ca 0 1 0 rg 0 0 200 200 re f Q", + ); + // Outside the glyph (10, 10), the green rectangle is clipped away. + clipped.assert_close(10, 10, [255, 255, 255], "outside 6 Tr glyph clip"); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 591fc79b3e..1a50693fb2 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -1,6 +1,8 @@ //! The crate's integration tests, linked into one test binary. +mod cid_cff_unusable_font_dict; mod cid_fallback_query; +mod fill_stroke_knockout; mod inline_image_ei; mod jbig2_invariants; mod knockout_backdrop; @@ -15,5 +17,7 @@ mod stray_q_in_a_form; mod stroke_degenerate_cubic; mod stroke_zero_end_tangent; mod substitute_widths; +mod symbol_mt_builtin_encoding; mod tiling_cell_placement; mod type3_text_clip; +mod widgets_above_annotations; diff --git a/crates/render/tests/it/symbol_mt_builtin_encoding.rs b/crates/render/tests/it/symbol_mt_builtin_encoding.rs new file mode 100644 index 0000000000..fd9d0d28ca --- /dev/null +++ b/crates/render/tests/it/symbol_mt_builtin_encoding.rs @@ -0,0 +1,113 @@ +//! A non-embedded font named `SymbolMT` (the name Symbol has as a TrueType font) or a style +//! variant such as `Symbol,Bold` is the standard Symbol font, and a non-embedded Symbol draws +//! through its built-in encoding (ISO 32000-2 §9.6.2.2), even when the font dictionary +//! declares `/WinAnsiEncoding` and the nonsymbolic flag: code 0x6D (`m`) is `mu`, as for +//! `/Symbol` itself (hayro-interpret patch (98)). + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A page showing "abmp" at 40 pt in a non-embedded font `font` (the inside of its dictionary). +fn page(font: &str) -> Vec { + page_with(font, None) +} + +/// [`page`] with `program` as object 6, a `/FontFile2` stream the font descriptor can point at. +fn page_with(font: &str, program: Option<&[u8]>) -> Vec { + let body = "BT /F1 40 Tf 10 30 Td (abmp) Tj ET"; + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + format!("<< /Type /Font {font} >>").into_bytes(), + ]; + if let Some(program) = program { + objects.push([format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program.to_vec(), b"\nendstream".to_vec()].concat()); + } + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +/// A TrueType `name` with WinAnsiEncoding, nonsymbolic flags and no font program, as written +/// by spreadsheet exports. +fn win_ansi_true_type(name: &str) -> String { + format!( + "/Subtype /TrueType /BaseFont /{name} /Encoding /WinAnsiEncoding /FontDescriptor << /Type /FontDescriptor /FontName /{name} /Flags 32 /FontBBox [0 -216 1113 693] /ItalicAngle 0 /Ascent 1005 /Descent -216 /CapHeight 693 /StemV 60 >>" + ) +} + +#[test] +fn symbol_mt_draws_symbols_through_the_built_in_encoding() { + let symbol = pixels(page("/Subtype /Type1 /BaseFont /Symbol")); + let latin = pixels(page("/Subtype /Type1 /BaseFont /Helvetica")); + assert!(symbol != latin, "the Symbol stand-in drew the Latin letters"); + for name in ["SymbolMT", "ABCDEF+SymbolMT", "SymbolMT,Bold", "Symbol,Italic", "Symbol-Bold"] { + assert!(pixels(page(&win_ansi_true_type(name))) == symbol, "{name} did not draw as Symbol"); + } + let type1 = "/Subtype /Type1 /BaseFont /SymbolMT /Encoding /WinAnsiEncoding"; + assert!(pixels(page(type1)) == symbol, "a Type1 SymbolMT did not draw as Symbol"); +} + +/// The number of dark pixels on a page. +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +/// Only the Symbol name itself, alone or with a style, counts: a font whose name merely starts +/// with it, or names another font after the separator, keeps its declared encoding and its +/// Latin stand-in, drawing the same letters as Helvetica. +#[test] +fn a_name_that_only_starts_with_symbol_is_not_symbol() { + let latin = pixels(page("/Subtype /Type1 /BaseFont /Helvetica")); + assert!(inked(&latin) > 100, "the Latin reference drew no text"); + for name in ["Symbolic", "SymbolPS", "Symbol-Helvetica", "SymbolMT,Helvetica"] { + assert!(pixels(page(&win_ansi_true_type(name))) == latin, "{name} did not draw the Latin letters"); + } +} + +/// A `/Differences` array still applies to a SymbolMT: code 0x6D named `alpha` draws alpha, as +/// for `/Symbol` with the same differences, not the built-in encoding's `mu`. +#[test] +fn symbol_mt_applies_its_differences() { + let symbol = pixels(page("/Subtype /Type1 /BaseFont /Symbol")); + let alpha = pixels(page("/Subtype /Type1 /BaseFont /Symbol /Encoding << /Differences [109 /alpha] >>")); + assert!(alpha != symbol, "the differences changed nothing"); + let font = win_ansi_true_type("SymbolMT") + .replace("/Encoding /WinAnsiEncoding", "/Encoding << /BaseEncoding /WinAnsiEncoding /Differences [109 /alpha] >>"); + assert!(pixels(page(&font)) == alpha, "SymbolMT did not draw the alpha its differences name"); +} + +/// An embedded font named SymbolMT draws its own program, not the Symbol stand-in. +#[test] +fn an_embedded_symbol_mt_draws_its_own_program() { + let program = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let embedded = |name: &str| { + let font = win_ansi_true_type(name).replace(" >>", " /FontFile2 6 0 R >>"); + pixels(page_with(&font, Some(program))) + }; + let own = embedded("ABCDEF+JetBrainsMono"); + assert!(inked(&own) > 100, "the embedded program drew no text"); + assert!(embedded("ABCDEF+SymbolMT") == own, "an embedded SymbolMT did not draw its own glyphs"); +} diff --git a/crates/render/tests/it/widgets_above_annotations.rs b/crates/render/tests/it/widgets_above_annotations.rs new file mode 100644 index 0000000000..7800fa05cf --- /dev/null +++ b/crates/render/tests/it/widgets_above_annotations.rs @@ -0,0 +1,74 @@ +//! A contributor-original synthetic page: a check box widget listed before a filled square +//! comment that covers it. Form fields' widgets are drawn after the page's other annotations +//! (hayro-interpret patch (86)), so the check box shows; the comment covered it before. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A 200 × 200 pt page whose `/Annots` lists `order` of: `W`, a check box drawing a green box, +/// `S`, a square comment drawing a black one over it, and `R`, a larger red square comment. +fn pdf(order: &str) -> Vec { + let annot = |c: char| match c { + 'W' => "6 0 R", + 'S' => "7 0 R", + _ => "8 0 R", + }; + let annots: Vec<&str> = order.chars().map(annot).collect(); + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{}] >>", annots.join(" ")), + form("0 1 0"), + form("0 0 0"), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (box) /F 4 /Rect [50 50 150 150] /AS /Off /AP << /N << /Off 4 0 R /Yes 4 0 R >> >> >>" + .to_string(), + "<< /Type /Annot /Subtype /Square /F 4 /C [0 0 0] /Rect [40 40 160 160] /AP << /N 5 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Square /F 4 /C [1 0 0] /Rect [30 30 170 170] /AP << /N 9 0 R >> >>".to_string(), + form("1 0 0"), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The colour at the page's centre. +fn centre(order: &str) -> [u8; 3] { + at(order, 100, 100) +} + +/// The colour at (`x`, `y`) px from the top left. +fn at(order: &str, x: usize, y: usize) -> [u8; 3] { + let mut renderer = PageRenderer::new(Arc::new(pdf(order)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + let px = out.rgba.get(at..at + 3).expect("the centre lies on the page"); + [px[0], px[1], px[2]] +} + +#[test] +fn a_widget_is_drawn_above_the_comments_on_its_page() { + assert_eq!(centre("WS"), [0, 255, 0], "a check box listed before a comment over it"); + assert_eq!(centre("SW"), [0, 255, 0], "a check box listed after it"); + // Comments keep their `/Annots` order among themselves. + assert_eq!(centre("RS"), [0, 0, 0], "the black square listed last is on top"); + assert_eq!(centre("SR"), [255, 0, 0], "the red square listed last is on top"); + // And with a widget among them: (45, 45) is inside both squares, outside the check box. + assert_eq!(at("WRS", 45, 45), [0, 0, 0], "a widget first: the black square listed last is on top"); + assert_eq!(at("WSR", 45, 45), [255, 0, 0], "a widget first: the red square listed last is on top"); + assert_eq!(centre("WSR"), [0, 255, 0], "and the check box above both"); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3edcd350a0..a48de69111 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -213,6 +213,11 @@ impl PdfCraftApp { ocr_web_waiting: None, ocr_verify: ocr_verify::OcrVerify::default(), run_inline: false, + panicking_frames: 0, + frame_panicked: false, + logic_without_ui: false, + #[cfg(test)] + panic_next: None, custom_actions: Vec::new(), wizard: Default::default(), action_run: None, @@ -465,7 +470,28 @@ impl eframe::App for PdfCraftApp { /// Each frame's work before drawing: opens, operating-system and control-channel requests, /// the quit guard, shortcuts, and the background jobs' results. + /// + /// Last-resort guard (AGENTS.md §4, #3914): a panic in a frame is reported and the app, with + /// every open tab, keeps running; see `frame_guard`. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.begin_logic(); + self.guarded_frame(|app| app.logic_unguarded(ctx)); + if self.frame_panicked { + self.close_after_panic(ctx); + } + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.begin_ui(); + self.guarded_frame(|app| app.ui_unguarded(ui)); + self.end_ui(); + } +} + +impl PdfCraftApp { + fn logic_unguarded(&mut self, ctx: &egui::Context) { + #[cfg(test)] + self.injected_panic(crate::frame_guard::FramePart::Logic); self.ctx = Some(ctx.clone()); #[cfg(not(target_arch = "wasm32"))] self.window_state.repair(ctx); @@ -670,7 +696,9 @@ impl eframe::App for PdfCraftApp { self.sync_titles(ctx); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui_unguarded(&mut self, ui: &mut egui::Ui) { + #[cfg(test)] + self.injected_panic(crate::frame_guard::FramePart::Ui); let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.language)); // A language change switches stock chrome between LTR and RTL before anything is drawn. diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index c8ac133a00..ff7e06f377 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -442,6 +442,8 @@ impl PdfCraftApp { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; let data = pdfcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att); + #[cfg(not(target_arch = "wasm32"))] + let source = d.path.clone(); match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), (Ok(bytes), true) => { @@ -456,7 +458,12 @@ impl PdfCraftApp { pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))), None, move |app, path| match crate::editing::write_atomically(&path.to_string_lossy(), &bytes) { - Ok(()) => app.notify_fmt("Saved {name}", &[("name", &path.display().to_string())]), + Ok(()) => { + // Windows: the file keeps the document's Mark-of-the-Web (#3990). Best + // effort: a volume without alternate data streams can't carry it. + let _ = pdfcraft_platform::zone::mark_from(&path, source.as_deref().map(std::path::Path::new)); + app.notify_fmt("Saved {name}", &[("name", &path.display().to_string())]) + } Err(e) => app.notify_fmt("Couldn't save: {e}", &[("e", &e.to_string())]), }, ); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 87abd3b812..26d1bd4946 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2722,9 +2722,9 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 73b097c706..d455d0a74b 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -116,6 +116,9 @@ impl PdfCraftApp { match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { Ok(done) => done, Err(m) => { + // A command stopped part way through closing or replacing a tab must not leave + // the next frame pointing past the last one (#3914). + self.keep_active_valid(); self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]); true } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 982878cb43..c4751fb367 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -127,7 +127,18 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); } let mut req = Pending::default(); - let modal = widgets::show_modal(ctx, "dialog", |ui| { + // Every dialog shares this modal. A different dialog than the one shown before starts from + // its own size, not from the last one's: rows that fill the height they are offered would + // otherwise keep, say, Print as tall as Preferences (#5118). Tabs of one dialog keep its size; + // the kinds of Marks and Export are different dialogs. + let shown = egui::Id::unique("dialog").with("shown"); + let kind = match dialog { + Dialog::Properties(_) => Dialog::Properties(PropsTab::Description), + _ => dialog, + }; + let remeasure = ctx.data(|d| d.get_temp::(shown)) != Some(kind); + ctx.data_mut(|d| d.insert_temp(shown, kind)); + let modal = widgets::show_modal_sized(ctx, "dialog", remeasure, |ui| { ui.set_width(match dialog { Dialog::Properties(_) => 640.0, Dialog::Print => 820.0, @@ -550,6 +561,8 @@ fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending } row(ui, "Application", i.creator.clone().unwrap_or_default()); row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); + row(ui, "Created", i.created.clone().unwrap_or_default()); + row(ui, "Modified", i.modified.clone().unwrap_or_default()); } PropsTab::InitialView => { use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; @@ -624,7 +637,8 @@ fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending for (m, n) in names { ui.selectable_value(&mut v.magnification, m, n); } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + // 100% is saved as Actual Size is (/XYZ zoom 1), so it reads back as Actual Size. + for p in [25.0, 50.0, 75.0, 100.0, 125.0, 150.0, 200.0] { ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); } }) diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 46dd1eb3a5..ec5479a372 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -259,10 +259,12 @@ const DATE_FORMATS: [&str; 11] = [ const BOX_NAMES: [&str; 6] = ["Left Header Text", "Center Header Text", "Right Header Text", "Left Footer Text", "Center Footer Text", "Right Footer Text"]; +/// The mark's colour is sRGB (written to the PDF as DeviceRGB), so it is edited as sRGB, not as +/// linear light. fn color_button(ui: &mut egui::Ui, c: &mut [f64; 3]) { - let mut f = c.map(|v| v as f32); - if egui::color_picker::color_edit_button_rgb(ui, &mut f).changed() { - *c = f.map(f64::from); + let mut srgb = rgb32(*c); + if egui::color_picker::color_edit_button_srgba(ui, &mut srgb, egui::color_picker::Alpha::Opaque).changed() { + *c = [srgb.r(), srgb.g(), srgb.b()].map(|v| f64::from(v) / 255.0); } } @@ -887,6 +889,30 @@ pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { mod tests { use super::*; + /// #5213: a mark's colour is sRGB (written as DeviceRGB), so the button shows it as is and + /// an untouched button leaves it alone. + #[test] + fn colour_button_shows_the_colour_it_applies() { + let ctx = egui::Context::default(); + let mut c = [0.5, 0.5, 0.5]; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| color_button(ui, &mut c)); + out.textures_delta.clear(); + fn fills(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Rect(r) => out.push(r.fill), + egui::Shape::Vec(v) => v.iter().for_each(|s| fills(s, out)), + _ => {} + } + } + let mut painted = Vec::new(); + out.shapes.iter().for_each(|s| fills(&s.shape, &mut painted)); + let fills = painted; + let grey = Color32::from_rgb(188, 188, 188); + assert!(!fills.contains(&grey), "swatch painted as if the colour were linear: {fills:?}"); + assert!(fills.contains(&rgb32(c)), "swatch {:?} not among {fills:?}", rgb32(c)); + assert_eq!(c, [0.5, 0.5, 0.5]); + } + #[test] fn text_applying_would_refuse_is_caught_in_the_dialog() { let mut d = MarksDraft::default(); diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 6713a91df6..a6803e9dd4 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -228,6 +228,12 @@ impl PdfCraftApp { if shortcut { self.request_quit(ctx); } + self.hold_close_for_unsaved(ctx); + } + + /// The window's close: held back with the "save changes?" question while something is + /// unsaved. Also run after a frame whose logic panicked before `guard_quit` (#3914). + pub(crate) fn hold_close_for_unsaved(&mut self, ctx: &egui::Context) { self.sync_unsaved_flag(); if !ctx.input(|i| i.viewport().close_requested()) { return; diff --git a/crates/ui-egui/src/frame_guard.rs b/crates/ui-egui/src/frame_guard.rs new file mode 100644 index 0000000000..1996515258 --- /dev/null +++ b/crates/ui-egui/src/frame_guard.rs @@ -0,0 +1,254 @@ +//! The last-resort guard around each frame (AGENTS.md §4, #3914). Commands, opens and file use +//! already catch panics; a frame's logic and drawing (dialog buttons, panel actions, canvas +//! gestures, reloads) did not, so a panic anywhere in them ended the app with every open tab and +//! up to a minute of unsaved edits. Now it is reported, the app is put back in a state the next +//! frame can draw, and unsaved documents are written to the recovery folder at once. + +use crate::PdfCraftApp; + +/// Frames in a row that may panic before the guard lets the panic end the app, as it did before. +/// By then every unsaved document has been snapshotted, so the next start offers to recover it. +const MAX_PANICKING_FRAMES: u32 = 3; + +impl PdfCraftApp { + /// Before `logic`. While the window is hidden eframe runs `logic` without `ui`, so a + /// `logic` that ran cleanly with no `ui` after it ends the run of panicking frames here. + pub(crate) fn begin_logic(&mut self) { + if self.logic_without_ui && !self.frame_panicked { + self.panicking_frames = 0; + } + self.frame_panicked = false; + self.logic_without_ui = true; + } + + /// Before `ui`: this frame's `logic` is followed by drawing. + pub(crate) fn begin_ui(&mut self) { + self.logic_without_ui = false; + } + + /// After `ui`: a frame whose logic and drawing both ran cleanly ends the run. + pub(crate) fn end_ui(&mut self) { + if !self.frame_panicked { + self.panicking_frames = 0; + } + } + + /// Run one part of the frame (`logic` or `ui`) under the guard. + pub(crate) fn guarded_frame(&mut self, part: impl FnOnce(&mut PdfCraftApp)) { + if let Err(message) = pdfcraft_engine::guard(|| part(self)) { + self.after_frame_panic(&message); + } + } + + /// After a frame panicked: keep the work, drop what the frame was showing on top (a dialog, + /// the command palette), keep `active` pointing at a tab, and say so. + fn after_frame_panic(&mut self, message: &str) { + log::error!("a frame panicked and was caught: {message}"); + if !self.frame_panicked { + self.frame_panicked = true; + self.panicking_frames = self.panicking_frames.saturating_add(1); + } + // Now, not at the next autosave tick. Guarded too: the session may be what panicked. + if let Err(e) = pdfcraft_engine::guard(|| self.autosave_now()) { + log::error!("snapshotting unsaved documents after a frame panic failed: {e}"); + } + if self.panicking_frames >= MAX_PANICKING_FRAMES { + // Every frame panics: the window can't be used or even closed from the app. End it as + // before this guard; the snapshots above are offered for recovery on the next start. + std::panic::resume_unwind(Box::new(message.to_string())); + } + self.dialog = None; + self.palette_open = false; + self.keep_active_valid(); + self.notify_fmt("That didn't work: an internal error stopped it ({m}).", &[("m", message)]); + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + } + + /// After `logic` panicked, perhaps before its quit guard ran: a close of the window asked for + /// in this frame still asks about unsaved changes, or eframe closes it and what the recovery + /// snapshot doesn't hold (a text being typed) is lost. If even that panics, the close is + /// cancelled; the user can close again. + pub(crate) fn close_after_panic(&mut self, ctx: &egui::Context) { + if ctx.input(|i| i.viewport().close_requested()) && pdfcraft_engine::guard(|| self.hold_close_for_unsaved(ctx)).is_err() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } + } + + /// `active` names an open tab, or nothing: a panic part way through closing or replacing a + /// tab can leave it past the end, and the next frame would index out of bounds unguarded. + pub(crate) fn keep_active_valid(&mut self) { + if self.active.is_some_and(|i| i >= self.views.len()) { + self.active = self.views.len().checked_sub(1); + } + } +} + +/// Tests: the part of a frame that panics on purpose while `panic_next` names it. +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FramePart { + Logic, + Ui, +} + +#[cfg(test)] +impl PdfCraftApp { + pub(crate) fn injected_panic(&mut self, part: FramePart) { + if self.panic_next == Some(part) { + panic!("injected {part:?} panic"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Dialog, RecoveryStore}; + use egui_kittest::Harness; + use pdfcraft_engine::{Edit, Session}; + + /// A recovery folder of its own, removed on drop. + struct Store(RecoveryStore); + + impl Store { + fn new(tag: &str) -> Store { + let dir = std::env::temp_dir().join(format!("pdfcraft-frame-guard-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + Store(RecoveryStore::new(dir)) + } + } + + impl Drop for Store { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(self.0.dir()); + } + } + + /// Snapshots written to the store. `RecoveryStore::list` leaves out a running instance's own. + fn snapshots(store: &Store) -> usize { + std::fs::read_dir(store.0.dir()).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "pdf")).count()).unwrap_or(0) + } + + fn pdf(text: &str) -> Vec { + Session::new().create_from_text("t", text).unwrap().to_vec() + } + + /// Two tabs, the second with an unsaved edit, and the About dialog open. + fn harness(store: &Store) -> Harness<'static, PdfCraftApp> { + let recovery = store.0.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.enable_recovery(recovery); + app.open_bytes("a.pdf", None, pdf("First")).unwrap(); + app.open_bytes("b.pdf", None, pdf("Second")).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().apply_edit(Edit::RotatePages { pages: vec![0], degrees: 90 })); + h.state_mut().dialog = Some(Dialog::About); + h.run_steps(1); + h + } + + fn toast(h: &Harness<'static, PdfCraftApp>) -> String { + h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() + } + + #[test] + fn a_panic_while_drawing_keeps_the_app_its_tabs_and_their_edits() { + let store = Store::new("ui"); + let mut h = harness(&store); + h.state_mut().panic_next = Some(FramePart::Ui); + h.run_steps(1); + h.state_mut().panic_next = None; + h.run_steps(2); + let app = h.state(); + assert_eq!(app.views.len(), 2, "every tab is still open"); + assert_eq!(app.active, Some(1)); + assert!(app.dialog.is_none(), "the dialog that was showing is dropped"); + assert_eq!(app.panicking_frames, 0, "a frame that draws resets the count"); + assert!(toast(&h).starts_with("That didn't work: an internal error stopped it (injected Ui panic)"), "{}", toast(&h)); + assert_eq!(snapshots(&store), 1, "the edited tab was snapshotted for recovery at once"); + } + + #[test] + fn a_panic_in_a_frames_logic_is_caught_too() { + let store = Store::new("logic"); + let mut h = harness(&store); + h.state_mut().panic_next = Some(FramePart::Logic); + h.run_steps(1); + h.state_mut().panic_next = None; + h.run_steps(2); + assert_eq!(h.state().views.len(), 2); + assert!(toast(&h).contains("injected Logic panic"), "{}", toast(&h)); + assert_eq!(snapshots(&store), 1); + } + + #[test] + fn closing_the_window_in_a_frame_whose_logic_panics_still_asks_about_unsaved_changes() { + // The panic comes before the quit guard runs: without the question eframe closes the + // window, and what isn't in the recovery snapshot (a text being typed) is lost. + let store = Store::new("quit"); + let mut h = harness(&store); + h.state_mut().dialog = None; + h.state_mut().panic_next = Some(FramePart::Logic); + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + h.run_steps(1); + h.state_mut().panic_next = None; + h.run_steps(1); + assert_eq!(h.state().close_request, Some(crate::CloseRequest::Quit), "the save question is asked"); + } + + #[test] + fn frames_that_keep_panicking_end_the_app_after_snapshotting_its_work() { + let store = Store::new("again"); + let mut h = harness(&store); + h.state_mut().panic_next = Some(FramePart::Ui); + // The guard lets the third panicking frame through, as the app ended before it. + let ended = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.run_steps(MAX_PANICKING_FRAMES as usize))); + assert!(ended.is_err(), "an app that can't draw a single frame isn't kept running"); + assert_eq!(snapshots(&store), 1, "its unsaved tab is offered for recovery on the next start"); + } + + #[test] + fn hidden_frames_that_run_cleanly_reset_the_count() { + // eframe runs `logic` without `ui` while the window is hidden: panics separated by + // clean hidden frames are not "in a row" and must not end the app. + let mut app = PdfCraftApp::new(); + for _ in 0..MAX_PANICKING_FRAMES * 2 { + app.begin_logic(); + app.guarded_frame(|_| panic!("hidden logic panic")); + app.begin_logic(); + app.guarded_frame(|_| {}); + } + app.begin_logic(); + assert_eq!(app.panicking_frames, 0, "a clean hidden frame resets the count"); + } + + #[test] + fn hidden_frames_that_keep_panicking_still_end_the_app() { + let mut app = PdfCraftApp::new(); + let ended = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for _ in 0..MAX_PANICKING_FRAMES { + app.begin_logic(); + app.guarded_frame(|_| panic!("hidden logic panic")); + } + })); + assert!(ended.is_err(), "panics in every hidden frame still reach the limit"); + } + + #[test] + fn active_stays_on_an_open_tab() { + let mut app = PdfCraftApp::new(); + app.active = Some(3); + app.keep_active_valid(); + assert_eq!(app.active, None, "no tabs: nothing is active"); + app.open_bytes("a.pdf", None, pdf("Only")).unwrap(); + app.active = Some(5); + app.keep_active_valid(); + assert_eq!(app.active, Some(0)); + } +} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e3464cf98a..f30edd8c86 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -39,6 +39,18 @@ fn translations(code: &str) -> String { lang.catalog_texts().flat_map(rows).flat_map(|(_, _, t)| t.split('|').map(str::to_owned).collect::>()).collect() } +#[test] +fn the_macos_bundle_declares_every_interface_language() { + // AppKit translates its own dialogs (open and save panels) only into the languages the app + // bundle declares, so `CFBundleLocalizations` must list every entry of `LANGUAGES`. + let plist = include_str!("../../../../packaging/macos/Info.plist.in"); + let block = plist.split("CFBundleLocalizations").nth(1).expect("the template declares CFBundleLocalizations"); + let block = block.split("").next().expect("the declaration is an array"); + let declared: Vec = block.split("").skip(1).filter_map(|s| s.split("").next()).map(str::to_lowercase).collect(); + let registered: Vec = LANGUAGES.iter().map(|l| l.code.to_lowercase()).collect(); + assert_eq!(declared, registered, "packaging/macos/Info.plist.in and LANGUAGES list different languages"); +} + #[test] fn keyboard_key_names_have_a_separate_context_and_english_fallback() { let keys = fixture_lang("x-keys", "key\tDelete\tEntf\n\tDelete\tLöschen\n\tOpen\tÖffnen\n"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 23625cb97b..d1440afda6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,7 @@ use theme::{ThemeKind, ThemePreference}; pub use workflows::ocr_verify::{OcrVerify, Phase}; mod app; +mod frame_guard; mod hooks; mod persist; mod platform; @@ -557,6 +558,16 @@ pub struct PdfCraftApp { pub ocr_verify: ocr_verify::OcrVerify, /// Background jobs (OCR, actions, listing pinned folders) run inline instead (tests). pub run_inline: bool, + /// Frames in a row whose logic or drawing panicked, and whether this frame's did + /// (`frame_guard`, #3914). + panicking_frames: u32, + frame_panicked: bool, + /// The last `logic` ran without a `ui` after it yet (eframe skips `ui` while the window is + /// hidden). + logic_without_ui: bool, + /// Tests: the part of the next frame that panics on purpose. + #[cfg(test)] + panic_next: Option, /// Action Wizard: the user's actions, the dialog state, the running action and (tests) the /// files to use instead of a picker. pub custom_actions: Vec, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e5c45549ab..d22acc5284 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -33,12 +33,15 @@ pub(crate) enum Nav { Page(usize), /// A bookmark's destination: its page and where on it. Dest(usize, pdfcraft_render::DestView), + /// A bookmark's action that goes nowhere in this document, run as a link's is. + Action(pdfcraft_render::LinkTarget), Flash(usize, [f32; 4]), } pub fn human_size(n: usize) -> String { match n { - n if n >= 1 << 20 => format!("{:.1} MB", n as f64 / (1u64 << 20) as f64), + // A size that rounds to "1024.0 KB" reads as the next unit. + n if n as f64 / 1024.0 >= 1023.95 => format!("{:.1} MB", n as f64 / (1u64 << 20) as f64), n if n >= 1 << 10 => format!("{:.1} KB", n as f64 / (1u64 << 10) as f64), n => byte_count(n), } @@ -48,3 +51,16 @@ pub fn human_size(n: usize) -> String { pub fn byte_count(n: usize) -> String { crate::i18n::trn(crate::i18n::current(), n as u64, "{n} byte", "{n} bytes") } + +#[cfg(test)] +mod human_size_tests { + use super::human_size; + + #[test] + fn rounds_up_into_next_unit() { + assert_eq!(human_size(1_048_524), "1023.9 KB"); + assert_eq!(human_size(1_048_525), "1.0 MB"); + assert_eq!(human_size(1_048_575), "1.0 MB"); + assert_eq!(human_size(1_048_576), "1.0 MB"); + } +} diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index 0674c36c34..baf84d7b44 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -219,10 +219,13 @@ pub(super) fn outline_item( let pos = pos2(rect.right() - 6.0 - g.size().x, rect.top() + 14.0 - g.size().y / 2.0); ui.painter().galley(pos, g, t.text_faint); } - if theme::hand(resp.clone()).clicked() - && let Some(p) = item.page - { - *cx.nav = Some(Nav::Dest(p, item.view)); + if theme::hand(resp.clone()).clicked() { + match (item.page, &item.action) { + (Some(p), _) => *cx.nav = Some(Nav::Dest(p, item.view)), + // A web address, another PDF or a layer change, as a link runs it. + (None, Some(action)) => *cx.nav = Some(Nav::Action(action.clone())), + (None, None) => {} + } } if resp.double_clicked() && cx.editable { *cx.action = Some(BmAction::StartRename(path.to_vec())); @@ -370,6 +373,7 @@ mod bookmark_filter { title: title.into(), page: Some(0), view: pdfcraft_render::DestView::Top, + action: None, children, open: true, color: None, diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ae9fbb9266..790c0ce2d3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -445,6 +445,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { Some(doc) => app.views[index].go_to_dest(p, dest, &doc.info), None => app.views[index].go_to_page(p), }, + Some(Nav::Action(target)) => { + if let Some((source, file, destination)) = crate::canvas::follow_link(app, index, Some(target)) { + app.open_remote_link(source, file, destination); + } + } Some(Nav::Flash(p, r)) => { let v = &mut app.views[index]; if let Some(doc) = app.session.get(id) { diff --git a/crates/ui-egui/src/platform/file_watch.rs b/crates/ui-egui/src/platform/file_watch.rs index 57de3428a3..49013d1b74 100644 --- a/crates/ui-egui/src/platform/file_watch.rs +++ b/crates/ui-egui/src/platform/file_watch.rs @@ -288,6 +288,9 @@ pub struct FileWatch { epoch: HashMap, /// The notice bar's Reload was pressed for this document. pub reload_request: Option, + /// The newest version found for the active document while a dialog was open over it, + /// applied when the dialog closes (#3917). + held: Option, } impl FileWatch { @@ -363,12 +366,27 @@ impl PdfCraftApp { let (found, _) = self.watch.inline.tick(); self.watch.found.lock().unwrap_or_else(PoisonError::into_inner).extend(found); } - let found = std::mem::take(&mut *self.watch.found.lock().unwrap_or_else(PoisonError::into_inner)); + let mut found = std::mem::take(&mut *self.watch.found.lock().unwrap_or_else(PoisonError::into_inner)); + if !self.holds_reload() + && let Some(held) = self.watch.held.take() + { + // Dropped when reloading was turned off meanwhile (in Preferences, the dialog itself) + // or when a newer version of the same document was just found: only that one loads. + if self.reload_changed_files && !found.iter().any(|c| c.doc == held.doc) { + found.insert(0, held); + } + } for c in found { self.file_changed(c); } } + /// A dialog or prompt about the active document is open: its new version waits (#3917). The + /// full-screen prompt is not modal elsewhere, but it is about the version on screen. + fn holds_reload(&self) -> bool { + self.modal_open() || self.pending_full_screen.is_some() + } + /// Hand `list` to the watcher, starting it when needed. `false`: not delivered (no context /// yet, or the worker couldn't start or ended); the next frame tries again. fn send_to_watcher(&mut self, list: Vec) -> bool { @@ -400,6 +418,13 @@ impl PdfCraftApp { /// changes. fn file_changed(&mut self, c: Changed) { let Some(i) = self.views.iter().position(|v| v.id == c.doc) else { return }; + // A dialog acts on the active document when its OK is pressed: replacing the document + // under it would apply the dialog to another version (#3917). Keep the newest version + // found until the dialog closes; the checks below run then. + if self.holds_reload() && self.active == Some(i) { + self.watch.held = Some(c); + return; + } // Found for an earlier reading or writing of the file, or for another file than the // document's now (a save, Save As or a reopen came between): older than the document in // memory, so it is not applied over it (#1802). The watcher was told the newer version. diff --git a/crates/ui-egui/src/platform/portable.rs b/crates/ui-egui/src/platform/portable.rs index fefa268e09..fcd6053fd4 100644 --- a/crates/ui-egui/src/platform/portable.rs +++ b/crates/ui-egui/src/platform/portable.rs @@ -1,8 +1,10 @@ //! Portable mode (#157): a marker file beside the executable ([`MARKERS`]) keeps the settings, //! log files, crash-recovery autosaves and new digital IDs in `/PdfCraftData`, so a -//! portable copy (on a USB stick, say) writes nothing to `%APPDATA%` or `%LOCALAPPDATA%`. The -//! Windows portable zip ships with `portable.txt`; the MSI doesn't. PhotoCraft uses the same -//! scheme (`photocraft/apps/photocraft/src/app_dirs.rs`). +//! portable copy (on a USB stick, say) writes nothing to `%APPDATA%`. In `%LOCALAPPDATA%` it keeps +//! only its single-instance socket and lock, two empty files in `%LOCALAPPDATA%\PdfCraft` that +//! must stay private to the user (#889, `apps/pdfcraft/src/single_instance.rs`). The Windows +//! portable zip ships with `portable.txt`; the MSI doesn't. PhotoCraft uses the same scheme +//! (`photocraft/apps/photocraft/src/app_dirs.rs`). //! //! If that folder can't be written (read-only medium, Program Files), portable mode stays off, the //! per-user folders are used and the app says why. diff --git a/crates/ui-egui/src/platform/system_fonts.rs b/crates/ui-egui/src/platform/system_fonts.rs index 4b5e1eca7a..570e5de0ed 100644 --- a/crates/ui-egui/src/platform/system_fonts.rs +++ b/crates/ui-egui/src/platform/system_fonts.rs @@ -339,12 +339,7 @@ fn han_candidates(traditional: bool) -> Vec { if traditional { &["msjh.ttc", "msyh.ttc", "mingliu.ttc", "simsun.ttc"] } else { &["msyh.ttc", "msjh.ttc", "simsun.ttc", "mingliu.ttc"] }; files.iter().map(|f| windows_fonts().join(f)).collect() } else if cfg!(target_os = "macos") { - let files: &[&str] = if traditional { - &["PingFang.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc", "Hiragino Sans GB.ttc"] - } else { - &["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc"] - }; - files.iter().map(|f| Path::new("/System/Library/Fonts").join(f)).collect() + macos_han_candidates(traditional) } else { unix_fonts(&[ "truetype/wqy/wqy-microhei.ttc", @@ -362,6 +357,26 @@ fn han_candidates(traditional: bool) -> Vec { } } +/// macOS 26 moved PingFang out of `/System/Library/Fonts` into the reserved system-font directory; +/// face 0 of that collection is the SC UI face. Older systems still have `PingFang.ttc`, which wins. +const MACOS_PINGFANG_UI: &str = "/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc"; + +fn macos_han_candidates(traditional: bool) -> Vec { + let dir = Path::new("/System/Library/Fonts"); + let pingfang_ui = PathBuf::from(MACOS_PINGFANG_UI); + let mut list = vec![dir.join("PingFang.ttc")]; + if traditional { + // Face 0 of PingFangUI.ttc is Simplified Chinese, so STHeiti stays ahead of it. + list.extend(["STHeiti Light.ttc", "STHeiti Medium.ttc"].iter().map(|f| dir.join(f))); + list.push(pingfang_ui); + list.push(dir.join("Hiragino Sans GB.ttc")); + } else { + list.push(pingfang_ui); + list.extend(["Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc"].iter().map(|f| dir.join(f))); + } + list +} + /// Well-known locations of Korean faces, best first. fn hangul_candidates() -> Vec { if cfg!(windows) { @@ -694,4 +709,21 @@ mod tests { eprintln!("{last_resort:?} is not installed on this machine — optional to the loader, so nothing to check"); } } + + #[test] + fn macos_simplified_prefers_pingfang_ui_over_hiragino() { + let list = macos_han_candidates(false); + let pos = |name: &str| list.iter().position(|p| p.ends_with(name)); + assert_eq!(pos("PingFang.ttc"), Some(0)); + assert_eq!(list[1], PathBuf::from(MACOS_PINGFANG_UI)); + assert!(pos("PingFangUI.ttc") < pos("Hiragino Sans GB.ttc")); + } + + #[test] + fn macos_traditional_keeps_stheiti_ahead_of_pingfang_ui() { + let list = macos_han_candidates(true); + let pos = |name: &str| list.iter().position(|p| p.ends_with(name)); + assert!(pos("STHeiti Medium.ttc") < pos("PingFangUI.ttc")); + assert!(pos("PingFangUI.ttc").is_some()); + } } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index f2e880983d..6249288c4d 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -3,7 +3,7 @@ //! their outlines, a click selects one, a double-click edits it and Delete removes it. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; +use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle, LinkZoom}; use pdfcraft_render::{DocInfo, LinkTarget}; use crate::canvas::{DocView, PageToolContext, PageXform}; @@ -46,9 +46,52 @@ pub struct LinkDraft { pub url: String, /// 1-based target page. pub target: usize, + /// How the target page is shown. + pub zoom: LinkZoom, + /// The link's action when the dialog opened (`None` for a new link). OK rewrites the action + /// only when it changed, so an appearance edit keeps a destination's position and kind + /// (`/XYZ left top`, `/FitR`…) that the dialog doesn't show. + pub current: Option, pub style: LinkStyle, } +/// The zooms Link Properties offers for a page view. +const ZOOMS: [LinkZoom; 12] = [ + LinkZoom::FitPage, + LinkZoom::FitWidth, + LinkZoom::FitHeight, + LinkZoom::FitVisible, + LinkZoom::Percent(100.0), + LinkZoom::Inherit, + LinkZoom::Percent(50.0), + LinkZoom::Percent(75.0), + LinkZoom::Percent(125.0), + LinkZoom::Percent(150.0), + LinkZoom::Percent(200.0), + LinkZoom::Percent(400.0), +]; + +fn zoom_label(zoom: LinkZoom) -> String { + match zoom { + LinkZoom::FitPage => tl!("Fit Page").to_string(), + LinkZoom::FitWidth => tl!("Fit Width").to_string(), + LinkZoom::FitHeight => tl!("Fit Height").to_string(), + LinkZoom::FitVisible => tl!("Fit Visible").to_string(), + LinkZoom::Inherit => tl!("Inherit Zoom").to_string(), + LinkZoom::Percent(p) if (p - 100.0).abs() < 1e-9 => tl!("Actual Size").to_string(), + // Another writer's factor can be anything up to f64::MAX, or a tiny fraction: exponent + // form keeps it short. + LinkZoom::Percent(p) if !(0.01..1e6).contains(&p) => format!("{p:.1e}%"), + // A fractional one (another writer's 0.5% or 62.5%) shows up to two places, so it agrees + // with what link_list reports instead of rounding to 0%. + LinkZoom::Percent(p) if p.fract() != 0.0 => { + let places = format!("{p:.2}"); + format!("{}%", places.trim_end_matches('0').trim_end_matches('.')) + } + LinkZoom::Percent(p) => format!("{p:.0}%"), + } +} + fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } @@ -202,9 +245,14 @@ impl PdfCraftApp { _ => "https://".into(), }, target: match l.action { - LinkAction::Page(p) => p + 1, + LinkAction::Page { page, .. } => page + 1, _ => 1, }, + zoom: match l.action { + LinkAction::Page { zoom, .. } => zoom, + _ => LinkZoom::FitPage, + }, + current: Some(l.action.clone()), style: l.style, }, None => LinkDraft { @@ -215,6 +263,8 @@ impl PdfCraftApp { web: true, url: "https://".into(), target: 1, + zoom: LinkZoom::FitPage, + current: None, style: LinkStyle::default(), }, }; @@ -339,6 +389,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token ui.add(egui::DragValue::new(&mut d.target).range(1..=pages.max(1))); ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())])); }); + ui.horizontal(|ui| { + ui.add_space(24.0); + let label = ui.label(tl!("Zoom")); + craft_ui::ComboBox::from_id_salt("link-zoom") + .selected_text(zoom_label(d.zoom)) + .show_ui(ui, |ui| { + for z in ZOOMS { + ui.selectable_value(&mut d.zoom, z, zoom_label(z)); + } + }) + .response + .labelled_by(label.id); + }); }); } let ok = d.preserve_action || !d.web || (d.url.trim().len() > 3 && d.url.trim() != "https://"); @@ -358,21 +421,20 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token (a, c) } +/// The action a confirmed draft sets. +fn action_for(d: &LinkDraft) -> LinkAction { + if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page { page: d.target.saturating_sub(1), zoom: d.zoom } } +} + /// The edit for a confirmed draft. pub(crate) fn edit_for(d: &LinkDraft) -> Edit { match d.index { - None => Edit::AddLink { - page: d.page, - rect: d.rect, - action: if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page(d.target.saturating_sub(1)) }, - style: d.style, - }, + None => Edit::AddLink { page: d.page, rect: d.rect, action: action_for(d), style: d.style }, Some(index) => Edit::SetLink { page: d.page, index, rect: None, - action: (!d.preserve_action) - .then(|| if d.web { LinkAction::Uri(d.url.trim().to_string()) } else { LinkAction::Page(d.target.saturating_sub(1)) }), + action: (!d.preserve_action).then(|| action_for(d)).filter(|a| d.current.as_ref() != Some(a)), style: Some(d.style), }, } @@ -380,13 +442,54 @@ pub(crate) fn edit_for(d: &LinkDraft) -> Edit { #[cfg(test)] mod tests { - use super::{LinkDraft, edit_for}; - use pdfcraft_engine::{Edit, LinkStyle}; + use super::{LinkDraft, edit_for, zoom_label}; + use pdfcraft_engine::{Edit, LinkAction, LinkStyle, LinkZoom}; + + fn draft(current: LinkAction) -> LinkDraft { + LinkDraft { + page: 0, + rect: [0.0; 4], + index: Some(3), + preserve_action: matches!(current, LinkAction::Other(_)), + web: false, + url: String::new(), + target: 1, + zoom: LinkZoom::FitPage, + current: Some(current), + style: LinkStyle::default(), + } + } #[test] fn appearance_edit_preserves_an_existing_unsupported_action() { let style = LinkStyle::default(); - let draft = LinkDraft { page: 0, rect: [0.0; 4], index: Some(3), preserve_action: true, web: false, url: String::new(), target: 1, style }; + let draft = draft(LinkAction::Other("named destination".into())); assert_eq!(edit_for(&draft), Edit::SetLink { page: 0, index: 3, rect: None, action: None, style: Some(style) }); } + + #[test] + fn only_a_changed_destination_is_rewritten() { + // A page view the dialog shows as it is: OK leaves the destination (and its position) alone. + let mut d = draft(LinkAction::Page { page: 0, zoom: LinkZoom::Percent(200.0) }); + d.zoom = LinkZoom::Percent(200.0); + assert!(matches!(edit_for(&d), Edit::SetLink { action: None, .. })); + d.zoom = LinkZoom::FitWidth; + assert!(matches!(edit_for(&d), Edit::SetLink { action: Some(LinkAction::Page { page: 0, zoom: LinkZoom::FitWidth }), .. })); + d.zoom = LinkZoom::Percent(200.0); + d.target = 2; + assert!(matches!(edit_for(&d), Edit::SetLink { action: Some(LinkAction::Page { page: 1, .. }), .. })); + } + + #[test] + fn a_zoom_label_stays_short_for_any_factor() { + assert_eq!(zoom_label(LinkZoom::Percent(200.0)), "200%"); + // The destination's own zoom, past what PdfCraft writes, is shown as it is. + assert_eq!(zoom_label(LinkZoom::Percent(10000.0)), "10000%"); + assert_eq!(zoom_label(LinkZoom::Percent(f64::MAX)), "1.8e308%"); + // A fractional zoom keeps its places instead of rounding to 0%. + assert_eq!(zoom_label(LinkZoom::Percent(0.5)), "0.5%"); + assert_eq!(zoom_label(LinkZoom::Percent(62.5)), "62.5%"); + assert_eq!(zoom_label(LinkZoom::Percent(33.333)), "33.33%"); + assert_eq!(zoom_label(LinkZoom::Percent(0.001)), "1.0e-3%"); + } } diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 3622c1fe18..128e033dc2 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -630,16 +630,17 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc for f in form { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page)) { let mut r = screen_rect(xf, info, page, w.rect); - let selected = prep.selected.as_ref().is_some_and(|(n, i)| n == &f.name && *i == wi); + let primary = prep.selected.as_ref().is_some_and(|(n, i)| n == &f.name && *i == wi); let also = prep.also.iter().any(|(n, i)| n == &f.name && *i == wi); - if selected && let (Some((_, g, start)), Some(p)) = (grab, pointer) { + let selected = primary || also; + if primary && let (Some((_, g, start)), Some(p)) = (grab, pointer) { r = dragged(r, g, p - start, ui.input(|i| i.modifiers.shift)); } painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL); painter.rect_stroke( r, CornerRadius::ZERO, - Stroke::new(if also { 2.0 } else { 1.0 }, if selected || also { SELECT_BLUE } else { Color32::from_gray(90) }), + Stroke::new(1.0, if selected { SELECT_BLUE } else { Color32::from_gray(90) }), egui::StrokeKind::Inside, ); // The name tag: white text on black, clipped to the field. @@ -655,6 +656,8 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc p2.galley(tag.min + vec2(3.0, 1.0), galley, Color32::WHITE); if selected { painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(2.0, SELECT_BLUE), egui::StrokeKind::Outside); + } + if primary { for (c, ..) in handles(r) { painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE)); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d7f4213711..4a45817744 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -35,7 +35,22 @@ pub fn modal(ctx: &egui::Context, id: impl std::hash::Hash + std::fmt::Debug) -> /// Show [`modal`], outline its controls, and remember the card rect for layout checks. pub fn show_modal(ctx: &egui::Context, id: &str, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { + show_modal_sized(ctx, id, false, content) +} + +/// [`show_modal`], growing from its contents' own height when `remeasure` is set rather than +/// from the height it had last frame: one modal id that shows different contents would +/// otherwise keep the height of the previous ones, since rows that fill the height offered to +/// them take all of it (#5118). The width stays, and the contents stay visible (no hidden +/// sizing frame), so the control channel can inspect a dialog the frame it opens. +pub fn show_modal_sized(ctx: &egui::Context, id: &str, remeasure: bool, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { let stored = egui::Id::unique(id); + if remeasure && let Some(mut state) = egui::AreaState::load(ctx, stored) { + state.size = state.size.map(|s| egui::vec2(s.x, 0.0)); + ctx.memory_mut(|m| m.areas_mut().set_state(egui::Modal::default_area(stored).layer(), state)); + // The card is centred on the height it ends with: draw once more at that height. + ctx.request_repaint(); + } modal(ctx, id).show(ctx, |ui| { begin_dialog(ui); let inner = content(ui); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 02b21d1692..764d4c6d24 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1435,6 +1435,10 @@ fn drawing_a_comment_by_drag_and_its_context_menu() { #[test] fn screenshots_of_window_and_region() { let (mut h, c) = harness(); + // The harness makes its GPU renderer on the first render. The app has one before the control + // channel answers anything, so make it here, outside the screenshot's 5 s deadline: with 16 + // tests at once on Windows, making it took up to 6 s and every copy of this test failed. + h.render().expect("the test renderer draws"); let shot = ok(&mut h, &c, "ui.screenshot", json!({})); let ppp = shot["pixels_per_point"].as_f64().unwrap(); assert_eq!(shot["width"].as_f64().unwrap(), (1400.0 * ppp).round()); diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index c495e608ff..cf829e7341 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -258,3 +258,54 @@ fn page_hover_cursors_stay_off_while_a_dialog_is_open() { h.run_steps(2); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "nothing under the dialog's backdrop can be clicked"); } + +#[test] +fn a_dialog_keeps_its_own_height_after_a_taller_one() { + // #5118: Print opened after Preferences was as tall as Preferences, with blank space below + // its buttons. Every dialog shares one modal, which kept the last dialog's size. + use pdfcraft_ui_egui::Dialog; + for dialog in [Dialog::Print, Dialog::RotatePages] { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app + }); + let show = |h: &mut Harness<'_, PdfCraftApp>, d: Dialog| { + h.state_mut().dialog = None; + h.run_steps(2); + h.state_mut().dialog = Some(d); + h.run_steps(6); + card(h, "dialog") + }; + let fresh = show(&mut h, dialog); + let prefs = show(&mut h, Dialog::Preferences); + assert!(prefs.height() > fresh.height() + 20.0, "{dialog:?} {fresh:?} isn't shorter than Preferences {prefs:?}"); + let after = show(&mut h, dialog); + assert!((after.height() - fresh.height()).abs() < 1.0, "{dialog:?} {fresh:?}, after Preferences {after:?}"); + } +} + +#[test] +fn each_kind_of_marks_dialog_keeps_its_own_height() { + // #5118: Header & Footer, Watermark and Background are one `Dialog::Marks` with different + // contents. Background opened after Header & Footer kept the taller dialog's height. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app + }); + let show = |h: &mut Harness<'_, PdfCraftApp>, d: &str| { + h.state_mut().dialog = None; + h.run_steps(2); + h.state_mut().set_option("dialog", d).unwrap(); + h.run_steps(6); + card(h, "dialog") + }; + let fresh = show(&mut h, "background"); + let header = show(&mut h, "header-footer"); + assert!(header.height() > fresh.height() + 20.0, "Background {fresh:?} isn't shorter than Header & Footer {header:?}"); + let after = show(&mut h, "background"); + assert!((after.height() - fresh.height()).abs() < 1.0, "Background {fresh:?}, after Header & Footer {after:?}"); +} diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index eb2bfa7afe..01660a3a60 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -149,6 +149,150 @@ fn pending(url: &str, origin: LinkOrigin) -> Option { Some(PendingLink { url: url.into(), origin }) } +const BOOKMARK_URL: &str = "https://example.org/from-a-bookmark"; + +/// Three 300×400 pages whose bookmarks open a web address, go to page 2 of `target.pdf` (a remote +/// go-to, whose `[1 /Fit]` is the other file's page) and go to page 3 here. +fn bookmark_fixture() -> Vec { + outline_pdf(&[ + ("Website", format!("/A << /S /URI /URI ({BOOKMARK_URL}) >>")), + ("Chapter in target", "/A << /S /GoToR /F (target.pdf) /D [1 /Fit] >>".into()), + ("Appendix", "/Dest [5 0 R /Fit]".into()), + ]) +} + +/// Three 300×400 pages (objects 3–5) and a flat outline of `bookmarks`: each a title and the rest +/// of its dictionary (its `/Dest` or `/A`). +fn outline_pdf(bookmarks: &[(&str, String)]) -> Vec { + let n = bookmarks.len(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + format!("<< /Type /Outlines /First 7 0 R /Last {} 0 R /Count {n} >>", 6 + n), + ]; + for (k, (title, rest)) in bookmarks.iter().enumerate() { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 6 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 8 + k) }; + objs.push(format!("<< /Title ({title}) /Parent 6 0 R{prev}{next} {rest} >>")); + } + 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()); + out +} + +/// The app with [`bookmark_fixture`] open from `source_path` (in memory when `None`) and the +/// Bookmarks panel showing. +fn bookmark_harness(source_path: Option) -> Harness<'static, PdfCraftApp> { + bookmark_harness_with(source_path, bookmark_fixture()) +} + +/// The app with `pdf` open from `source_path` (in memory when `None`) and the Bookmarks panel +/// showing. +fn bookmark_harness_with(source_path: Option, pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("source.pdf", source_path, pdf).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "bookmarks").unwrap(); + app + }); + h.run_steps(8); + h +} + +/// Click the Bookmarks panel row titled `title`. +fn click_bookmark(h: &mut Harness<'static, PdfCraftApp>, title: &str) { + h.get_by_label(title).click(); + h.run_steps(4); +} + +/// A bookmark that opens a web address asks first, as a link does, and stays on the page; a +/// bookmark to a page here still goes there. +#[test] +fn a_web_bookmark_asks_first_like_a_link() { + let _gpu = gpu(); + let mut h = bookmark_harness(None); + click_bookmark(&mut h, "Website"); + assert_eq!(h.state().pending_link, pending(BOOKMARK_URL, LinkOrigin::Link)); + assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers"); + assert_eq!(h.state().views[0].current, 0); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert_eq!((h.state().pending_link.clone(), h.state().last_opened_url.clone()), (None, None)); + + click_bookmark(&mut h, "Appendix"); + assert_eq!(h.state().views[0].current, 2); +} + +/// A bookmark to another PDF opens that file at its page, as a link does. Its `[1 /Fit]` is the +/// other file's page 2, never this file's: the source document stays where it was. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_bookmark_to_another_pdf_opens_it_instead_of_moving_here() { + let _gpu = gpu(); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-bookmark-gotor-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap(); + let mut h = bookmark_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned())); + click_bookmark(&mut h, "Chapter in target"); + support::wait_until(&mut h, "remote document", |app| app.views.len() == 2); + assert_eq!(h.state().views[0].current, 0, "the source stays on its page"); + assert_eq!(h.state().active, Some(1)); + assert_eq!(h.state().views[1].current, 1, "the other file opens at the bookmark's page"); +} + +/// Bookmarks go through the link policy, not around it. A script action is named and not run (its +/// `app.launchURL` asks for nothing), a Launch of a program is named and nothing starts, a +/// destination that doesn't exist does nothing, and a remote go-to by an absolute path waits for +/// consent before the file is touched. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn bookmarks_go_through_the_link_policy() { + let _gpu = gpu(); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-bookmark-policy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("absent.pdf"); + // A PDF string: backslashes and parentheses escaped. + let file = target.to_str().unwrap().replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); + let pdf = outline_pdf(&[ + ("Script", r#"/A << /S /JavaScript /JS (app.launchURL\("https://example.org/from-a-script", true\);) >>"#.into()), + ("Program", "/A << /S /Launch /F (tool.exe) >>".into()), + ("Missing", "/Dest (nowhere)".into()), + ("Absolute", format!("/A << /S /GoToR /F ({file}) /D [0 /Fit] >>")), + ]); + let mut h = bookmark_harness_with(None, pdf); + for (title, notice) in [ + ("Script", Some("This PDF uses the unsupported JavaScript action.")), + ("Program", Some("This PDF uses the unsupported Launch action.")), + ("Missing", None), + ] { + h.state_mut().toast = None; + click_bookmark(&mut h, title); + let s = h.state(); + assert_eq!(s.toast.as_ref().map(|t| t.0.as_str()), notice, "{title}"); + assert_eq!((&s.pending_link, &s.last_opened_url, s.views.len(), s.views[0].current), (&None, &None, 1, 0), "{title}: nothing opened"); + } + click_bookmark(&mut h, "Absolute"); + assert_eq!(h.state().pending_remote_link.as_ref().map(|p| p.path.clone()), Some(target)); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().toast.is_none(), "the file is not touched before consent"); +} + #[test] fn a_link_asks_first_and_opens_when_allowed() { let _gpu = gpu(); diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index fe65314fa4..2807f1e2f0 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -1055,7 +1055,7 @@ fn image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation() h.state_mut().apply_edit(pdfcraft_engine::Edit::AddLink { page: 0, rect: [30.0, 230.0, 60.0, 270.0], - action: pdfcraft_engine::LinkAction::Page(0), + action: pdfcraft_engine::LinkAction::Page { page: 0, zoom: pdfcraft_engine::LinkZoom::FitPage }, style: pdfcraft_engine::LinkStyle::default(), }); h.state_mut().signature = Some(SavedSig::Image(image)); diff --git a/crates/ui-egui/tests/it/panel_snapshots.rs b/crates/ui-egui/tests/it/panel_snapshots.rs index 97fba87777..df295a86c2 100644 --- a/crates/ui-egui/tests/it/panel_snapshots.rs +++ b/crates/ui-egui/tests/it/panel_snapshots.rs @@ -204,6 +204,10 @@ fn open(p: RightPanel) -> (Harness<'static, PdfCraftApp>, &'static str) { h.run_steps(2); h.state_mut().views[0].find.as_mut().expect("Advanced Search opens a search").query = "sample".into(); support::wait_until(&mut h, "both pages searched", |app| app.views[0].find.as_ref().is_some_and(|f| f.matches.len() == 2)); + // Each page is searched as its text arrives, and the match made current is the first + // one found: page 2's when its text comes first. Searched again with both pages read, + // as a new query is, the current match is the first from the page in view. + h.state_mut().views[0].rerun_find(); h.run_steps(2); return (h, "search"); } diff --git a/crates/ui-egui/tests/it/pdf_links.rs b/crates/ui-egui/tests/it/pdf_links.rs index 7ed2903bc5..40be0d0fc2 100644 --- a/crates/ui-egui/tests/it/pdf_links.rs +++ b/crates/ui-egui/tests/it/pdf_links.rs @@ -77,13 +77,72 @@ fn drawing_editing_and_deleting_links() { } h.get_by_label("OK").click(); h.run_steps(3); - assert_eq!(links(&h)[0].action, LinkAction::Page(0)); + assert_eq!(links(&h)[0].action, LinkAction::Page { page: 0, zoom: pdfcraft_engine::LinkZoom::FitPage }); // Delete the selected link. h.key_press(egui::Key::Delete); h.run_steps(3); assert!(links(&h).is_empty()); } +#[test] +fn link_properties_sets_the_page_view_zoom() { + use egui::accesskit::Role; + use pdfcraft_engine::LinkZoom; + let mut h = harness(); + assert!(h.state_mut().execute("edit.link")); + let (a, b) = (at(&h, 40.0, 300.0), at(&h, 160.0, 320.0)); + drag(&mut h, a, b); + h.get_by_label("Create Link"); + h.get_by_label("Go to a page view").click(); + h.run_steps(3); + // The toolbar has a zoom box of its own; the link's shows Fit Page until changed. + h.get_by(|n| n.role() == Role::ComboBox && n.value().as_deref() == Some("Fit Page")).click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "200%").scroll_to_me(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "200%").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(links(&h)[0].action, LinkAction::Page { page: 0, zoom: LinkZoom::Percent(200.0) }); +} + +/// One page with a link whose destination has a position, which Link Properties doesn't show. +const POSITIONED: &[u8] = 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 300 400] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Link /Rect [40 40 160 60] /Border [0 0 0] /Dest [3 0 R /XYZ 100 300 2] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn an_appearance_edit_keeps_the_destination_as_written() { + use pdfcraft_render::{DestView, LinkTarget}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("positioned.pdf", None, POSITIONED.to_vec()).unwrap(); + app + }); + h.run_steps(6); + let positioned = LinkTarget::Page(0, DestView::Xyz { left: Some(100.0), top: Some(300.0), zoom: Some(2.0) }); + let target = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.links[0].target.clone() + }; + assert_eq!(target(&h), positioned); + h.state_mut().views[0].links.open_existing = Some((0, 0)); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::LinkProps)); + h.state_mut().link_draft.as_mut().unwrap().style.color = [1.0, 0.0, 0.0]; + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(links(&h)[0].style.color, [1.0, 0.0, 0.0], "the edit applied"); + assert_eq!(target(&h), positioned, "the destination is not rewritten without its position"); +} + #[test] fn removing_all_links_and_undo() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 330cd50667..0da954dd4c 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -1032,6 +1032,34 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() { assert_eq!(doc.bytes.as_ref(), &original); } +/// Windows: a saved attachment keeps the Mark-of-the-Web of the PDF it came from, and gets the +/// Internet zone when that PDF has none or isn't a file (#3990). +#[cfg(windows)] +#[test] +fn a_saved_attachment_carries_the_documents_zone() { + let dir = AttachmentDir::new(); + let zone_of = |p: &std::path::Path| std::fs::read(format!("{}:Zone.Identifier", p.display())).ok(); + let mail = dir.0.join("from mail.pdf"); + std::fs::write(&mail, attachment_fixture()).unwrap(); + let zone = "[ZoneTransfer]\r\nZoneId=3\r\nHostUrl=https://mail.example/invoice.pdf\r\n"; + std::fs::write(format!("{}:Zone.Identifier", mail.display()), zone).unwrap(); + let local = dir.0.join("local.pdf"); + std::fs::write(&local, attachment_fixture()).unwrap(); + let mut h = harness(&[]); + let opened = [(Some(&mail), zone), (Some(&local), pdfcraft_platform::zone::INTERNET_ZONE), (None, pdfcraft_platform::zone::INTERNET_ZONE)]; + for (i, (path, expected)) in opened.into_iter().enumerate() { + h.state_mut().open_bytes(&format!("doc{i}.pdf"), path.map(|p| p.to_string_lossy().into_owned()), attachment_fixture()).unwrap(); + h.run_steps(2); + let id = h.state().views.last().unwrap().id; + let chosen = dir.0.join(format!("saved{i}.exe")); + h.state_mut().pick_override = Some(vec![chosen.to_string_lossy().into_owned()]); + h.state_mut().attachment_action(id, 0, false); + h.run_steps(2); + assert_eq!(std::fs::read(&chosen).unwrap(), b"hello notes"); + assert_eq!(zone_of(&chosen).as_deref(), Some(expected.as_bytes()), "document {i}"); + } +} + /// Every file under `dir`, recursively, sorted. fn files_under(dir: &std::path::Path) -> Vec { let mut out = Vec::new(); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 457bfce8c9..d6abe1f8b3 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -55,6 +55,68 @@ fn rect_of(h: &Harness<'static, PdfCraftApp>, name: &str) -> [f64; 4] { s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().widgets[0].rect } +#[test] +fn shift_selected_small_widgets_have_blue_tags_and_visible_outlines() { + use egui::{Color32, Event, Modifiers, PointerButton, Shape, StrokeKind}; + use pdfcraft_engine::{Edit, NewField, Session}; + + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("selection.pdf", None, bytes, None).unwrap(); + for (name, rect, kind) in [ + ("first", [40.0, 290.0, 54.0, 304.0], NewField::CheckBox), + ("second", [90.0, 290.0, 104.0, 304.0], NewField::CheckBox), + ("text", [40.0, 230.0, 230.0, 254.0], NewField::Text { multiline: false }), + ] { + session.apply(id, Edit::AddField { name: Some(name.into()), page: 0, rect, kind }).unwrap(); + } + let mut h = harness_with("selection.pdf", session.save_bytes(id).unwrap().as_ref().clone()); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(3); + for (x, modifiers) in [(47.0, Modifiers::NONE), (97.0, Modifiers::SHIFT)] { + let pos = at(&h, x, 103.0); + h.event(Event::ModifiersChanged(modifiers)); + h.hover_at(pos); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + } + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().views[0].prepare.names().len(), 2); + let blue = Color32::from_rgb(0x14, 0x73, 0xE6); + for (x, y, selected) in [(47.0, 103.0, true), (97.0, 103.0, true), (135.0, 158.0, false)] { + let center = at(&h, x, y); + let shapes: Vec<_> = h.output().shapes.iter().filter(|s| s.clip_rect.contains(center)).collect(); + let tag = shapes.iter().position(|s| { + matches!(&s.shape, Shape::Rect(r) + if r.rect.contains(center) && r.fill == if selected { blue } else { Color32::BLACK }) + }); + assert!(tag.is_some(), "widget at {center:?} must have its selection-colored name tag"); + let outline = shapes.iter().position(|s| { + matches!(&s.shape, Shape::Rect(r) + if r.rect.contains(center) && r.stroke.color == blue && r.stroke.width == 2.0 && r.stroke_kind == StrokeKind::Outside) + }); + if selected { + assert!(outline > tag, "the selected outline must be outside and painted after the tag"); + } else { + assert!(outline.is_none(), "unselected text field has no selection outline"); + } + } + assert_eq!( + h.output() + .shapes + .iter() + .filter(|s| matches!(&s.shape, Shape::Circle(c) + if c.radius == 3.5 && c.stroke.color == blue)) + .count(), + 4, + "only the primary has resize handles" + ); +} + #[test] fn placing_moving_editing_and_deleting_a_field() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/reload.rs b/crates/ui-egui/tests/it/reload.rs index d9e6b142b3..688b10c619 100644 --- a/crates/ui-egui/tests/it/reload.rs +++ b/crates/ui-egui/tests/it/reload.rs @@ -366,3 +366,76 @@ fn opening_a_file_that_is_open_focuses_its_tab() { let app = h.state(); assert_eq!((app.views.len(), app.active, app.views[0].id), (2, Some(0), id), "focused, not opened again"); } + +#[test] +fn a_file_rebuilt_under_an_open_dialog_reloads_when_the_dialog_closes() { + // #3917: a dialog's OK acts on the active document. Reloading it in place while the dialog is + // open would apply the dialog (Rotate, Protect, Comment Properties…) to the new version. + let file = TempPdf::new("under-dialog", &fixture(2)); + let mut h = harness(&file.path); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::RotatePages); + h.run_steps(2); + file.rebuild(&fixture(4)); + h.run_steps(4); + assert_eq!(pages(&h), 2, "not replaced under the dialog"); + file.rebuild(&fixture(5)); + h.run_steps(4); + assert_eq!(pages(&h), 2, "still not replaced under the dialog"); + h.state_mut().dialog = None; + h.run_steps(3); + assert_eq!((pages(&h), dirty(&h)), (5, false), "the newest version is applied once the dialog closes"); +} + +#[test] +fn a_version_held_under_preferences_is_dropped_when_reloading_is_turned_off_there() { + // #3917 review: closing Preferences after unticking "Reload documents when their files + // change on disk" must not still apply the version found while it was open. + let file = TempPdf::new("held-pref-off", &fixture(2)); + let mut h = harness(&file.path); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::RotatePages); + h.run_steps(2); + file.rebuild(&fixture(4)); + h.run_steps(4); + h.state_mut().reload_changed_files = false; + h.state_mut().dialog = None; + h.run_steps(4); + assert_eq!(pages(&h), 2, "reloading was turned off before the dialog closed"); +} + +#[test] +fn a_newer_version_found_as_the_dialog_closes_replaces_the_held_one() { + // #3917 review: only the newest version is applied, not the held one first (which would run + // its scripts, and whose dialogs could hold the newest one back again). Each reload is a new + // edit generation: one is expected. + let file = TempPdf::new("held-coalesced", &fixture(2)); + let mut h = harness(&file.path); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::RotatePages); + h.run_steps(2); + file.rebuild(&fixture(4)); + h.run_steps(4); + // The watcher reads a changed file at its second look: one frame sees the change, and the + // frame that closes the dialog reads it, together with the held version. + file.rebuild(&fixture(5)); + h.step(); + let generation = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().edit_generation(); + let before = generation(&h); + h.state_mut().dialog = None; + h.step(); + assert_eq!((pages(&h), generation(&h) - before), (5, 1), "the held version is not loaded on the way"); +} + +#[test] +fn a_file_rebuilt_under_the_full_screen_prompt_reloads_once_it_is_answered() { + // #3917 review: the full-screen prompt is about the document's current version. + let file = TempPdf::new("under-full-screen", &fixture(2)); + let mut h = harness(&file.path); + let id = h.state().views[0].id; + h.state_mut().pending_full_screen = Some(id); + h.run_steps(2); + file.rebuild(&fixture(4)); + h.run_steps(4); + assert_eq!(pages(&h), 2, "not replaced under the prompt"); + h.state_mut().pending_full_screen = None; + h.run_steps(3); + assert_eq!(pages(&h), 4, "applied once the prompt is gone"); +} diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 341e718c16..852c66657b 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -974,6 +974,53 @@ fn initial_view_is_edited_and_honoured_on_open() { assert_eq!((view.current, view.cover, app.right), (1, true, Some(pdfcraft_ui_egui::RightPanel::Bookmarks))); } +#[test] +fn initial_view_magnification_offers_25_and_100_percent() { + use egui::accesskit::Role; + use pdfcraft_engine::Magnification; + use pdfcraft_ui_egui::canvas::Fit; + fn open_initial_view(h: &mut Harness<'static, PdfCraftApp>) { + h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::InitialView)); + // The dialog's height settles a frame after it opens; egui centers from the previous size. + h.run_steps(6); + } + fn pick(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + h.get_by_role_and_label(Role::ComboBox, "Magnification").click(); + h.run_steps(3); + // The list is taller than the popup, so the item may need scrolling into view. + h.get_by_role_and_label(Role::Button, label).scroll_to_me(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(2); + } + /// Clicks OK, then reopens the saved file: its initial magnification, and the zoom and fit + /// its view opens at. + fn save(h: &mut Harness<'static, PdfCraftApp>) -> (Magnification, f32, Fit) { + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let id = s.views[s.active.unwrap()].id; + let bytes = s.session.save_bytes(id).unwrap(); + let mut app = PdfCraftApp::new(); + app.open_bytes("again.pdf", None, bytes.to_vec()).unwrap(); + let view = &app.views[0]; + (app.session.get(view.id).unwrap().initial_view().magnification, view.zoom, view.fit) + } + let mut h = harness(); + open_initial_view(&mut h); + for (label, percent) in [("100%", 100.0), ("25%", 25.0)] { + pick(&mut h, label); + let (_, v) = h.state().view_draft.as_ref().expect("seeded"); + assert_eq!(v.magnification, Magnification::Percent(percent), "{label}"); + } + assert_eq!(save(&mut h), (Magnification::Percent(25.0), 0.25, Fit::None)); + // 100% is saved as Actual Size is (/XYZ zoom 1), so it reads back as Actual Size. A file + // without an opening zoom also opens at zoom 1, but fitting the width. + open_initial_view(&mut h); + pick(&mut h, "100%"); + assert_eq!(save(&mut h), (Magnification::ActualSize, 1.0, Fit::None)); +} + fn drag(h: &mut Harness<'static, PdfCraftApp>, a: egui::Pos2, b: egui::Pos2) { h.hover_at(a); h.run_steps(1); diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 7751012b8b..0200059910 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -247,10 +247,12 @@ impl Layouter<'_> { match self.caption_reserve(f, inner_w, ctx) { Some((Placement::Top | Placement::Bottom, r)) => ui + r, Some((placement, r)) => { - // A check box's caption without a reserve is drawn in all the box leaves. + // A check box's caption without a reserve is drawn in all the box leaves; any + // other caption beside the widget is as wide as its reserve (XFA 3.3 p. 645), the + // width `draw_field` gives it. let w = match auto_check_reserve(f, placement, inner_w, None) { Some(auto) => auto, - None => inner_w - r, + None => r.min(inner_w), }; let cap = f.caption.as_ref().map(|c| { let rich = match &c.value { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 43d6633c56..d7cfd1ca83 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3030,6 +3030,20 @@ fn a_flowed_subform_is_as_tall_as_its_content_whatever_its_minimum_height() { assert!((top("") - flowed - (216.0 - 14.4)).abs() < 0.01, "{} against {flowed}", top("")); } +#[test] +fn a_caption_beside_a_check_box_wraps_in_its_reserve() { + // A 7.5in check box whose right-hand caption reserves 6.5in: two lines of text, not one or + // two words to a line in the 1in the box keeps. The field after it follows directly. + let words = "Applicant does not authorize the office to permit a participating office access to the application as filed. ".repeat(2); + let body = format!( + r#"{words}"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let w = widgets(&form.pages[0]); + let gap = w[1].rect.y - w[0].rect.y; + assert!(gap < 72.0, "the check box took {gap}pt"); +} + #[test] fn the_form_packet_restores_a_caption_a_script_changed() { let body = r#"