diff --git a/Cargo.lock b/Cargo.lock index fafd037d33..781255619d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17" +source = "git+https://github.com/storytold/craft-libs?rev=0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d#0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" dependencies = [ "craft-winprint-gdi", "log", @@ -4416,6 +4416,7 @@ dependencies = [ "sha2 0.11.1", "stringprep", "thiserror 2.0.21", + "zeroize", ] [[package]] @@ -4656,10 +4657,12 @@ name = "pdfcraft-print" version = "0.18.1" dependencies = [ "craft-print", + "log", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", "pdfcraft-render", + "skrifa 0.44.0", "thiserror 2.0.21", ] @@ -4747,6 +4750,7 @@ dependencies = [ "sha2 0.11.1", "sha3", "thiserror 2.0.21", + "zeroize", ] [[package]] @@ -4808,6 +4812,7 @@ dependencies = [ "web-time", "winsafe", "xattr", + "zeroize", ] [[package]] @@ -8310,7 +8315,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4" +source = "git+https://github.com/storytold/winprint.rs?rev=3c978dfcf3d460577fabd87aeea7a84ffdcfb396#3c978dfcf3d460577fabd87aeea7a84ffdcfb396" dependencies = [ "flate2", "fmt-derive", @@ -8325,6 +8330,7 @@ dependencies = [ "windows", "windows-core", "windows-implement", + "windows-numerics", "xml", ] diff --git a/Cargo.toml b/Cargo.toml index b0f3411624..ab8b964ce8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 # the commit in `craft-i18n.rev`). Already in the tree through craft-ui. craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "0e4943cb5e7b50bd4eb01e8c580b97603c8f9e3d" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. @@ -84,6 +84,7 @@ hayro-jbig2 = "0.3" # foundation thiserror = "2" +zeroize = "1" # Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives # the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it). regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] } diff --git a/ROADMAP.md b/ROADMAP.md index e20839c69b..71573a6ead 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) +> > **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged) > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged) @@ -238,6 +240,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged. + - **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886. - **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft. @@ -330,6 +334,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged. - **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged. - **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged. - **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged. @@ -527,6 +532,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file. - **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. - **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up. - **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`. @@ -827,6 +833,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. | | 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. | | 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. | | 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. | diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index cb3365a4fe..b5effbc118 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -434,7 +434,10 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { for warning in &p.warnings { match warning { pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped" + ); } pdfcraft_render::RenderWarning::ColourFallback => { let _ = diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 837a803e22..797e14e52f 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -475,6 +475,7 @@ fn app_creator<'a>( } app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); + app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index 99c3264006..e91bb94e0a 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -123,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool { /// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` /// follows it, within a few steps (a cycle stops there). -fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { +pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { for _ in 0..8 { match &*doc.get(r) { Object::Ref(next) => r = *next, @@ -340,6 +340,9 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let direct = crate::draw::with_direct_values(doc, raw); let d = direct.as_ref(); let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); + if !is_comment_subtype(&subtype) { + return None; + } // A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one). let style = (subtype == "FreeText").then(|| text_style::text_style(d, None)); let color = match &style { diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 9af83b0542..f8681f899b 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,4 +1,4 @@ -use super::info::text_value; +use super::info::{is_comment_subtype, text_value}; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; @@ -106,16 +106,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result Ok(()) } -/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). +/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]). +pub(crate) fn is_comment_dict(d: &Dict) -> bool { + d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s))) +} + +/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { let p = page_ref(doc, page)?; let mut list = annots(doc, p); let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; let r = match entry { - Object::Ref(r) if doc.get(r).as_dict().is_some() => r, - Object::Dict(d) => { + Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r, + Object::Dict(d) if is_comment_dict(&d) => { let r = doc.add(Object::Dict(d)); - list[index] = Object::Ref(r); + if let Some(slot) = list.get_mut(index) { + *slot = Object::Ref(r); + } set_annots(doc, p, list)?; r } diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index eb77e3f973..c478c98a16 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -133,6 +133,10 @@ fn deleted_index(doc: &Document, page: usize, index: usize) -> Result Value { use pdfcraft_engine::js::Request as R; match r { @@ -14,6 +14,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Beep => json!({ "beep": true }), R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), + R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }), R::ShowPanel(p) => json!({ "panel": p.id() }), } } diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 2fd51038de..37f1ed2cbb 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option { match (&f.actions.calculate, &f.actions.scripts.calculate) { (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), (Calculate::Notation(n), _) => Some(json!({ "notation": n })), - (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })), (Calculate::None, None) => None, } } diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 7bab26e6c1..b57bce0176 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -412,7 +412,9 @@ pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str { use pdfcraft_render::RenderWarning; match warning { - RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", + RenderWarning::ContentTruncated => { + "content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped" + } RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all", 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/printing.rs b/crates/automation/src/printing.rs index 06f7005e27..55fec9ef55 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -1,7 +1,7 @@ //! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print //! dialog options. -use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; +use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed, write_atomic}; @@ -62,8 +62,17 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { /// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default /// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells /// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the -/// caller chose in `options` stands. -fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize, sheets: &[print::Sheet]) -> Result { +/// caller chose in `options` stands. It asks the driver for grayscale only when the pages aren't +/// grey pictures already ([`print::driver_needs_grayscale`]). +fn spool_job( + a: &Args, + printer: &str, + title: String, + duplex: Option, + paper: usize, + raster: Option, + sheets: &[print::Sheet], +) -> Result { let options = match a.get("options") { None => Vec::new(), Some(Value::Object(m)) => m @@ -81,7 +90,7 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option = options.into_iter().chain(print::media_option(paper)).collect(); @@ -174,9 +183,6 @@ impl Automation { let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false)) .map_err(|e| bad(e.to_string()))?; let layout = match a.opt_str("layout")?.unwrap_or("fit") { - "fit" => Layout::Size(SizeMode::Fit), - "actual" => Layout::Size(SizeMode::Actual), - "shrink" => Layout::Size(SizeMode::Shrink), "custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)), "multiple" => { // Custom pages per sheet: `cols` across by `rows` down, both or neither. @@ -221,13 +227,14 @@ impl Automation { s => return Err(bad(format!("unknown booklet_subset {s:?}"))), }, binding: if a.opt_str("binding")? == Some("right") { Binding::Right } else { Binding::Left }, + creep: a.opt_num("creep")?.unwrap_or(0.0), }, "poster" => Layout::Poster { scale: a.opt_num("scale")?.unwrap_or(200.0), overlap: a.opt_num("overlap")?.unwrap_or(18.0), cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true), }, - l => return Err(bad(format!("unknown layout {l:?}"))), + l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?), }; let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) { @@ -242,13 +249,8 @@ impl Automation { "landscape" => Orientation::Landscape, o => return Err(bad(format!("unknown orientation {o:?}"))), }; - let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") { - "document" => Content::Document, - "document-and-markups" => Content::DocumentAndMarkups, - "document-and-stamps" => Content::DocumentAndStamps, - "form-fields-only" => Content::FormFieldsOnly, - c => return Err(bad(format!("unknown comments_forms {c:?}"))), - }; + let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups"); + let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?; let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. @@ -274,7 +276,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, settings.raster, &laid_out)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -290,7 +292,7 @@ mod tests { /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. #[test] fn a_booklet_decides_two_sided_itself() { - let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left, creep: 0.0 }; for asked in ["printer", "off", "long-edge", "short-edge"] { assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); @@ -320,7 +322,7 @@ mod tests { #[test] fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() { let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::>(); - let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options; + let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, None, s).unwrap().options; let media = ("media".to_string(), "Letter".to_string()); let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]); let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] }; @@ -337,7 +339,7 @@ mod tests { fn printing_to_a_printer_feeds_the_laid_out_paper() { let job = |args: Value, paper_name: Option<&str>| { let (index, _) = paper_named(paper_name).unwrap(); - spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap() + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, None, &[]).unwrap() }; let media = |m: &str| ("media".to_string(), m.to_string()); // US Letter when no paper is asked for, after the driver's own options. @@ -353,7 +355,21 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); - assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, &[]).ok().map(|j| j.printer), Some(None)); - assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, &[]).is_err()); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, None, &[]).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, None, &[]).is_err()); + } + + /// #5806: grayscale with Print as image sends grey pictures, so the driver isn't asked for + /// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused. + #[test] + fn grey_pictures_dont_ask_the_driver_for_grayscale() { + let grey = print::Raster { dpi: 300.0, grayscale: true }; + let colour = print::Raster { dpi: 300.0, grayscale: false }; + let job = + |args: Value, raster: Option| spool_job(&Args(&args), "Office", String::new(), None, 0, raster, &[]).unwrap().grayscale; + assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey"); + assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do"); + assert!(job(json!({ "grayscale": true }), Some(colour)), "colour pictures still need the driver"); + assert!(!job(json!({}), None)); } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e1320972b8..4342e162f8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -212,7 +212,7 @@ pub fn tools() -> Vec { )), t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx: when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), - t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") + t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a picture left out because its JBIG2 mask is past a decoder limit, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, @@ -764,6 +764,7 @@ pub fn tools() -> Vec { "auto_rotate": { "type": "boolean" }, "booklet_subset": { "type": "string", "enum": ["both", "front", "back"] }, "binding": { "type": "string", "enum": ["left", "right"] }, + "creep": { "type": "number", "minimum": 0, "maximum": 10, "description": "Booklet creep compensation: points each sheet's pages move toward the fold, times the sheet's place from the outside (default 0)." }, "overlap": { "type": "number", "minimum": 0 }, "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, @@ -773,9 +774,9 @@ pub fn tools() -> Vec { "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, "collate": { "type": "boolean" }, - "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." }, + "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." }, "grayscale": { "type": "boolean" }, - "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." }, + "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, }), @@ -1395,7 +1396,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index bc9d2e9ee5..e0ceba48c0 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6200,6 +6200,9 @@ fn printing_through_tools() { assert_eq!(ok(&mut a, "doc_info", json!({ "doc": printed }))["document"]["pages"].as_u64(), Some(n.div_ceil(4))); let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "layout": "poster", "scale": 400, "path": "poster.pdf" })); assert!(r["sheets"].as_u64().unwrap() > 1); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "layout": "booklet", "creep": 1.5, "path": "booklet.pdf" })); + assert_eq!(r["sheets"].as_u64(), Some(n.div_ceil(4) * 2), "creep keeps the booklet's sheets"); + assert!(a.call("doc_print", &json!({ "doc": doc, "layout": "booklet", "creep": -1, "path": "x.pdf" })).is_err(), "negative creep"); assert!(matches!(a.call("doc_print", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs index 013c287d5d..aba200d3d8 100644 --- a/crates/automation/tests/it/render_warnings.rs +++ b/crates/automation/tests/it/render_warnings.rs @@ -123,6 +123,45 @@ fn page_render_reports_a_recovered_malformed_indexed_image() { let _ = std::fs::remove_dir_all(dir); } +/// A picture whose JBIG2 soft mask is past one of the decoder's limits isn't drawn (#6722), and +/// `page_render` says why the page is incomplete, in the words `pdfcraft-cli render` prints. +#[test] +fn page_render_says_a_picture_was_left_out_for_its_jbig2_mask() { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-jbig2-mask-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // One JBIG2 segment (ISO 14492 §7.4.8): the information of a page 65,536 columns wide, a + // column past the decoder's bound on a side, as the mask's dictionary says too. + let mut mask = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + mask.extend_from_slice(&65536u32.to_be_bytes()); + mask.extend_from_slice(&2u32.to_be_bytes()); + mask.extend_from_slice(&[0; 11]); // resolution, flags, striping + let content = "q 200 0 0 200 0 0 cm /Im Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + content.len(), + mask.len() + ) + .into_bytes(); + pdf.extend_from_slice(&mask); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + std::fs::write(dir.join("mask.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = open(&mut a, "mask.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!( + report["warnings"], + json!(["content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"]) + ); + let _ = std::fs::remove_dir_all(dir); +} + /// Over MCP the warnings are a text block after the image and the call's `structuredContent`. #[cfg(feature = "mcp")] #[test] diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 56286ce4a0..0067304f66 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -246,12 +246,14 @@ pub struct DecodeBudget { bytes: usize, forms: usize, seen: HashSet, + /// Decodes refused for lack of allowance ([`DecodeBudget::refused`]). + refusals: usize, } impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { limit: bytes, bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -264,6 +266,22 @@ impl DecodeBudget { self.bytes } + /// How many decodes were refused for lack of allowance: one that came once it was spent, or + /// ran past what was left. A decode that spent exactly what was left wasn't refused, so an + /// allowance used up to the byte has none. + pub fn refused(&self) -> usize { + self.refusals + } + + /// Whether the allowance is spent and `stream`, which has data, would be refused, noting the + /// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's + /// parameters, work the allowance doesn't count. A stream without data still reads. + pub fn refuses(&mut self, stream: &Stream) -> bool { + let refuses = self.bytes == 0 && !stream.raw.is_empty(); + self.refusals += usize::from(refuses); + refuses + } + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. pub fn exhausted(&self) -> bool { self.bytes == 0 || self.forms == 0 @@ -283,6 +301,51 @@ impl DecodeBudget { self.charge(stream, max, Stream::decoded_within_counted).map(|(data, _)| data) } + /// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as + /// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the + /// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares + /// its data and copies no more of its dictionary than its filters and parameters + /// ([`DecodeBudget::decode_with_damage`] reads it so). A + /// stream so reads here when it reads without an allowance and the allowance can pay for + /// what its stages decode, the output of one stage on top of another's. A stage past `max` + /// is an error that charges what the stages before it decoded and `max`, the work it took to + /// find out; past the stages' total it charges that total, and past what is left it spends + /// all of it, as [`DecodeBudget::decode_within`] does. + pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result, CosError> { + // Before the parameters are resolved, work the allowance doesn't count. + if self.refuses(stream) { + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); + } + let stream = doc.decoding_view(stream); + let total = max.saturating_mul(stream.filters().len().max(1)); + let room = total.min(self.bytes); + match stream.decoded_within_each_counted(max, room) { + Ok((data, _, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Ok(data) + } + // A stage stopped at `max`, below what was left of the room. + Err((FilterError::LimitExceeded(_), spent)) if room.saturating_sub(spent) > max => { + self.bytes = self.bytes.saturating_sub(spent.saturating_add(max)); + Err(CosError::Filter(FilterError::LimitExceeded(max).to_string())) + } + // At the stages' total, with more left. + Err((FilterError::LimitExceeded(_), _)) if total < self.bytes => { + self.bytes = self.bytes.saturating_sub(total); + Err(CosError::Filter(FilterError::LimitExceeded(total).to_string())) + } + Err((FilterError::LimitExceeded(_), _)) => { + self.bytes = 0; + self.refusals += 1; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) + } + Err((e, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Err(CosError::Filter(e.to_string())) + } + } + } + /// [`DecodeBudget::decode_within`] (`usize::MAX` for no bound of its own), with the stream's /// `/DecodeParms` given by reference read in `doc`, also saying whether the stream is damaged: /// `true` when a filter stopped at corrupt data, so the bytes are only what decoded before it. @@ -304,7 +367,7 @@ impl DecodeBudget { /// pays for the bytes decoded before the error, so referencing a stream again and again /// spends the allowance by the work each decode does. fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec, bool), CosError> { - if self.bytes == 0 && !stream.raw.is_empty() { + if self.refuses(stream) { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } if max < self.bytes { @@ -324,6 +387,7 @@ impl DecodeBudget { } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; + self.refusals += 1; Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } Err((e, spent)) => { @@ -1025,4 +1089,99 @@ mod tests { assert!(budget.decode_strict(&Document::new_empty(), &chained).is_ok()); assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); } + + /// `decode_stages_within` holds every stage to the limit, as a decode without an allowance + /// does, and the stages together to the limit for each of them. A stream behind ASCIIHex and + /// Flate whose stages each stay within it reads, the hex stage's output charged on top; one + /// whose Flate stage would make more stops there, charged what the hex stage made and the + /// limit, the rest of the allowance left; a Flate stage making hex text past the limit leaves + /// the stream unread though the total has room, as `Stream::decoded_within` leaves it; and a + /// stream past what is left spends all of it. + #[test] + fn each_stage_is_held_to_the_limit() { + let chained = |data: &[u8], filters: [&str; 2]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(filters.into_iter().map(Object::name).collect())); + Stream::from_raw(dict, data.to_vec()) + }; + let hexed = |data: &[u8]| { + let flate = Stream::flate(Dict::new(), data); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + (chained(&hex, ["ASCIIHexDecode", "FlateDecode"]), flate.raw.len()) + }; + let max = 1 << 16; + let (fits, inner) = hexed(&vec![0u8; max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max)); + assert_eq!(budget.remaining(), (1 << 20) - inner - max); + let (bomb, inner) = hexed(&vec![0u8; 8 * max]); + let mut budget = DecodeBudget::new(1 << 20, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err()); + assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit"); + // Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together + // make 1.8 times the limit, within twice it, but the Flate stage alone is past it. + let text = "00".repeat(max * 6 / 10).into_bytes(); + let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]); + assert!(wide.decoded_within(max).is_err()); + assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err()); + let mut budget = DecodeBudget::new(max, 0); + assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err()); + assert_eq!(budget.remaining(), 0); + } + + /// What `decode_stages_within` charges, and which limit it reports, on each way a read stops: + /// a stage at the stream's limit with more of the room left charges what came before and the + /// limit; at the stages' total, with more of the allowance left, it charges that total; at + /// what is left of the allowance it spends all of it and reports the allowance's limit, as + /// `decode_within` does. Each boundary between them is read on both sides. + #[test] + fn a_stage_past_its_limit_charges_what_came_before_and_the_limit() { + let max = 1 << 12; + let hex = |data: &[u8]| data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect::>(); + let two = |raw: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex(raw)) + }; + let says = |read: Result, CosError>, limit: usize| { + read.err().map(|e| e.to_string()) == Some(CosError::Filter(FilterError::LimitExceeded(limit).to_string()).to_string()) + }; + // Flate data making eight times the limit: the hex stage makes `inner` bytes of it, and + // the Flate stage stops at the limit. + let bomb = Stream::flate(Dict::new(), &vec![0u8; 8 * max]).raw.to_vec(); + let inner = bomb.len(); + // A byte more of the room than the hex stage and the limit: the Flate stage stops at the + // limit, so that is what it charges. + let mut budget = DecodeBudget::new(inner + max + 1, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max)); + assert_eq!(budget.remaining(), 1); + // Exactly the hex stage and the limit: what was left stopped it as much as the limit did, + // so it is past the allowance, spends it and reports the allowance's limit. + let mut budget = DecodeBudget::new(inner + max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max)); + assert_eq!(budget.remaining(), 0); + // The same data padded to exactly the limit: the hex stage makes all of it, and the Flate + // stage, at the limit, reaches the stages' total. With more of the allowance left, that + // total is what it charges and reports. + let mut padded = bomb.clone(); + padded.resize(max, 0); + let mut budget = DecodeBudget::new(10 * max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max)); + assert_eq!(budget.remaining(), 8 * max); + // With exactly the total left (of an allowance of three times the limit), it is past the + // allowance: all of it is spent, and the allowance's limit reported. + let mut budget = DecodeBudget::new(3 * max, 0); + budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap(); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max)); + assert_eq!(budget.remaining(), 0); + // With less than the total left, the same. + let mut budget = DecodeBudget::new(max, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max)); + assert_eq!(budget.remaining(), 0); + // With nothing left, a stream with data is refused before it is decoded, even one that + // decodes to nothing; an empty one reads. + let mut budget = DecodeBudget::new(0, 0); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0)); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new())); + } } diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index f40f175d69..f2c9760c12 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -351,10 +351,12 @@ impl Stream { } /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data - /// decoded before the damage is returned). Streams with image codecs fail with + /// decoded before the damage is returned). Decoding past + /// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one + /// stream, is an error. Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. pub fn decoded(&self) -> Result, CosError> { - self.decoded_within(MAX_DECODED) + self.decoded_within(crate::budget::MAX_PAGE_DECODED) } /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than @@ -382,6 +384,26 @@ impl Stream { self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) } + /// [`Stream::decoded_within_counted`] with every filter stage also held to `each`, as + /// [`Stream::decoded_within`] holds it, while `max` bounds the stages together + /// ([`pdfcraft_filters::decode_tolerant_counted_each`]). Without filters the data is held to + /// both. + pub(crate) fn decoded_within_each_counted( + &self, + each: usize, + max: usize, + ) -> Result<(Vec, bool, usize), (pdfcraft_filters::FilterError, usize)> { + let mut damaged = false; + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant_counted_each(chain, data, each.min(MAX_DECODED), max).map(|(v, partial, spent)| { + damaged = partial; + (v, spent) + }) + }; + let max = if self.readable_filters().is_ok_and(|chain| chain.is_empty()) { max.min(each) } else { max }; + self.decode_chain(max, decode).map(|(v, spent)| (v, damaged, spent)) + } + /// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a /// document's images ([`pdfcraft_filters::decode_tolerant_within`]). pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result, CosError> { @@ -780,6 +802,13 @@ mod tests { assert_eq!(stream.decoded_strict().unwrap(), b"A"); } + #[test] + fn decoded_stops_at_renderer_cap_of_256_mib() { + let data = vec![0u8; (256 << 20) + 1]; + let stream = Stream::flate(Dict::new(), &data); + assert!(matches!(stream.decoded(), Err(CosError::Filter(_)))); + } + #[test] fn first_filter_decodes_only_the_outer_wrapper() { let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes(); diff --git a/crates/crypt/Cargo.toml b/crates/crypt/Cargo.toml index 9edb008f2c..dcc7d2b935 100644 --- a/crates/crypt/Cargo.toml +++ b/crates/crypt/Cargo.toml @@ -16,6 +16,7 @@ md-5 = "0.11" sha2 = "0.11" stringprep = "0.1" thiserror.workspace = true +zeroize.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 9478e74130..8b520d281d 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -19,6 +19,7 @@ mod rc4; use md5::{Digest as _, Md5}; use sha2::{Sha256, Sha384, Sha512}; +use zeroize::Zeroizing; pub use aes_cbc::{aes_cbc_decrypt, aes_cbc_encrypt}; pub use rc4::rc4; @@ -147,9 +148,9 @@ pub enum StreamKind<'a> { #[derive(Clone, Debug)] pub struct SecurityHandler { dict: EncryptDict, - key: Vec, + key: Zeroizing>, auth: Auth, - recovered_user: Option>, + recovered_user: Option>>, methods: Methods, } @@ -190,7 +191,7 @@ impl Methods { } impl SecurityHandler { - fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + fn new(dict: EncryptDict, key: Zeroizing>, auth: Auth, recovered_user: Option>>) -> Self { let methods = Methods::new(&dict); Self { dict, key, auth, recovered_user, methods } } @@ -248,7 +249,7 @@ impl SecurityHandler { if methods.string != Method::Identity || methods.stream != Method::Identity { return None; } - Some(Self { dict, key: Vec::new(), auth: Auth::User, recovered_user: None, methods }) + Some(Self { dict, key: Zeroizing::new(Vec::new()), auth: Auth::User, recovered_user: None, methods }) } /// Whether the handler has the file key: false for [`SecurityHandler::without_key`]. @@ -289,7 +290,7 @@ impl SecurityHandler { /// For R2–R4 documents opened with the owner password: the user password, which owner /// authentication recovers (Algorithm 7). Lets components that only accept user passwords /// open the document too. - pub fn recovered_user_password(&self) -> Option> { + pub fn recovered_user_password(&self) -> Option>> { self.recovered_user.clone() } @@ -306,7 +307,7 @@ impl SecurityHandler { if !matches!(self.dict.r, 5 | 6) || !self.has_key() { return None; } - let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let plain = Zeroizing::new(aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false)); let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } @@ -348,7 +349,7 @@ impl SecurityHandler { /// the same way, so a page it shows is one this crate can read and save. AES-128 keys are /// left as they are: `AESV2` takes a 128-bit key (Table 25), and Acrobat's extension is only /// seen with RC4. Every key PdfCraft writes under `/V 4` is 16 bytes already. - fn object_key(&self, method: Method, num: u32, generation: u16) -> Vec { + fn object_key(&self, method: Method, num: u32, generation: u16) -> Zeroizing> { if method == Method::Aes256 { return self.key.clone(); } @@ -363,8 +364,9 @@ impl SecurityHandler { if method == Method::Aes128 { h.update(b"sAlT"); } - let digest = h.finalize(); - digest[..(key.len() + 5).min(16)].to_vec() + let mut digest = Zeroizing::new(h.finalize().to_vec()); + digest.truncate((key.len() + 5).min(16)); + digest } fn apply(&self, method: Method, num: u32, generation: u16, data: &[u8], encrypt: bool) -> Vec { @@ -456,31 +458,31 @@ pub fn pdfdoc_byte(c: char) -> Option { /// Revisions 2–4: the password in PDFDocEncoding (§7.6.4.3.2, Algorithm 2), at most 32 bytes. /// Characters PDFDocEncoding lacks are dropped. -fn legacy_password(pw: &str) -> Vec { - pw.chars().filter_map(pdfdoc_byte).take(32).collect() +fn legacy_password(pw: &str) -> Zeroizing> { + Zeroizing::new(pw.chars().filter_map(pdfdoc_byte).take(32).collect()) } /// The bytes an R2–R4 password may have been written as: PDFDocEncoding, as the specification /// asks; and for a password with characters PDFDocEncoding lacks (ğ, ş, İ, …), which it gives /// no bytes, also its UTF-8 bytes, which qpdf and other tools write for such passwords. -fn legacy_candidates(pw: &str) -> Vec> { +fn legacy_candidates(pw: &str) -> Vec>> { let mut out = vec![legacy_password(pw)]; if pw.chars().any(|c| pdfdoc_byte(c).is_none()) { - out.push(pw.bytes().take(32).collect()); + out.push(Zeroizing::new(pw.bytes().take(32).collect())); } out } /// Revisions 5–6: SASLprep (RFC 4013), UTF-8, at most 127 bytes. -fn sasl_password(pw: &str) -> Vec { - let prepped = stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string()); - let mut bytes = prepped.into_bytes(); +fn sasl_password(pw: &str) -> Zeroizing> { + let prepped = Zeroizing::new(stringprep::saslprep(pw).map(|c| c.into_owned()).unwrap_or_else(|_| pw.to_string())); + let mut bytes = Zeroizing::new(prepped.as_bytes().to_vec()); bytes.truncate(127); bytes } -fn pad(pw: &[u8]) -> [u8; 32] { - let mut out = PADDING; +fn pad(pw: &[u8]) -> Zeroizing<[u8; 32]> { + let mut out = Zeroizing::new(PADDING); let n = pw.len().min(32); out[..n].copy_from_slice(&pw[..n]); out[n..].copy_from_slice(&PADDING[..32 - n]); @@ -494,20 +496,20 @@ fn key_len(d: &EncryptDict) -> usize { // ── Revisions 2–4 ────────────────────────────────────────────────────────────────────────────── /// Algorithm 2: file key from a (user) password. -fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Vec { +fn file_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Zeroizing> { let n = key_len(d); let mut h = Md5::new(); - h.update(pad(pw)); + h.update(*pad(pw)); h.update(&d.o[..d.o.len().min(32)]); h.update(d.p.to_le_bytes()); h.update(id0); if d.r >= 4 && !d.encrypt_metadata { h.update([0xFF; 4]); } - let mut digest = h.finalize().to_vec(); + let mut digest = Zeroizing::new(h.finalize().to_vec()); if d.r >= 3 { for _ in 0..50 { - digest = Md5::digest(&digest[..n]).to_vec(); + digest = Zeroizing::new(Md5::digest(&digest[..n]).to_vec()); } } digest.truncate(n); @@ -524,7 +526,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { h.update(id0); let mut x = rc4(key, &h.finalize()); for i in 1..=19u8 { - let k: Vec = key.iter().map(|b| b ^ i).collect(); + let k = Zeroizing::new(key.iter().map(|b| b ^ i).collect::>()); x = rc4(&k, &x); } x.extend_from_slice(&[0u8; 16]); @@ -536,7 +538,7 @@ fn u_value_r4(d: &EncryptDict, id0: &[u8], key: &[u8]) -> Vec { /// Some producers ignore `/EncryptMetadata false` when deriving the key (they omit the /// `FF FF FF FF` of Algorithm 2, step f). Viewers open those files, so if the strict derivation /// fails we retry the other way. -fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option> { +fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option>> { let check = |d: &EncryptDict| { let key = file_key_r4(d, id0, pw); let u = u_value_r4(d, id0, &key); @@ -547,54 +549,57 @@ fn user_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option> { } /// The RC4 key derived from an owner password (Algorithm 3, steps a–d). -fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Vec { +fn owner_rc4_key(d: &EncryptDict, owner_pw: &[u8]) -> Zeroizing> { let n = key_len(d); - let mut digest = Md5::digest(pad(owner_pw)).to_vec(); + let mut digest = Zeroizing::new(Md5::digest(*pad(owner_pw)).to_vec()); if d.r >= 3 { for _ in 0..50 { - digest = Md5::digest(&digest).to_vec(); + digest = Zeroizing::new(Md5::digest(&*digest).to_vec()); } } digest.truncate(n); digest } +type SecretBytes = Zeroizing>; + /// Algorithm 7: authenticate an owner password (recover the user password from `/O`). /// Returns the file key and the recovered user password (padding removed). -fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(Vec, Vec)> { +fn owner_key_r4(d: &EncryptDict, id0: &[u8], pw: &[u8]) -> Option<(SecretBytes, SecretBytes)> { let rk = owner_rc4_key(d, pw); - let mut user = d.o[..d.o.len().min(32)].to_vec(); + let mut user = Zeroizing::new(d.o[..d.o.len().min(32)].to_vec()); if d.r == 2 { - user = rc4(&rk, &user); + user = Zeroizing::new(rc4(&rk, &user)); } else { for i in (0..=19u8).rev() { - let k: Vec = rk.iter().map(|b| b ^ i).collect(); - user = rc4(&k, &user); + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); + user = Zeroizing::new(rc4(&k, &user)); } } let key = user_key_r4(d, id0, &user)?; // The recovered value is the padded password: strip the padding suffix. let len = (0..=32).find(|&i| user.len() >= 32 && user[i..32] == PADDING[..32 - i]).unwrap_or(user.len().min(32)); - Some((key, user[..len].to_vec())) + user.truncate(len); + Some((key, user)) } // ── Revisions 5–6 ────────────────────────────────────────────────────────────────────────────── /// The hash of a password for R5 (SHA-256) or R6 (Algorithm 2.B). -fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { - let mut k = { +fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Zeroizing> { + let mut k = Zeroizing::new({ let mut h = Sha256::new(); h.update(pw); h.update(salt); h.update(udata); h.finalize().to_vec() - }; + }); if r == 5 { return k; } let mut round = 0u32; loop { - let mut k1 = Vec::with_capacity(64 * (pw.len() + k.len() + udata.len())); + let mut k1 = Zeroizing::new(Vec::with_capacity(64 * (pw.len() + k.len() + udata.len()))); for _ in 0..64 { k1.extend_from_slice(pw); k1.extend_from_slice(&k); @@ -603,13 +608,13 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { // `k` is always a SHA-2 digest (32, 48 or 64 bytes): key = its first 16 bytes, IV = the next 16. let Some((key, rest)) = k.split_first_chunk::<16>() else { break }; let Some(iv) = rest.first_chunk::<16>() else { break }; - let e = aes_cbc_encrypt(key, iv, &k1, false); + let e = Zeroizing::new(aes_cbc_encrypt(key, iv, &k1, false)); let m = e.iter().take(16).map(|b| u32::from(*b)).sum::() % 3; - k = match m { - 0 => Sha256::digest(&e).to_vec(), - 1 => Sha384::digest(&e).to_vec(), - _ => Sha512::digest(&e).to_vec(), - }; + k = Zeroizing::new(match m { + 0 => Sha256::digest(&*e).to_vec(), + 1 => Sha384::digest(&*e).to_vec(), + _ => Sha512::digest(&*e).to_vec(), + }); round += 1; if round >= 64 && e.last().is_none_or(|b| u32::from(*b) <= round - 32) { break; @@ -620,19 +625,19 @@ fn hash_r6(r: i64, pw: &[u8], salt: &[u8], udata: &[u8]) -> Vec { } /// Algorithm 2.A: authenticate as owner (`owner = true`) or user; returns the file key. -fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>, CryptError> { +fn key_r6(d: &EncryptDict, pw: &[u8], owner: bool) -> Result>>, CryptError> { if d.u.len() < 48 || d.o.len() < 48 { return Err(CryptError::Malformed("/O and /U must be 48 bytes for R5/R6".into())); } let (hashed, encrypted, udata) = if owner { (&d.o[..48], &d.oe, &d.u[..48]) } else { (&d.u[..48], &d.ue, &[][..]) }; - if hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { + if *hash_r6(d.r, pw, &hashed[32..40], udata) != hashed[..32] { return Ok(None); } if encrypted.len() < 32 { return Err(CryptError::Malformed("/OE or /UE must be 32 bytes".into())); } let k = hash_r6(d.r, pw, &hashed[40..48], udata); - Ok(Some(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false))) + Ok(Some(Zeroizing::new(aes_cbc_decrypt(&k, &[0; 16], &encrypted[..32], false)))) } // ── Creating encryption ──────────────────────────────────────────────────────────────────────── @@ -654,7 +659,7 @@ pub enum Algorithm { /// dropped), at most 32 bytes; for R6 its SASLprep (RFC 4013: soft hyphens and the like removed, /// other spaces made plain, then NFKC; the password as given if SASLprep refuses it), in UTF-8, /// at most 127 bytes. -fn prepared_password(algorithm: Algorithm, password: &str) -> Vec { +fn prepared_password(algorithm: Algorithm, password: &str) -> Zeroizing> { match algorithm { Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => legacy_password(password), Algorithm::Aes256 => sasl_password(password), @@ -672,7 +677,7 @@ pub fn effective_password(algorithm: Algorithm, password: &str) -> Vec { let prepared = prepared_password(algorithm, password); match algorithm { Algorithm::Rc4_40 | Algorithm::Rc4_128 | Algorithm::Aes128 => pad(&prepared).to_vec(), - Algorithm::Aes256 => prepared, + Algorithm::Aes256 => prepared.to_vec(), } } @@ -737,10 +742,10 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result= 3 { for i in 1..=19u8 { - let k: Vec = rk.iter().map(|b| b ^ i).collect(); + let k = Zeroizing::new(rk.iter().map(|b| b ^ i).collect::>()); o = rc4(&k, &o); } } @@ -754,15 +759,15 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result> = &h.key; + let _: &Option>> = &h.recovered_user; + assert_eq!(h.recovered_user_password().as_deref().map(Vec::as_slice), Some(&b"user pw"[..]), "{alg:?}"); assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap().recovered_user_password(), None); } let d = new(Algorithm::Rc4_128, "", "owner", -1).dict().clone(); - assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref(), Some(&b""[..])); + assert_eq!(SecurityHandler::open(d, ID, Some("owner")).unwrap().recovered_user_password().as_deref().map(Vec::as_slice), Some(&b""[..])); } /// /O, /U and the first /ID of files qpdf 12.4.2 wrote (`--static-id`), with the file keys @@ -393,7 +395,7 @@ fn legacy_passwords_are_pdfdoc_encoded_with_a_utf8_fallback() { assert_eq!((user.auth(), user.file_key().to_vec()), (Auth::User, hex("3564ffcea186122595ad55ee157da71f"))); let owner = SecurityHandler::open(r3.clone(), &id, Some("Öğretmen")).unwrap(); assert_eq!((owner.auth(), owner.file_key().to_vec()), (Auth::Owner, hex("3564ffcea186122595ad55ee157da71f"))); - assert_eq!(owner.recovered_user_password(), Some(vec![0x9A, b'l', 0x9A, b'k', 0xA0])); + assert_eq!(owner.recovered_user_password(), Some(Zeroizing::new(vec![0x9A, b'l', 0x9A, b'k', 0xA0]))); // Dropping ı and € (the old Latin-1 conversion) or a near miss doesn't open it. for wrong in ["lk", "ilik€", "ılık", "Ogretmen"] { assert_eq!(SecurityHandler::open(r3.clone(), &id, Some(wrong)).unwrap_err(), CryptError::WrongPassword, "{wrong}"); @@ -423,6 +425,6 @@ fn pdfdoc_encoding_round_trips() { for c in ['ğ', 'ş', 'İ', '\u{A0}', '\u{85}', 'Ж', '日'] { assert_eq!(pdfdoc_byte(c), None, "{c}"); } - assert_eq!(legacy_candidates("Çok"), [vec![0xC7, b'o', b'k']]); - assert_eq!(legacy_candidates("şifre"), [b"ifre".to_vec(), "şifre".as_bytes().to_vec()]); + assert_eq!(legacy_candidates("Çok"), [Zeroizing::new(vec![0xC7, b'o', b'k'])]); + assert_eq!(legacy_candidates("şifre"), [Zeroizing::new(b"ifre".to_vec()), Zeroizing::new("şifre".as_bytes().to_vec())]); } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index c4a39bf689..e269fda375 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -61,6 +61,15 @@ impl PageImage { self.clips.iter().any(|clip| clip.app_crop) } + /// The editing frame, respecting explicit PDF/editor crops but not the page boundary. + /// Selected handles remain reachable outside the page; body hits still use `contains`. + pub fn frame_rect(&self) -> Option<[f64; 4]> { + self.clips.iter().try_fold(self.rect, |rect, clip| { + let b = clip.path.bounding_box(); + intersection(rect, [b.x0, b.y0, b.x1, b.y1]) + }) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 57009ce99e..f2033a64de 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -13628,6 +13628,25 @@ fn page_rules_refuse_unread_graphics_state() { assert_eq!(page_rules(&image_page_with("10 10 m 100 10 l S"), 0).unwrap(), [[10.0, 10.0, 100.0, 10.0]]); } +#[test] +fn image_editing_frame_keeps_page_edge_outside_explicit_clips() { + for (prefix, expected) in [ + ("", [-30.0, -20.0, 70.0, 80.0]), + ("-10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ("/PdfCraftImageCrop MP -10 -5 60 65 re W n ", [-10.0, -5.0, 50.0, 60.0]), + ] { + let doc = image_page_with(&format!("q {prefix}100 0 0 100 -30 -20 cm /Im0 Do Q")); + for doc in [&doc, &reopen(&doc)] { + let im = images::page_images(doc, 0).unwrap().remove(0); + assert_eq!(im.frame_rect(), Some(expected)); + assert_eq!(im.has_editor_crop(), prefix.starts_with("/PdfCraftImageCrop")); + assert_eq!(im.visible_rect.unwrap()[0..2], [0.0, 0.0]); + assert!(!im.contains([-5.0, 10.0]), "out-of-page pixels stay unselectable"); + assert!(im.contains([10.0, 10.0])); + } + } +} + #[test] fn explicit_builtin_thai_replaces_an_unavailable_source_without_installing_a_font() { let mut doc = text_page("BT /F1 16 Tf 50 700 Td (A) Tj ET BT /F2 12 Tf 50 600 Td (ab) Tj ET"); @@ -14295,6 +14314,29 @@ fn legacy_thai_subsets_are_read_within_one_budget_per_edit() { assert_eq!(pdfcraft_cos::write_full(&distinct, &Default::default()).unwrap(), before, "a refused edit changes nothing"); } +/// An edit whose THSarabunPSK subset spends the edit's allowance to the byte goes through: only a +/// read refused for want of it refuses the edit. The subset is read four times over its program +/// (the edit's metrics read, `LegacyThaiFont`'s, the editor's program read and its metrics read) +/// and three times over its ToUnicode, so a program 192 bytes short of 16 MiB and a ToUnicode of +/// 256 bytes cost exactly the 64 MiB; a byte more of ToUnicode is three past it. +#[test] +fn a_thai_edit_that_spends_its_allowance_to_the_byte_goes_through() { + let mut program = legacy_thai_program(); + program.resize((16 << 20) - 192, 0); + let edit = |unicode_len: usize| { + let mut unicode = LEGACY_THAI_UNICODE.to_vec(); + unicode.resize(unicode_len, b' '); + let mut doc = text_page("BT /F1 16 Tf 50 650 Td <00d6> Tj ET"); + let font = legacy_thai_font(&mut doc, "ABCDEF+THSarabunPSK", &program, &unicode); + doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font)); + let style = BlockStyle { width: Some(400.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("กขค"), &style).map(|_| ()).map_err(|e| e.to_string()) + }; + assert_eq!(edit(256), Ok(())); + let error = edit(257).unwrap_err(); + assert!(error.contains("THSarabunPSK") && error.contains("MiB of font data"), "{error}"); +} + /// A THSarabunPSK-named subset that has OpenType Thai mark positioning is a modern face: it is /// shaped with its own layout like any source font, not by the legacy shaper (which would ask /// for Windows PUA alternates such a face doesn't need). diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 487bb0e20e..9c2732c35f 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -116,8 +116,11 @@ struct LegacyFace { space: f64, } -/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets -/// (each at most 16 MiB): a page may name many of them. +/// What one edit may decode of its THSarabunPSK subsets' font data: their programs (each at most +/// 16 MiB), which it keeps, and the streams of the metrics read to measure them and to adapt their +/// programs' cmaps: their CMaps and glyph maps, and a subset's program again, for which of its +/// glyphs have outlines. A page may name many of them. The metrics read to find the subsets among +/// the page's fonts aren't charged to it. const MAX_THAI_PROGRAMS: usize = 64 << 20; struct PreparedCharacters { @@ -900,6 +903,9 @@ fn legacy_thai_font( } let family = pdfcraft_fonts::embedding::face_key(&source.base_font); let mut candidates = vec![source.font.clone()]; + // Every name on the page is read here for its face name alone, aliases of one font and other + // faces' fonts too, with no total, as before: charged to the edit's allowance, each alias would + // pay again and other faces would spend what the face's subsets need. candidates.extend(fonts.iter().filter_map(|(name, font)| { let font = doc.dict(font)?; (pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned()) @@ -914,9 +920,15 @@ fn legacy_thai_font( let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) { Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias }, None => { - let metrics = Metrics::from_dict(doc, &font); - let space = source_space(doc, fonts, &metrics); - let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new); + // Read within the edit's allowance; past it the subset goes unread, and the edit is + // refused below. + let metrics = Metrics::from_dict_within(doc, &font, budget); + let (space, glyphs) = if metrics.past_allowance { + (0.0, None) + } else { + let space = source_space(doc, fonts, &metrics); + (space, pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new)) + }; let style = FontStyle::of(doc, &font, &metrics); LegacyFace { glyphs, metrics, style, object, space } } @@ -924,9 +936,11 @@ fn legacy_thai_font( cache.insert(name, face); } // A subset left unread would count as unusable, silently changing which subset draws the text. - if budget.remaining() == 0 { + // A read refused for want of the allowance is what refuses the edit: reads that spent it to the + // byte refused nothing. + if budget.refused() > 0 { return Err(EditError::Invalid(format!( - "the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text", + "the page's {} subsets need more than {} MiB of font data for one edit; choose a replacement font for this text", source.base_font.rsplit('+').next().unwrap_or(&source.base_font), MAX_THAI_PROGRAMS >> 20 ))); diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index eb213f3f64..2694e90492 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -420,13 +420,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi if let Some((added_idx, added_item)) = matched_added && let AddedContent::Image(ai) = added_item.content.clone() { + // Page visibility alone is not a crop: dragging an added image over the page edge + // must not discard its editable added-item identity on the next resize. let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) - || img.visible_rect.is_some_and(|v| { - (v[0] - img.rect[0]).abs() > 1e-4 - || (v[1] - img.rect[1]).abs() > 1e-4 - || (v[2] - img.rect[2]).abs() > 1e-4 - || (v[3] - img.rect[3]).abs() > 1e-4 - }); + || img.frame_rect().is_none_or(|frame| frame.iter().zip(img.rect).any(|(a, b)| (a - b).abs() > 1e-4)); let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); let should_promote = has_active_crop diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 31f1aed19e..d2b698a370 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -81,13 +81,13 @@ impl JsOutput { } /// Adds what a field's own script produced: its alerts, console lines, errors and requests, - /// except a request to show a side panel or open Save As, which is noted on the console - /// and dropped. + /// except a request to show a side panel, open Save As, or open or save an attached file, + /// which is noted on the console and dropped. fn absorb(&mut self, o: &Outcome) { self.alert(o.alerts.iter().cloned()); self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel, or open Save As. + // button's may show or hide a side panel, open Save As, or open or save an attached file. // The warnings go through `say` like any other console line, so they count against the // line and byte caps instead of growing the console past them. let mut notes = Vec::new(); @@ -95,6 +95,9 @@ impl JsOutput { match r { Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), + Request::OpenAttachment { name, .. } => { + notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file")) + } r => self.requests.push(r.clone()), } } @@ -510,10 +513,12 @@ impl Session { } let doc = self.get(id).ok_or(EditError::NoDocument)?; let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - // A button PdfCraft generated from an XFA template runs its XFA click script. + // A button PdfCraft generated from an XFA template runs its XFA click script (the app runs + // click scripts for buttons only, so other fields' click events don't run here either). if let Some(t) = target && let Some(tpl) = doc.xfa_template.clone() && let Some(field) = doc.form.iter().find(|f| f.name == t) + && field.kind == crate::FormFieldKind::PushButton && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { // The click returns only the output of its own edit; output from opening or from earlier edits stays. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3912bfd5ec..741730453c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11338,6 +11338,61 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// `run_javascript` runs an XFA click script only for a push button, as the app does: a text +/// field with a click event of its own, named as the target, asks to open no attached file. +#[test] +fn run_javascript_runs_xfa_click_scripts_only_for_buttons() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + let o = s.run_javascript(id, "", Some("guide")).unwrap(); + assert_eq!(o.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "the button's click: {o:?}"); + let o = s.run_javascript(id, "", Some("note")).unwrap(); + assert!(o.requests.is_empty(), "a text field's click script doesn't run here: {o:?}"); +} + +/// In an XFA form a button's click script may open an attached file; the same request from a +/// text field's exit script is noted in the console and not made. +#[test] +fn an_xfa_button_may_open_an_attached_file_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].guide[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click opens a file: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("open an attached file")), "{:?}", out.console); +} + /// A field whose layout:ready script only reads the page count keeps the value its script gives /// it: only a field the script gives the page call's result is numbered by the layout. #[test] @@ -13241,6 +13296,25 @@ fn redacting_an_optimized_jbig2_page_stores_the_rest_of_its_group_again() { assert_eq!(named, 0, "no image names the dictionary, which left with them"); } +#[test] +fn resizing_an_added_image_back_inside_the_page_keeps_its_identity() { + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 2, 2); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap(); + } + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([-30.0, -20.0, 70.0, 80.0]), name: "edge.png".into(), bytes: Arc::new(png) }).unwrap(); + let original = s.get(id).unwrap().added()[0].obj; + let target = [10.0, 10.0, 60.0, 60.0]; + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(target) }).unwrap(); + let added = s.get(id).unwrap().added(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].obj, original); + assert_eq!(added[0].content.rect(), target); +} + #[test] fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { let (mut session, id) = rotated_blank(0); diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 393b997af5..9b28320a55 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::OpenAttachment { .. } => Some("open an attached file"), XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, @@ -910,6 +911,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }), XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs index 5739c57160..be2ff1883e 100644 --- a/crates/engine/src/xfa_form.rs +++ b/crates/engine/src/xfa_form.rs @@ -15,13 +15,10 @@ pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) + let signed = |doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field| { + f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) + }; + pdfcraft_forms::redraw_fields(doc, |doc, f| !signed(doc, f)).map_err(|e| e.to_string()) } /// A warning for each field whose appearance PdfCraft drew with `?` for a character its font diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 8c250ca484..e2b3379ac2 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -1,5 +1,5 @@ use super::as_docx::{hex, rgb8}; -use super::layout::{Item, Page, items}; +use super::layout::{Item, Page, Run, Table, items}; /// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, /// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them @@ -94,6 +94,64 @@ pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<& html_with_mode(pages, title, true, language) } +fn style_color(color: [f64; 3], simple: bool) -> String { + if simple { String::new() } else { hex(color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) } +} + +fn wrap_inline(mut text: String, bold: bool, italic: bool) -> String { + if text.trim().is_empty() { + return text; + } + if italic { + text = format!("{text}"); + } + if bold { + text = format!("{text}"); + } + text +} + +fn para_html(runs: &[Run<'_>], lvl: u8, simple: bool) -> Option { + let non_empty: Vec<_> = runs.iter().map(|r| (r.block, esc(&r.text))).filter(|(_, text)| !text.is_empty()).collect(); + let &(first, _) = non_empty.iter().find(|(_, text)| !text.trim().is_empty())?; + let mut t = String::new(); + for (block, text) in non_empty { + if text.trim().is_empty() { + t.push_str(&text); + continue; + } + let mut run = wrap_inline(text, block.bold && lvl == 0, block.italic); + // The paragraph has its first visible run's colour; a run in another colour says so. + let colour = rgb8(block.color); + if !simple && colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = style_color(first.color, simple); + Some(match lvl.min(6) { + 0 => format!("{t}

\n"), + l => format!("{t}\n"), + }) +} + +fn table_html(t: &Table, simple: bool) -> String { + let mut s = String::from("\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let body = wrap_inline(esc(&c.text), c.bold, c.italic); + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = style_color(c.color, simple); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + s +} + fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String { let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default(); let mut s = format!( @@ -108,50 +166,11 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s for it in items(pages) { match it { Item::Para(runs, lvl) => { - let Some(first) = runs.first().map(|r| r.block) else { continue }; - let mut t = String::new(); - for r in &runs { - let mut run = esc(&r.text); - if r.block.italic { - run = format!("{run}"); - } - if r.block.bold && lvl == 0 { - run = format!("{run}"); - } - // The paragraph has its first run's colour; a run in another colour says so. - let colour = rgb8(r.block.color); - if !simple && colour != rgb8(first.color) { - let [red, green, blue] = colour; - run = format!("{run}"); - } - t.push_str(&run); - } - let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), + if let Some(p) = para_html(&runs, lvl, simple) { + s.push_str(&p); } } - Item::Table(t) => { - s.push_str("\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } + Item::Table(t) => s.push_str(&table_html(&t, simple)), Item::Img(im) => { let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); @@ -162,3 +181,71 @@ fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&s s.push_str("\n\n"); s } + +#[cfg(test)] +mod tests { + use super::{html, para_html, table_html}; + use crate::layout::{Block, Cell, Page, Run, Table}; + + fn block(text: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { + Block { + text: text.into(), + rect: [x0, y0, x1, y1], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + } + + #[test] + fn empty_or_control_only_runs_and_blocks_do_not_emit_empty_tags() { + let page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + Block { bold: true, italic: true, color: [1.0, 0.0, 0.0], ..block("\u{1}\u{FFFF}", 72.0, 700.0, 90.0, 712.0) }, + Block { color: [0.0, 0.5, 0.0], ..block("Hello", 90.5, 700.0, 140.0, 712.0) }, + Block { bold: true, ..block("\u{0}\u{2}", 72.0, 660.0, 120.0, 672.0) }, + ], + images: Vec::new(), + background: None, + rules: Vec::new(), + }; + let out = html(&[page], "Clean"); + assert!(out.contains("

Hello

"), "{out}"); + assert!(!out.contains(""), "{out}"); + assert!(!out.contains(""), "{out}"); + assert_eq!(out.matches(" Visible

\n") + ); + } + + #[test] + fn heading_level_is_clamped_and_empty_styled_cells_stay_clean() { + let b = block("Deep Heading", 72.0, 700.0, 200.0, 712.0); + let runs = [Run { text: "Deep Heading".into(), block: &b }]; + assert_eq!(para_html(&runs, 9, true).as_deref(), Some("
Deep Heading
\n")); + + let table = Table { + rect: [0.0, 0.0, 100.0, 20.0], + cols: vec![0.0, 50.0], + rows: vec![vec![ + Cell { text: "\u{1}".into(), size: 11.0, bold: true, italic: true, color: [0.0; 3], span: 1 }, + Cell { text: "Val".into(), size: 11.0, bold: true, italic: false, color: [0.0; 3], span: 1 }, + ]], + ruled: None, + }; + assert_eq!(table_html(&table, true), "\n\n
Val
\n"); + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9342213577..4bee6b451c 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -212,6 +212,18 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp run(chain, data, max_output, Some(max_output), false, false, None) } +/// [`decode_tolerant_counted`] with the two bounds apart: `max_stage` bounds every stage, as +/// [`decode_tolerant`]'s bound does, and `max_output` the bytes all stages decode together. A +/// stage stopped by either gives [`FilterError::LimitExceeded`] and the bytes decoded before it. +pub fn decode_tolerant_counted_each( + chain: &[(Filter, Params)], + data: &[u8], + max_stage: usize, + max_output: usize, +) -> Result<(Vec, bool, usize), (FilterError, usize)> { + run(chain, data, max_stage, Some(max_output), false, false, None) +} + /// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images: /// a step for each byte of `data`, paid before anything is decoded, [`FLATE_READ_WORK`] for each /// byte a Flate stage reads, paid before it runs (again if it reads them twice), and one for each @@ -451,6 +463,30 @@ mod tests { assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); } + /// [`decode_tolerant_counted_each`] holds every stage to its own bound as well as the stages + /// to their total: a Flate stage that would make more than that bound stops there, though + /// the total has room, and the stage after it gets nothing; within both, the chain reads + /// whole, one stage's output charged on top of another's. + #[test] + fn each_stage_is_held_to_its_own_bound_within_the_total() { + let hex = "00".repeat(1 << 16).into_bytes(); + let data = super::flate::encode(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + // The total alone lets the Flate stage make all of the hex text, and the hex stage read it. + STAGE_BYTES.with(|c| c.set(0)); + let whole = decode_tolerant_counted(&chain, &data, 3 << 16).map(|(v, _, _)| v); + assert_eq!(whole.ok(), Some(vec![0u8; 1 << 16])); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len() + hex.len()); + // Each stage held to 64 KiB, the Flate stage stops there and the hex stage gets nothing. + STAGE_BYTES.with(|c| c.set(0)); + let held = decode_tolerant_counted_each(&chain, &data, 1 << 16, 3 << 16); + assert!(matches!(held, Err((FilterError::LimitExceeded(m), 0)) if m == 1 << 16), "{held:?}"); + assert_eq!(STAGE_BYTES.with(|c| c.get()), data.len()); + // Within both, it reads whole. + let (out, _, spent) = decode_tolerant_counted_each(&chain, &data, 2 << 16, 3 << 16).unwrap(); + assert_eq!((out.len(), spent), (1 << 16, hex.len() + (1 << 16))); + } + use super::*; #[test] diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 9d8d0d6b85..f68761e02e 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -67,6 +67,11 @@ pub struct Metrics { /// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the /// embedded program. See [`cids_with_outlines`]. outlined_cids: Vec, + /// A stream these metrics come from (a CMap, the program, `/CIDToGIDMap`, a Type 3 glyph + /// description) was left unread because the decode allowance [`Metrics::from_dict_within`] + /// was given was spent: what it would have decided (code lengths, CIDs, widths, meanings, a + /// Type 3 font's height) is a guess. + pub past_allowance: bool, } fn nums(doc: &Document, o: Option<&Object>) -> Vec { @@ -79,6 +84,12 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } +/// Whether `font`'s `/Encoding` is the name Identity-H, given directly or through a reference, +/// as [`Metrics::from_dict`] reads it (a Type 0 font's `/Encoding` may be indirect). +fn identity_h(doc: &Document, font: &Dict) -> bool { + font.get(b"Encoding").is_some_and(|e| doc.resolve(e).as_name() == Some(b"Identity-H".as_slice())) +} + /// Whether `base_font` (subset tag allowed) names a legacy Indic font family that is laid out in /// visual order (Kruti Dev, DevLys, Chanakya, Shusha, Shivaji, Walkman, Akruti, APS-DV, DV-TT…). fn legacy_indic_font(base_font: &str) -> bool { @@ -92,14 +103,17 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { - embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) + // No total: one font's two programs, and the metrics read that may adapt one, are each + // bounded by that stream's own limit. + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) } /// The largest embedded sfnt program read for the editor. const MAX_SFNT: usize = 16 << 20; /// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: -/// each program decoded is charged whether or not it turns out to be usable. +/// each program decoded is charged whether or not it turns out to be usable, and so are the +/// streams of the metrics read that adapts a program without a Unicode cmap. pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); @@ -108,7 +122,10 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; + // As in `Reads::decode`: each stage within the limit, the program, the last, too, read + // through a view of the stream; once the allowance is spent a program with data is refused + // before its parameters are resolved. + let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -120,13 +137,32 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) { return Some(bytes); } + // A composite font's cmap is rebuilt only through Identity-H codes (`composite_glyph_maps`): + // with any other `/Encoding` nothing is, so its metrics aren't read, and a program with a + // cmap of its own is kept as it is, as when the rebuild finds nothing. + if font.name(b"Subtype") == Some(b"Type0") && !identity_h(doc, font) { + if own_cmap { + return Some(bytes); + } + continue; + } // PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only // cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged. - let metrics = Metrics::from_dict(doc, font); + // Metrics read past the allowance, its widths' read of the program again included, + // adapt no copy. + let metrics = Metrics::from_dict_within(doc, font, budget); + if metrics.past_allowance { + continue; + } let mut mappings = std::collections::BTreeMap::new(); if metrics.composite { + let refused = budget.refused(); if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) { mappings = map; + } else if budget.refused() > refused { + // The map the cmap is rebuilt from was left unread for want of the allowance: no + // copy is listed, adapted or as it is, as for metrics read past it. + continue; } if own_cmap { let charmap = face.charmap(); @@ -166,7 +202,7 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg /// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has /// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read. /// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one. -fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { +fn cids_with_outlines(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec { use skrifa::raw::TableProvider as _; let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()) else { @@ -175,13 +211,13 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() }; let program = doc.resolve(&program); let Object::Stream(stream) = program.as_ref() else { return Vec::new() }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { return Vec::new() }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() }; let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start); match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() { None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))), Some(Object::Stream(map)) => { - let Ok(map) = doc.with_direct_parms(map).decoded_within(1 << 17) else { return Vec::new() }; + let Some(map) = reads.decode(map, MAX_CID_TO_GID) else { return Vec::new() }; bit_set_below_64k( map.as_chunks::<2>() .0 @@ -200,7 +236,7 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { /// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up /// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself /// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. -fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics, reads: &mut Reads<'_>) { use skrifa::instance::{LocationRef, Size}; use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; @@ -208,7 +244,7 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(16 << 20) else { continue }; + let Some(bytes) = reads.decode(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); if upem == 0.0 { @@ -354,8 +390,48 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; /// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger -/// one is not decoded at all. +/// one is left unread (behind one filter not decoded past it; behind several, see `Reads::decode`). const MAX_CMAP_BYTES: usize = 16 << 20; +/// Largest decoded `/CIDToGIDMap` stream read: two bytes for each CID below 65,536. +const MAX_CID_TO_GID: usize = 1 << 17; + +/// The streams one reading of a font decodes, against the caller's allowance, and whether one was +/// left unread because that allowance was spent. +struct Reads<'a> { + doc: &'a Document, + budget: &'a mut DecodeBudget, + refused: bool, +} + +impl<'a> Reads<'a> { + fn new(doc: &'a Document, budget: &'a mut DecodeBudget) -> Self { + Self { doc, budget, refused: false } + } + + /// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given + /// by reference too, through a view of the stream that copies no more of its dictionary than + /// its filters and parameters; `None` when it can't be, noting when the allowance is why. + /// + /// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and + /// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a + /// map of 65,536 CIDs behind ASCIIHex and Flate, its Flate data smaller than the map, makes + /// all of `MAX_CID_TO_GID` in its last stage, with the hex stage's output on top. So a stream + /// reads here when it reads without an allowance and what is left pays for it. A stream with a + /// stage past `max` is unread. When what is left is more than what the stages before that one + /// made and `max`, it is charged those two and isn't refused; otherwise, with more left than + /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; + /// with no more left than that total, it spends all of it and counts as refused. + fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + match self.budget.decode_stages_within(self.doc, stream, max) { + Ok(data) => Some(data), + Err(_) => { + self.refused |= self.budget.remaining() == 0; + None + } + } + } +} + /// Most Type 3 glyph descriptions read for their `d1` box. A simple font shows at most 256 codes. const MAX_TYPE3_GLYPHS: usize = 256; /// Largest decoded Type 3 glyph description read for its `d1` box; a larger one is skipped. @@ -372,8 +448,8 @@ const MAX_TYPE3_EXTENT: f64 = 1e6; /// no glyph declares one (`d0` glyphs don't). The descriptions the encoding's `/Differences` /// names are chosen, since it "shall specify the complete character encoding" (Table 110); only a /// font without one has its first descriptions chosen. Reads at most [`MAX_TYPE3_GLYPHS`] -/// descriptions within [`MAX_TYPE3_FONT_BYTES`]. -fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { +/// descriptions within [`MAX_TYPE3_FONT_BYTES`], each charged to the reading's allowance too. +fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option<([f64; 4], bool)> { let procs = dict(doc, font.get(b"CharProcs"))?; let mut names: Vec<&[u8]> = Vec::new(); // Whether every glyph that can be drawn is read; if not, the union may be short of them. @@ -401,12 +477,30 @@ fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<([f64; 4], bool)> { } else { names.iter().filter_map(|n| procs.get(n)).collect() }; - let mut budget = DecodeBudget::new(MAX_TYPE3_FONT_BYTES, 0); + // What is left of the font's own total: the descriptions decode against the reading's + // allowance, which charges them, and each within what is left of this. + let mut left = MAX_TYPE3_FONT_BYTES; let mut union: Option<[f64; 4]> = None; for proc in chosen { let Object::Stream(s) = &*doc.resolve(proc) else { continue }; - let Ok(data) = budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES) else { - if budget.remaining() == 0 { + // Once the reading's allowance is spent the rest go unread, before their parameters are + // resolved. + if reads.budget.refuses(s) { + reads.refused = true; + complete = false; + break; + } + let before = reads.budget.remaining(); + let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); + left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); + let Ok(data) = read else { + if reads.budget.remaining() == 0 { + // The rest go unread too, for want of the reading's allowance. + reads.refused = true; + complete = false; + break; + } + if left == 0 { // The rest go unread too. complete = false; break; @@ -785,11 +879,25 @@ impl Metrics { code_len: 1, explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(), outlined_cids: Vec::new(), + past_allowance: false, } } + /// The metrics of `font`. Each stream they come from decodes within its own limit (16 MiB for + /// a CMap or a program, 4 MiB for a Type 3 font's glyph descriptions together), and again on + /// every call: a caller that reads many fonts, or one font many times, shares an allowance + /// through [`Metrics::from_dict_within`]. pub fn from_dict(doc: &Document, font: &Dict) -> Self { - let mut m = Self::read_metrics(doc, font); + // No total, so nothing is refused: each stream stays within its own limit, as it always has. + Self::from_dict_within(doc, font, &mut DecodeBudget::new(usize::MAX, 0)) + } + + /// [`Metrics::from_dict`], decoding against `budget`, which the caller shares across the fonts + /// it reads (a page's, say): each stream pays for what it decodes, usable or not. A stream the + /// allowance can't pay for is left unread, and [`Metrics::past_allowance`] says so. + pub fn from_dict_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Self { + let mut reads = Reads::new(doc, budget); + let mut m = Self::read_metrics(doc, font, &mut reads); let base = font .name(b"BaseFont") .map(|name| String::from_utf8_lossy(name).into_owned()) @@ -806,17 +914,18 @@ impl Metrics { m.italic |= italic; m.base_font = base; if m.composite && m.subset { - m.outlined_cids = cids_with_outlines(doc, font); + m.outlined_cids = cids_with_outlines(doc, font, &mut reads); } m.code_len = match &m.codes { Codes::One => 1, Codes::Two => 2, Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; - m.unicode = unicode_map(doc, font, m.composite); + m.unicode = unicode_map(doc, font, m.composite, &mut reads); if m.std14.is_some() { - embedded_widths(doc, font, &mut m); + embedded_widths(doc, font, &mut m, &mut reads); } + m.past_allowance = reads.refused; let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -837,7 +946,7 @@ impl Metrics { m } - fn read_metrics(doc: &Document, font: &Dict) -> Self { + fn read_metrics(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Self { let mut m = Metrics::fallback(); m.std14 = None; let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); @@ -850,7 +959,7 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) + && let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { @@ -915,7 +1024,7 @@ impl Metrics { // A union cut short by a limit may miss glyphs that are drawn, so a nonzero // `/FontBBox` comes first then; all zero, the partial union is still better // than no box. A box with no height in text space gives way to the next. - let glyphs = type3_glyph_box(doc, font); + let glyphs = type3_glyph_box(doc, font, reads); // All zero: no assumption about glyph sizes (Table 110). let bbox = <[f64; 4]>::try_from(nums(doc, font.get(b"FontBBox"))).ok().filter(|r| r.iter().any(|&v| v != 0.0)); let union = |complete: bool| glyphs.filter(|g| g.1 == complete).map(|(r, _)| (r, 0.0, 0.0)); @@ -1088,8 +1197,14 @@ impl Metrics { } /// The bytes that show `text` in this font, or `None` if some character has no code or - /// no glyph in it (the caller then substitutes another font). + /// no glyph in it, or the metrics were read past their allowance (the caller then substitutes + /// another font). Empty text needs no verified glyph and always encodes. pub fn encode(&self, text: &str) -> Option> { + // A refused read cannot establish the mappings or glyphs needed to write new text. + // Empty text needs neither and remains usable for erasing existing text. + if self.past_allowance && !text.is_empty() { + return None; + } // The lowest code showing each wanted character. Explicit entries and the encoding are // checked one by one; each range is asked for the wanted characters it can show. let wanted: HashSet = text.chars().collect(); @@ -1432,7 +1547,7 @@ pub fn glyph_unicode(name: &str) -> Option { /// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads /// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and /// extraction agree: `€` for code 0x80, which Standard leaves without a character. -fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { +fn symbolic_glyph_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Vec<(u32, String)> { use skrifa::GlyphId; use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag}; let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; @@ -1443,7 +1558,7 @@ fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { 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 Some(bytes) = reads.decode(stream, MAX_SFNT) else { return Vec::new() }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; let Ok(cmap) = face.cmap() else { return Vec::new() }; // A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft @@ -1534,7 +1649,7 @@ fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str], /// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name /// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding. -fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { +fn unicode_map(doc: &Document, font: &Dict, composite: bool, reads: &mut Reads<'_>) -> Unicode { let mut out = Unicode::default(); if !composite { let enc = font.get(b"Encoding").map(|e| doc.resolve(e)); @@ -1579,8 +1694,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { // A font whose `post` table names its glyphs reads by those names; one that names none // and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback. if base_name.is_none() && !symbolic_builtin { - out.encoding.extend(symbolic_glyph_texts(doc, font)); - out.encoding.extend(symbolic_mac_roman_texts(doc, font)); + out.encoding.extend(symbolic_glyph_texts(doc, font, reads)); + out.encoding.extend(symbolic_mac_roman_texts(doc, font, reads)); } 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()) @@ -1606,10 +1721,8 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { Some(Object::Stream(s)) => { - // With `/DecodeParms` given by reference, as every stream of the font is read here, - // and as the redaction proof reads the map: the two must get the same text, or a copy - // redaction rightly left counts as a survivor. - if let Ok(data) = doc.with_direct_parms(s).decoded_within(MAX_CMAP_BYTES) { + // Reads resolves indirect parameters too, matching the redaction proof's text. + if let Some(data) = reads.decode(s, MAX_CMAP_BYTES) { parse_to_unicode(&data, &mut out); } } @@ -1657,7 +1770,7 @@ fn identity_to_unicode() -> Vec { /// 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)> { +fn symbolic_mac_roman_texts(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> 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() }; @@ -1668,7 +1781,7 @@ fn symbolic_mac_roman_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { 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 Some(bytes) = reads.decode(stream, 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) diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 2d95465944..975b71289e 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -178,9 +178,10 @@ fn a_font_without_widths_measures_with_its_embedded_program() { } } -#[test] -fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; +/// Inter with its cmap replaced by a Mac format-6 one mapping code 65 to Ω's glyph, which egui +/// can't use; and that glyph. +fn mac_subset() -> (Vec, skrifa::GlyphId) { + use skrifa::MetadataProvider as _; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let face = skrifa::FontRef::new(original).unwrap(); let expected = face.charmap().map('Ω').unwrap(); @@ -194,6 +195,13 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); subset.extend_from_slice(&cmap); + (subset, expected) +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let (subset, expected) = mac_subset(); let face = skrifa::FontRef::new(&subset).unwrap(); assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); @@ -214,6 +222,331 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); } +/// The metrics read that adapts a program's cmap is paid for from the editor's allowance too: a +/// step short of the program, its ToUnicode and the program again for its widths and for the +/// readings of its codes by glyph name and as Mac OS Roman, the metrics are read past the allowance +/// and no copy is adapted. +#[test] +fn adapting_a_programs_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let (subset, expected) = mac_subset(); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let cmap = b"1 beginbfchar <41> <03A9> endbfchar"; + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let cost = 4 * subset.len() + cmap.len(); + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(cost - 1, 0)).is_none()); + // A composite font whose codes aren't Identity-H can't be adapted, so its metrics aren't + // read: the program is all it pays for. + let mut budget = DecodeBudget::new(cost, 0); + let mut composite = source.clone(); + composite.set(b"Subtype".to_vec(), Object::name("Type0")); + assert!(embedded_sfnt_within(&doc, &composite, &mut budget).is_none()); + assert_eq!(budget.remaining(), cost - subset.len()); +} + +/// A Type 0 subset font whose `/Encoding`, program, `/CIDToGIDMap` and ToUnicode are streams, and +/// what each decodes to. +fn subset_type0(doc: &mut Document, to_unicode: &[u8]) -> (Dict, [usize; 4]) { + let encoding = b"1 begincodespacerange <0000> endcodespacerange"; + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let map = [0u8, 0, 0, 1, 0, 2]; + let mut stream = |data: &[u8]| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), data)))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), stream(program)); + let mut descendant = Dict::new(); + descendant.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + descendant.set(b"CIDToGIDMap".to_vec(), stream(&map)); + let entries = vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("ABCDEF+Inter")), + ("Encoding", stream(encoding)), + ("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])), + ("ToUnicode", stream(to_unicode)), + ]; + (font(doc, entries), [encoding.len(), program.len(), map.len(), to_unicode.len()]) +} + +/// Each stream a font's metrics come from is paid for from the allowance they are read within, +/// down to the byte; a stream it can't pay for is left unread, and the metrics say so. +#[test] +fn a_fonts_streams_are_paid_for_from_its_allowance() { + let mut doc = Document::new_empty(); + let (f, sizes) = subset_type0(&mut doc, b"1 beginbfchar <0001> <0058> endbfchar"); + let cost: usize = sizes.iter().sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.code_len, whole.text_of(1).as_deref()), (2, Some("X"))); + assert!(!whole.outlined_cids.is_empty(), "the program and its map were read"); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + // A byte short, the ToUnicode, read last, is left unread; what came before is kept. + let mut budget = DecodeBudget::new(cost - 1, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance); + assert_eq!((m.code_len, m.text_of(1)), (2, None)); + assert_eq!(m.outlined_cids, whole.outlined_cids); + // With nothing left, none of them is read. + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(m.codes, Codes::Two, "the Encoding CMap was left unread"); + // Only a subset's program says which glyphs it lacks: the whole font pays for its CMaps alone. + let mut whole_font = f.clone(); + whole_font.set(b"BaseFont".to_vec(), Object::name("Inter")); + let mut budget = DecodeBudget::new(cost, 0); + let m = Metrics::from_dict_within(&doc, &whole_font, &mut budget); + assert!(!m.past_allowance && m.outlined_cids.is_empty()); + assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]); +} + +/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to +/// call the font read past it; a stream with data is left unread, and is. +#[test] +fn an_empty_stream_reads_with_nothing_left() { + let past = |data: &[u8]| { + let mut doc = Document::new_empty(); + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); + Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + }; + assert!(!past(b"")); + assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); +} + +/// A stream behind one filter past its own limit, with more left than its limit, is left unread as +/// it always was, paying for the limit, and is no reason to call the font read past the allowance. +#[test] +fn a_cmap_past_its_own_limit_is_not_past_the_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(MAX_CMAP_BYTES + 1, b' '); + let (f, sizes) = subset_type0(&mut doc, &cmap); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + let m = Metrics::from_dict_within(&doc, &f, &mut budget); + assert!(!m.past_allowance); + assert_eq!(m.text_of(1), None); + assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::() + MAX_CMAP_BYTES); +} + +/// A `/CIDToGIDMap` as long as a map can be, which compresses well, reads the same behind no +/// filter, Flate, or ASCIIHex and Flate: each stage may make as much as the map's limit, so the hex +/// stage's output on top of it doesn't leave it unread. A map past the limit is unread behind each. +#[test] +fn a_full_cid_map_reads_behind_one_or_two_filters() { + let read = |entries: usize, filters: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let flate = Stream::flate(Dict::new(), &map); + let stream = match filters { + 0 => Stream::from_raw(Dict::new(), map), + 1 => flate, + _ => { + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex.into_bytes()) + } + }; + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + Metrics::from_dict(&doc, &f).outlined_cids + }; + let full = read(1 << 16, 1); + assert!(!full.is_empty()); + assert_eq!(read(1 << 16, 0), full); + assert_eq!(read(1 << 16, 2), full); + for filters in 0..=2 { + assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters"); + } +} + +/// The editor's program read holds every filter stage to the program's limit: a program behind +/// ASCIIHex and Flate whose Flate stage would make 24 MiB stops at 16 MiB, charged that and what +/// the hex stage made, where the stages' total alone would let it make all 24 MiB. A program +/// within the limit behind the same two filters reads (see also +/// `an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit`). +#[test] +fn an_editors_program_read_holds_each_stage_to_its_limit() { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), &vec![b' '; 24 << 20]); + let hex: Vec = flate.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).chain(*b">").collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let program = doc.add(Object::Stream(Stream::from_raw(dict, hex))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none()); + assert_eq!(usize::MAX / 2 - budget.remaining(), flate.raw.len() + MAX_SFNT); +} + +/// Each stage of a stream a font's metrics are read from is held to the stream's limit, as a +/// decode without an allowance holds it: a `/CIDToGIDMap` behind Flate and ASCIIHex whose Flate +/// stage makes hex text past the map's limit is unread, as `Stream::decoded_within` leaves it, +/// though the two stages together make less than twice the limit; with its hex text within the +/// limit it reads. +#[test] +fn each_stage_of_a_metrics_stream_is_held_to_its_limit() { + let read = |cids: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..cids).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let hex: Vec = map.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let stream = Stream::from_raw(dict, Stream::flate(Dict::new(), &hex).raw.to_vec()); + let unread = stream.decoded_within(MAX_CID_TO_GID).is_err(); + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + (Metrics::from_dict(&doc, &f).outlined_cids.is_empty(), unread) + }; + // 0.6 of the limit: its hex text, 1.2 of it, is past the limit; the stages make 1.8 of it. + assert_eq!(read(MAX_CID_TO_GID * 6 / 20), (true, true)); + assert_eq!(read(MAX_CID_TO_GID / 4), (false, false)); +} + +/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program +/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though +/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a +/// byte past it is unread behind either. +#[test] +fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() { + let program = |len: usize, noise: usize| { + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + // Bytes Flate can't shrink, then zeros. + let mut x = 1u32; + program.extend((0..noise).map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + x.to_be_bytes()[0] + })); + program.resize(len, 0); + program + }; + let read = |program: &[u8], hex: bool| { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), program); + let stream = if hex { + let digits = b"0123456789ABCDEF"; + let mut data: Vec = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect(); + data.push(b'>'); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, data) + } else { + flate + }; + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream)))); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + embedded_sfnt(&doc, &simple) + }; + let near = program(12 << 20, 8 << 20); + let full = program(MAX_SFNT, 0); + for hex in [false, true] { + assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}"); + assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}"); + assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}"); + } +} + +/// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the +/// difference from the row above), so read without the predictor it is garbled; with its +/// `/DecodeParms` given by reference or direct. +fn predicted(doc: &mut Document, data: &[u8], columns: usize, by_reference: bool) -> Object { + let mut above = vec![0u8; columns]; + let mut rows = Vec::new(); + for row in data.chunks(columns) { + rows.push(2); + rows.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a))); + above[..row.len()].copy_from_slice(row); + } + let mut s = Stream::flate(Dict::new(), &rows); + let parms: Dict = + [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(i64::try_from(columns).unwrap()))].into_iter().collect(); + let parms = if by_reference { Object::Ref(doc.add(Object::Dict(parms))) } else { Object::Dict(parms) }; + s.dict.set(b"DecodeParms".to_vec(), parms); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A stream whose `/DecodeParms` are given by reference reads as it does with them direct: a PNG +/// predictor's tag bytes are taken out of the map, the CMap and the editor's program, not left in. +#[test] +fn parameters_given_by_reference_are_read() { + let read = |by_reference: bool| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..1000u16).flat_map(u16::to_be_bytes).collect(); + let map = predicted(&mut doc, &map, 2, by_reference); + let mut cmap = b"1 beginbfchar <0001> <0058> endbfchar".to_vec(); + cmap.resize(48, b' '); + let unicode = predicted(&mut doc, &cmap, 16, by_reference); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + f.set(b"ToUnicode".to_vec(), unicode); + let m = Metrics::from_dict(&doc, &f); + (m.outlined_cids.clone(), m.text_of(1).map(Cow::into_owned)) + }; + let direct = read(false); + assert!(!direct.0.is_empty()); + assert_eq!(direct.1.as_deref(), Some("X")); + assert_eq!(read(true), direct); + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + program.resize(program.len().next_multiple_of(4), 0); + for by_reference in [false, true] { + let mut doc = Document::new_empty(); + let file = predicted(&mut doc, &program, 4, by_reference); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), file); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert_eq!(embedded_sfnt(&doc, &simple).as_deref(), Some(program.as_slice()), "by reference: {by_reference}"); + } +} + +/// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for +/// as many readings as it holds, and the rest are left unread, where each reading used to decode +/// it afresh. +#[test] +fn fonts_naming_one_cmap_share_one_allowance() { + let mut doc = Document::new_empty(); + let mut cmap = b"1 beginbfchar <41> <0058> endbfchar".to_vec(); + cmap.resize(1_000_000, b' '); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), &cmap))); + let fonts: Vec = (0..100) + .map(|_| { + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("ToUnicode", Object::Ref(unicode))]) + }) + .collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + let read: Vec = fonts.iter().map(|f| Metrics::from_dict_within(&doc, f, &mut budget)).collect(); + let whole = read.iter().filter(|m| !m.past_allowance).count(); + assert_eq!(whole, (64 << 20) / cmap.len()); + assert!(read[..whole].iter().all(|m| m.text_of(0x41).as_deref() == Some("X"))); + assert!(read[whole..].iter().all(|m| m.text_of(0x41).as_deref() == Some("A")), "the encoding's meaning, unread past the allowance"); + assert_eq!(budget.remaining(), 0); +} + /// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its /// own names (Inter's version 2.0 table), and none past the last glyph. #[test] @@ -388,6 +721,21 @@ fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() { ], ); assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), ""); + // The names are read within the allowance the metrics are read within: the program is paid + // for by the glyph-name reading, by the Mac OS Roman one (which finds the glyphs named and + // reads nothing) and for the widths. A byte short of the first, the names go unread. + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + let mut budget = DecodeBudget::new(3 * subset.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), symbolic); + assert_eq!(budget.remaining(), 0); + let short = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(subset.len() - 1, 0)); + assert!(short.past_allowance); + assert_ne!(short.decode(&codes), TEXT, "the names unread"); } /// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase @@ -603,6 +951,24 @@ fn type3_glyph_byte_limits_leave_honest_metrics() { assert_eq!((m.ascent, m.descent), (5.0, -2.0), "the /FontBBox, not the first glyph's box alone"); } +/// A Type 3 font's glyph descriptions are paid for from the allowance its metrics are read within, +/// each what it decodes to: one the allowance can't pay for is left unread, and the metrics say so. +#[test] +fn type3_glyph_boxes_are_paid_for_from_the_allowance() { + let mut doc = Document::new_empty(); + let procs = ["1 0 0 -20 1 300 d1", "1 0 0 0 1 400 d1"]; + let f = type3(&mut doc, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0, 0, 0, 0], &procs); + let cost: usize = procs.iter().map(|p| p.len()).sum(); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!((whole.ascent, whole.descent), (400.0, -20.0)); + let mut budget = DecodeBudget::new(cost, 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(cost - 1, 0)); + assert!(m.past_allowance, "a byte short, the second description is left unread"); +} + /// A Type 3 font measured from its glyphs' `d1` boxes keeps them over a descriptor's `/Ascent` /// and `/Descent`, which are optional for Type 3 (ISO 32000-2 Table 120). #[test] @@ -1142,7 +1508,7 @@ fn malformed_cmaps_never_panic() { let mut enc = Dict::new(); enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); + assert_eq!(unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))).text(u32::MAX).as_deref(), Some("B")); } #[test] @@ -1373,6 +1739,29 @@ 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)); } +#[test] +fn refused_subset_reads_do_not_encode_unverified_glyphs() { + let mut doc = Document::new_empty(); + let (mut f, sizes) = subset_type0(&mut doc, b""); + f.set(b"Encoding".to_vec(), Object::name("Identity-H")); + f.set(b"ToUnicode".to_vec(), Object::name("Identity-H")); + if let Some(Object::Array(descendants)) = f.get_mut(b"DescendantFonts") + && let Some(Object::Dict(descendant)) = descendants.first_mut() + { + descendant.set(b"DW".to_vec(), Object::Int(1000)); + } + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance && !whole.outlined_cids.is_empty()); + assert!(whole.encode("\u{ffff}").is_none()); + // Refuse the program, then the CID map after reading the complete program. + for allowance in [0, sizes[1]] { + let partial = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(allowance, 0)); + assert!(partial.past_allowance); + assert_eq!(partial.encode(""), Some(Vec::new())); + assert!(partial.encode("\u{ffff}").is_none(), "allowance {allowance} must not turn /DW into proof of a glyph"); + } +} + /// [`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) @@ -1457,7 +1846,7 @@ fn a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman( 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); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); 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 @@ -1492,7 +1881,7 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { 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); + let u = unicode_map(&doc, &f, false, &mut Reads::new(&doc, &mut DecodeBudget::new(usize::MAX, 0))); [0x88, 0x8E].map(|c| u.text(c).unwrap_or_default().into_owned()) }; assert_eq!(read(vec![]), ["à", "é"]); @@ -1512,8 +1901,40 @@ fn the_mac_roman_reading_needs_no_base_encoding_and_no_symbolic_builtin() { assert_eq!(read(vec![("Encoding", Object::Dict(notdef))]), ["à", ""], "/Differences naming .notdef"); } +/// The program reads that tell a symbolic TrueType font's codes by its glyph names and as Mac OS +/// Roman are each paid for from the allowance the metrics are read within: one it can't pay for is +/// left unread, and the metrics say so. #[test] -fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { +fn the_symbolic_readings_of_a_program_are_paid_for() { + let program = cmap_only_sfnt(Some(&[(0x88, 106)]), &[(0xF088, 106)]); + let mut doc = Document::new_empty(); + let stream = 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(stream)); + // With `/Widths` the program isn't read for its advances: these are its only reads. + let entries = vec![ + ("Subtype", Object::name("TrueType")), + ("FontDescriptor", Object::Dict(descriptor)), + ("FirstChar", Object::Int(0x88)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ]; + let f = font(&mut doc, entries); + let whole = Metrics::from_dict(&doc, &f); + assert!(!whole.past_allowance); + assert_eq!(whole.text_of(0x88).as_deref(), Some("à")); + let mut budget = DecodeBudget::new(2 * program.len(), 0); + assert_eq!(Metrics::from_dict_within(&doc, &f, &mut budget), whole); + assert_eq!(budget.remaining(), 0); + let m = Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(2 * program.len() - 1, 0)); + assert!(m.past_allowance, "a byte short, the Mac OS Roman reading's read is left unread"); + assert_eq!(m.text_of(0x88), None); +} + +/// A Type 0 font over Inter's program without its `cmap`, Identity-H, whose `/CIDToGIDMap` sends +/// CID 17 to Inter's Ω and whose ToUnicode says code 17 is Ω; also the program object, the +/// program, Ω's glyph and what the ToUnicode and the map decode to. +fn cmapless_composite(doc: &mut Document) -> (Dict, pdfcraft_cos::ObjRef, Vec, skrifa::GlyphId, [usize; 2]) { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap(); @@ -1525,7 +1946,6 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without subset[12 + index * 16..28 + index * 16].copy_from_slice(record); } assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err()); - let mut doc = Document::new_empty(); let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset))); let mut descriptor = Dict::new(); descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); @@ -1535,9 +1955,10 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let mut descendant = Dict::new(); descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids)); - let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar"))); + let cmap = b"1 beginbfchar <0011> <03a9> endbfchar"; + let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), cmap))); let source = font( - &mut doc, + doc, vec![ ("Subtype", Object::name("Type0")), ("Encoding", Object::name("Identity-H")), @@ -1545,6 +1966,14 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without ("ToUnicode", Object::Ref(unicode)), ], ); + (source, program, subset, expected, [cmap.len(), 36]) +} + +#[test] +fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, program, subset, expected, _) = cmapless_composite(&mut doc); let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor"); assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too"); @@ -1552,3 +1981,74 @@ fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") }; assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact"); } + +/// The metrics read that rebuilds a composite subset's cmap is paid for from the editor's +/// allowance, as a simple font's is: the program, its ToUnicode, then its `/CIDToGIDMap`. With the +/// ToUnicode left unread no copy is adapted, though what is left would pay for the map. +#[test] +fn adapting_a_composite_subsets_cmap_is_paid_for() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (source, _, subset, expected, [cmap, map]) = cmapless_composite(&mut doc); + let cost = subset.len() + cmap + map; + let mut budget = DecodeBudget::new(cost, 0); + let editor = embedded_sfnt_within(&doc, &source, &mut budget).unwrap(); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); + assert_eq!(budget.remaining(), 0); + assert!(cmap > map); + assert!(embedded_sfnt_within(&doc, &source, &mut DecodeBudget::new(subset.len() + cmap - 1, 0)).is_none()); +} + +/// A composite font whose `/Encoding` isn't Identity-H can't have its cmap rebuilt +/// (`composite_glyph_maps` reads only Identity-H codes), so the editor reads its program and no +/// more: its ToUnicode and `/CIDToGIDMap` are left for what is left of the allowance. +#[test] +fn a_composite_font_that_cant_be_adapted_spends_only_its_program() { + let mut doc = Document::new_empty(); + let (source, _, subset, _, [cmap, map]) = cmapless_composite(&mut doc); + for encoding in ["Identity-V", "UniJIS-UCS2-H"] { + let mut f = source.clone(); + f.set(b"Encoding".to_vec(), Object::name(encoding)); + let mut budget = DecodeBudget::new(subset.len() + cmap + map, 0); + assert!(embedded_sfnt_within(&doc, &f, &mut budget).is_none(), "{encoding}"); + assert_eq!(budget.remaining(), cmap + map, "{encoding}"); + } +} + +/// A composite font's `/Encoding` given through a reference is the name it refers to, as the +/// metrics read it: Identity-H by reference is adapted as Identity-H written directly is. +#[test] +fn a_composite_fonts_encoding_given_by_reference_is_read() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let identity = doc.add(Object::name("Identity-H")); + source.set(b"Encoding".to_vec(), Object::Ref(identity)); + assert_eq!(Metrics::from_dict(&doc, &source).code_len, 2); + let editor = embedded_sfnt(&doc, &source).expect("Identity-H by reference is adapted"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} + +/// The `/CIDToGIDMap` a composite subset's cmap is rebuilt from reads as the font's other streams +/// do: a full map, which compresses well, reads behind ASCIIHex and Flate, though the hex stage's +/// output comes on top of the map. +#[test] +fn a_full_cid_map_rebuilds_a_composite_subsets_cmap_behind_two_filters() { + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let (mut source, _, _, expected, _) = cmapless_composite(&mut doc); + let mut map = vec![0u8; MAX_CID_TO_GID]; + map[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes()); + let flate = Stream::flate(Dict::new(), &map); + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + let map = Object::Ref(doc.add(Object::Stream(Stream::from_raw(dict, hex.into_bytes())))); + let Some(Object::Array(mut descendants)) = source.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + source.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + let editor = embedded_sfnt(&doc, &source).expect("the map behind two filters is read"); + assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected)); +} diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index ac151a5a18..84075f87e8 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -7,7 +7,7 @@ //! does (a mark takes none); correct maps and unrelated fonts are never changed. No font //! artwork is bundled. -use super::{Dict, Document, Metrics, dict, only_char, unicode_map}; +use super::{Dict, Document, Metrics, Reads, dict, identity_h, only_char, unicode_map}; use pdfcraft_cos::{DecodeBudget, Object}; use std::collections::BTreeMap; @@ -19,15 +19,18 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option Option> { let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?; let program = doc.resolve(descriptor.get(b"FontFile2")?); let Object::Stream(program) = program.as_ref() else { return None }; - DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok() + DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data) } /// Source glyph positions and the original PDF character codes that draw them. Empty codes @@ -190,14 +193,15 @@ pub(super) fn composite_glyph_maps( budget: &mut DecodeBudget, ) -> Option<(BTreeMap, BTreeMap)> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - if font.name(b"Encoding") != Some(b"Identity-H") { + if !identity_h(doc, font) { return None; } let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?; let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() { None => None, Some(Object::Name(n)) if n == b"Identity" => None, - Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?), + // Read as the font's other streams are, a map behind several filters too. + Some(Object::Stream(s)) => Some(Reads::new(doc, budget).decode(s, super::MAX_CID_TO_GID)?), _ => return None, }; let face = skrifa::FontRef::new(bytes).ok()?; @@ -294,13 +298,17 @@ impl LegacyThaiFont { /// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper /// assumes can't be trusted there, so it is shaped as an ordinary source font. /// - /// Its streams are decoded against `budget`, which the caller shares between every font it - /// reads: what they keep is the programs decoded and the editor cmaps added to them. + /// Its streams, its metrics' too, are decoded against `budget`, which the caller shares + /// between every font it reads: what they keep is the programs decoded and the editor cmaps + /// added to them. Metrics read past it adapt nothing: the font is refused. pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result, String> { - let m = Metrics::from_dict(doc, font); + let m = Metrics::from_dict_within(doc, font, budget); if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") { return Ok(None); } + if m.past_allowance { + return Err("the source Thai font's metrics are past the edit's allowance".into()); + } if !space.is_finite() || space < 0.0 { return Err("the source space width is invalid".into()); } @@ -528,6 +536,46 @@ mod tests { /// An intact legacy map: nominal Thai and two tone alternates. const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; + /// A THSarabunPSK subset whose own cmap is Latin-only has its Thai mappings rebuilt from its + /// `/CIDToGIDMap`. With that map left unread for want of the editor's allowance, the editor + /// lists no copy, rather than the program without its Thai mappings. + #[test] + fn a_map_left_unread_for_the_allowance_lists_no_copy() { + let mut doc = Document::new_empty(); + let program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program)))); + let map: Vec = (0u16..512).flat_map(u16::to_be_bytes).collect(); + let cid_to_gid = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &map)))); + let font = thai_font_over(&mut doc, file, cid_to_gid, INTACT); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let cost = usize::MAX / 2 - budget.remaining(); + // The map the cmap is rebuilt from is read last: a byte short of the cost, it is refused. + let mut short = DecodeBudget::new(cost - 1, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut short).is_none(), "the program is listed without its Thai mappings"); + assert_eq!(short.refused(), 1); + } + + /// `LegacyThaiFont::from_pdf` reads its metrics within the edit's allowance too: the subset's + /// program, for its outlined glyphs, and its ToUnicode are paid for on top of what the + /// editor's read of it pays. Metrics read past the allowance refuse the font before its + /// program is read. + #[test] + fn a_legacy_fonts_metrics_are_paid_for_from_the_edits_allowance() { + let mut doc = Document::new_empty(); + let program = thai_ordered_program(); + let font = thai_font(&mut doc, &program, INTACT); + let mut editor = DecodeBudget::new(usize::MAX / 2, 0); + assert!(crate::pdf::embedded_sfnt_within(&doc, &font, &mut editor).is_some()); + let editor_cost = usize::MAX / 2 - editor.remaining(); + let mut budget = DecodeBudget::new(usize::MAX / 2, 0); + LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut budget).unwrap(); + assert_eq!(usize::MAX / 2 - budget.remaining(), editor_cost + program.len() + INTACT.len()); + let mut short = DecodeBudget::new(program.len() + INTACT.len() - 1, 0); + let error = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut short).err().unwrap_or_default(); + assert!(error.contains("past the edit's allowance"), "{error}"); + } + /// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai /// codes, and the oversized range first. const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> endcodespacerange \ @@ -547,6 +595,32 @@ mod tests { let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect(); assert_eq!(recovered[&262], 'ั'); assert_eq!(recovered[&297], '๘'); + // `/Encoding` given through a reference is the name it refers to. + let mut indirect = font.clone(); + indirect.set(b"Encoding".to_vec(), Object::Ref(doc.add(Object::name("Identity-H")))); + assert_eq!(recovered_thai_map(&doc, &indirect), recovered_thai_map(&doc, &font)); + // `/CIDToGIDMap` given through a reference is the name it refers to, and a missing one is + // taken as Identity, the default of PDF 1.x (ISO 32000-1), as the renderer takes it. + let with_map = |map: Option| { + let mut f = font.clone(); + let Some(Object::Array(desc)) = f.get(b"DescendantFonts").cloned() else { panic!("fixture") }; + let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") }; + match map { + Some(map) => desc.set(b"CIDToGIDMap".to_vec(), map), + None => { + desc.remove(b"CIDToGIDMap"); + } + } + f.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)])); + f + }; + let by_reference = with_map(Some(Object::Ref(doc.add(Object::name("Identity"))))); + assert_eq!(recovered_thai_map(&doc, &by_reference), recovered_thai_map(&doc, &font), "a map by reference"); + assert_eq!(recovered_thai_map(&doc, &with_map(None)), recovered_thai_map(&doc, &font), "a missing map"); + // Any other map is refused: one that moves glyphs, and another name. + let moved = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[0, 1, 0, 0])))); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(moved))), None, "a map that moves glyphs"); + assert_eq!(recovered_thai_map(&doc, &with_map(Some(Object::name("Foo")))), None, "another name"); // 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that // put Mai Han-akat one glyph earlier passes every anchor and only this check sees it. let mut shifted = thai_ordered_program(); diff --git a/crates/fonts/tests/stream_views.rs b/crates/fonts/tests/stream_views.rs new file mode 100644 index 0000000000..de210637ae --- /dev/null +++ b/crates/fonts/tests/stream_views.rs @@ -0,0 +1,62 @@ +//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms` +//! is given by reference is read through a view that shares its data and copies no more of its +//! dictionary than its filters and parameters, not through a copy of the whole stream. A test +//! binary of its own, so that the one test is all the process runs while its peak is measured. + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within}; + +/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before))) +} + +/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`. +fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object { + let mut s = Stream::flate(Dict::new(), data); + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s.dict.set(b"Junk".to_vec(), junk.clone()); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their +/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by +/// the editor's program read and by the metrics read: neither copies an array. A read through a +/// copy of the stream would add about as much as one array holds. +#[test] +fn reading_a_fonts_streams_copies_no_dictionary() { + let junk = Object::Array(vec![Object::Int(0); 1 << 20]); + let array_kib = (std::mem::size_of::() << 20) as u64 >> 10; + let mut doc = Document::new_empty(); + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))]))); + let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk); + let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk); + drop(junk); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + font.set(b"ToUnicode".to_vec(), unicode); + let Some(((), growth)) = peak_growth(|| { + for _ in 0..8 { + let mut budget = DecodeBudget::new(64 << 20, 0); + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let m = Metrics::from_dict_within(&doc, &font, &mut budget); + assert_eq!(m.text_of(0x41).as_deref(), Some("X")); + } + }) else { + eprintln!("the peak can't be measured here"); + return; + }; + assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB"); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 2ea52df387..12f3ee5ea9 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -98,6 +98,12 @@ pub enum Calculate { Simple { op: CalcOp, fields: Vec }, /// Simplified field notation, e.g. `Price * Quantity`. Notation(String), + /// A custom calculation script, written as given (Field Properties ▸ Calculate ▸ Custom + /// calculation script). Reading a field never gives this: a script that isn't one of the + /// forms above stays in [`Actions::unsupported`] and [`Scripts::calculate`], where the + /// JavaScript engine runs it. Authoring writes it back byte for byte; a blank one writes no + /// script. + Custom(String), } /// A field's recognised scripts. diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 66738246c2..b7f9bd4c4c 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -445,6 +445,7 @@ impl Calculate { match self { Calculate::None => Vec::new(), Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Custom(js) => calculation_reads(js), Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { Notation::Name(name) => Some(name), _ => None, @@ -620,7 +621,8 @@ const MAX_EXPANDED_VALUES: usize = 1 << 20; pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { let num = |s: &String| make_number(s).unwrap_or(0.0); match c { - Calculate::None => None, + // A custom script is the JavaScript engine's to run. + Calculate::None | Calculate::Custom(_) => None, // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the // empty product 1: a blank quantity × price row reads 0, and a total over the rows // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 8212da5bf2..3af5e2dca3 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -383,9 +383,12 @@ fn notation_text(src: &str) -> &str { } } +/// The `/AA /C` JavaScript that stores `c`; `None` when it stores none (no calculation, or a +/// blank custom script). pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, + Calculate::Custom(js) => (!js.trim().is_empty()).then(|| js.clone()), Calculate::Simple { op, fields } => Some(format!( "AFSimple_Calculate({}, new Array({}));", js_str(op.code()), diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index b4b6452a3c..d19d9f2a5f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -487,6 +487,20 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() { } } +/// A custom calculation script (#6630) is written as given, a blank one as no script; it reads the +/// fields it names, and running it is the JavaScript engine's, not the AF calculator's. +#[test] +fn a_custom_calculation_script_is_kept_as_written() { + let js = "event.value = this.getField(\"a\").value * getField(\"b\").value; // getField(\"c\")"; + let c = Calculate::Custom(js.into()); + assert_eq!(calculate_js(&c).as_deref(), Some(js)); + for blank in ["", " \r\n\t"] { + assert_eq!(calculate_js(&Calculate::Custom(blank.into())), None, "{blank:?}"); + } + assert_eq!(c.reads(), ["a", "b"]); + assert_eq!(calculate(&c, &|_| vec!["2".into()]), None); +} + #[test] fn simple_calculations_fold_a_repeated_group_without_collecting_it() { let group = |_: &str| vec!["2".to_string(); 3]; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 715db946e4..ee6eef518b 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -21,7 +21,7 @@ pub(crate) use tree::ancestor_sets; pub use edit::set_props; pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; pub(crate) use look::button_appearance; -pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, redraw_fields, set_button_icon}; pub(crate) use tree::{ensure_form, form_fonts_as_objects}; /// The smallest width and height of a field's widget, in points: a smaller one is refused. diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2c727779eb..ea718369bf 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result Option { + let js = crate::af::calculate_js(c)?; + let text = match c { + crate::af::Calculate::Custom(_) => PdfString::text(&js), + _ => PdfString::literal(js.into_bytes()), + }; + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), text); + Some(Object::Dict(a)) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -328,10 +343,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result<(), FormError> { /// `FormError::NoSuchField` when no field is named `name`. pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + redraw_widgets_of(doc, &f, only) +} + +/// [`redraw_field`] for each field [`fields`] lists that `keep` accepts. The form's fields are +/// read once: drawing a field changes only its own widgets' appearances, so the list stays true +/// for the rest. Looking each one up by name instead reads the whole form again for every field, +/// which grows with the square of the field count (a laid out XFA form with a thousand rows holds +/// thousands of fields). A failure names the field it stopped at. +pub fn redraw_fields(doc: &mut Document, mut keep: impl FnMut(&Document, &Field) -> bool) -> Result<(), FormError> { + for f in fields(doc) { + if keep(doc, &f) { + redraw_widgets_of(doc, &f, None).map_err(|e| FormError::Invalid(format!("{}: {e}", f.name)))?; + } + } + Ok(()) +} + +/// [`redraw_widgets`] for the field `f`, as `fields(doc)` read it. +fn redraw_widgets_of(doc: &mut Document, f: &Field, only: Option) -> Result<(), FormError> { let mut undrawn = false; for (index, w) in f.widgets.iter().enumerate() { if only.is_some_and(|only| only != index) { @@ -323,7 +342,7 @@ pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (mut s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + let (mut s, m) = appearance::field_appearance_drawn(doc, f, w, &f.value, true); missing = m; // Thai glyphs are streams of their own. pdfcraft_fonts::shaped_text::store_streams(doc, &mut s.dict); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 236310f268..6a35956ed0 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -27,7 +27,7 @@ pub mod table; pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, - delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, redraw_fields, set_button_icon, set_props, }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index b69e24d8dc..6f3eda2be4 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1726,6 +1726,70 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } +/// The `/JS` string of field `name`'s `/AA /C` action, as stored. +fn calculate_action_js(doc: &Document, name: &str) -> Option { + let f = field(&fields(doc), name).obj; + let aa = doc.get(f).as_dict().and_then(|d| d.get(b"AA").cloned()).map(|a| doc.resolve(&a))?; + let c = aa.as_dict().and_then(|aa| aa.get(b"C").cloned()).map(|c| doc.resolve(&c))?; + Some(c.as_dict().and_then(|c| c.get(b"JS")).and_then(|s| s.as_string()).expect("/JS is a string").clone()) +} + +/// Field Properties ▸ Calculate ▸ Custom calculation script (#6630): the script is written as +/// given, as a text string, and reads back as the field's custom script, in the calculation +/// order; a blank one removes the script and the field's place in the order. +#[test] +fn a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it() { + use crate::af::Calculate; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("codice")).unwrap(); + add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("barcode")).unwrap(); + let js = "// Code 39: tra asterischi, così\r\nevent.value = \"*\" + this.getField(\"codice\").value + \"*\";"; + let custom = |js: &str| FieldProps { calculate: Some(Calculate::Custom(js.into())), ..FieldProps::default() }; + set_props(&mut doc, "barcode", &custom(js)).unwrap(); + for doc in [doc.clone(), reopen(&doc)] { + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, Some(js)), "read back as a script"); + assert!(actions.unsupported.contains(&"calculate")); + let stored = calculate_action_js(&doc, "barcode").expect("an /AA /C action"); + assert_eq!(stored.bytes, pdfcraft_cos::PdfString::text(js).bytes, "a text string, so ì isn't UTF-8 bytes another viewer reads as ì's pieces"); + let order = calculation_order(&doc, &all); + assert_eq!(order.fields.iter().map(|f| (f.name.as_str(), f.listed)).collect::>(), [("barcode", true)]); + assert_eq!(order.fields[0].reads, ["codice"]); + } + for blank in ["", " \n\t"] { + let mut doc = doc.clone(); + set_props(&mut doc, "barcode", &custom(blank)).unwrap(); + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, None), "{blank:?}"); + assert!(calculate_action_js(&doc, "barcode").is_none(), "{blank:?} writes no /C"); + assert!(calculation_order(&doc, &all).fields.is_empty(), "{blank:?} leaves the calculation order"); + } +} + +/// A sum and a notation are still written as the AF calls they are, byte for byte, beside the +/// custom script that is written as a text string (#6630). +#[test] +fn a_sum_and_a_notation_are_written_as_their_af_calls() { + use crate::af::{CalcOp, Calculate, calculate_js}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "total"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let sum = Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }; + for c in [sum, Calculate::Notation("a * b".into())] { + set_props(&mut doc, "total", &FieldProps { calculate: Some(c.clone()), ..FieldProps::default() }).unwrap(); + let js = calculate_js(&c).unwrap(); + assert_eq!(calculate_action_js(&doc, "total").map(|s| s.bytes), Some(js.into_bytes()), "{c:?}"); + let all = fields(&reopen(&doc)); + assert_eq!((&field(&all, "total").actions.calculate, &field(&all, "total").actions.scripts.calculate), (&c, &None)); + } +} + /// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. #[test] fn unsupported_first_validation_call_preserves_custom_script_dispatch() { @@ -5721,6 +5785,34 @@ fn set_values_reads_the_form_once_not_per_value() { assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); } +/// Redrawing every field of a large form reads the form once, not once per field: a laid out XFA +/// form with a thousand rows holds thousands of fields, and reading them all again for each one +/// took 15 s a relayout. The fields `keep` turns down keep the appearance they had. +#[test] +fn redraw_fields_reads_the_form_once_however_many_fields_it_holds() { + for n in [250u32, 1000] { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + ]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /V (v{i}) /Rect [50 0 250 10] /P 3 0 R >>")); + } + let mut doc = document(&objs); + let before = form_reads(); + redraw_fields(&mut doc, |_, f| f.name != "f0").unwrap(); + assert_eq!(form_reads() - before, 1, "{n} fields"); + let all = fields(&doc); + let has_ap = |name: &str| field(&all, name).widgets.iter().all(|w| doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))); + assert!(!has_ap("f0"), "turned down: left as it was"); + assert!((1..n).all(|i| has_ap(&format!("f{i}"))), "{n} fields"); + assert!(ap(&doc, &field(&all, &format!("f{}", n - 1)).widgets[0]).contains(&format!("(v{})", n - 1))); + } +} + /// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and /// the group's buttons in the calculation order; and a text field `name` with two widgets. fn radio_group_form() -> Document { diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index f4792ba64b..7e171bc369 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -100,6 +100,9 @@ struct Interp<'h> { /// burning a core to the loop limit). #[cfg(not(target_arch = "wasm32"))] deadline: Option, + /// `deadline` passed and stopped the script: the run reports as abandoned. + #[cfg(not(target_arch = "wasm32"))] + out_of_time: bool, } type R = Result; @@ -151,6 +154,8 @@ pub(crate) fn run_formcalc_at( exited: false, #[cfg(not(target_arch = "wasm32"))] deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)), + #[cfg(not(target_arch = "wasm32"))] + out_of_time: false, }; let mut out = XfaOutcome::default(); match interp.block(&body) { @@ -165,6 +170,19 @@ pub(crate) fn run_formcalc_at( } Err(e) => out.error = Some(e), } + // The caller stops waiting at the same time limit, so which of the two notices first is + // down to the scheduler. The outcome must not depend on it: stopped by the clock, the run + // reports what an abandoned one does, the error and no changes. Keeping the changes made + // it a race: a loop adding rows until the clock stopped it left the form unchanged on one + // run and 1000 rows longer on the next (each laid out again, tens of seconds). + #[cfg(not(target_arch = "wasm32"))] + if let (true, Some(d)) = (interp.out_of_time, deadline) { + return XfaOutcome { + error: Some(format!("the script ran longer than {:.1} s and was abandoned", d.as_secs_f64())), + abandoned: true, + ..Default::default() + }; + } out.notes = host.notes(); out.effects = host.effects; out.console = host.console; diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 5f279ea4f6..26de7ba9eb 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -25,6 +25,7 @@ impl Interp<'_> { && let Some(d) = self.deadline && std::time::Instant::now() > d { + self.out_of_time = true; return Err(RAN_TOO_LONG.into()); } #[cfg(target_arch = "wasm32")] diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 297e7604d1..3550257b9e 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -4,7 +4,7 @@ use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; use super::{ Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, - js_string, + error, js_string, }; // ── app, util, console ────────────────────────────────────────────────────────────────────── @@ -120,6 +120,36 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult<(String, bool)> { + let first = arg(args, 0); + // Any object of named parameters is read by key, one without `cName` too, so a missing name + // is refused rather than read as the object's text. + let named = first.as_object().filter(|o| !o.is_array() && !o.is_callable()); + let name = match &named { + Some(o) => text_line(&o.get(js_string!("cName"), ctx)?, ctx)?, + None => text_line(&first, ctx)?, + }; + if name.is_empty() { + return Err(error("exportDataObject needs cName, the attached file's name")); + } + let launch = match &named { + Some(o) => o.get(js_string!("nLaunch"), ctx)?, + None => arg(args, 3), + }; + let launch = if launch.is_undefined() || launch.is_null() { 0.0 } else { launch.to_number(ctx)? }; + Ok((name, launch == 1.0 || launch == 2.0)) +} + +/// `this.exportDataObject(…)`: asks the viewer to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + host(ctx)?.borrow_mut().request(Request::OpenAttachment { name, open }); + Ok(JsValue::undefined()) +} + /// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print /// and Save As. Other items do nothing. pub(crate) fn menu_item(name: &str) -> Option { @@ -199,11 +229,11 @@ fn color_object(ctx: &mut Context) -> JsObject { const XFA_VERSION: f64 = 3.5; /// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, -/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and -/// `color` objects. +/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`, +/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, +/// `display` and `color` objects. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ + let globals: [(&str, usize, NativeFunction); 10] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), @@ -213,6 +243,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)), ]; for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 1419ba2a86..68d581f093 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -68,6 +68,7 @@ impl Host { let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + Request::OpenAttachment { name, .. } => name.len(), _ => 0, }; let kept = self.room(bytes); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 0bf4032efd..7948c06120 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -8,7 +8,7 @@ //! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`. //! - the document (`this`, also the global object): `getField`, `numFields`, //! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`, -//! `calculateNow`, `print`, `submitForm`, `mailDoc`. +//! `calculateNow`, `print`, `submitForm`, `mailDoc`, `exportDataObject`. //! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index d3195e6a5a..86a6945846 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -241,6 +241,12 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` + /// 1 or 2) or to save (0). + OpenAttachment { + name: String, + open: bool, + }, /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. Highlight(bool), /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 72a88ac9f3..c726f78af9 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1637,7 +1637,21 @@ $host.gotoURL("https://example.org")"#, Limits { loop_iterations: u64::MAX, recursion: 32 }, Some(std::time::Duration::ZERO), ); - assert!(o.error.unwrap().contains("too long")); + assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}"); + // Stopped by its clock, a script keeps none of its changes, as when the caller stops + // waiting first: the outcome is the same whichever side notices. A loop adding rows + // until the clock stops it kept up to 1000 new rows when the script noticed first. + let o = crate::formcalc::run_formcalc_at( + "while (1) do table._row.addInstance(1) endwhile", + &ev, + &doc, + std::sync::Arc::new(form()), + false, + Limits { loop_iterations: u64::MAX, recursion: 32 }, + Some(std::time::Duration::from_millis(50)), + ); + assert!(o.abandoned && o.effects.is_empty() && o.notes.is_empty(), "{:?}, {} effects", o.error, o.effects.len()); + assert!(o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{:?}", o.error); assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); @@ -1885,6 +1899,16 @@ mod session { slow.timeout = std::time::Duration::from_millis(200); let o = s.run(slow); assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); + // Whether the script's clock or the session's wait notices first, a FormCalc loop + // adding rows until the time is up is abandoned with none of its rows, and the session + // is dead after it. + let mut s = XfaSession::new(XfaDoc::default()); + let mut slow = script("while (1) do table._row.addInstance(1) endwhile", true, "form1[0].page1[0].go[0]", "click"); + slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 }; + slow.timeout = std::time::Duration::from_millis(200); + let o = s.run(slow); + assert!(o.abandoned && o.effects.is_empty(), "{:?}, {} effects", o.error, o.effects.len()); + assert!(s.dead().is_some_and(|w| w.contains("abandoned"))); } } @@ -1924,6 +1948,61 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod data_objects { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + /// `exportDataObject`, as a document method or a bare global, asks to open the named attached + /// file when `nLaunch` is 1 or 2 and to save it otherwise; without `cName` it is an error. + #[test] + fn export_data_object_asks_to_open_or_save_an_attached_file() { + let o = go( + "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); exportDataObject('notes.txt'); \ + this.exportDataObject({cName: 'form.pdf', nLaunch: 1});", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::OpenAttachment { name: "Guide.pdf".into(), open: true }, + Request::OpenAttachment { name: "notes.txt".into(), open: false }, + Request::OpenAttachment { name: "form.pdf".into(), open: true }, + ] + ); + let o = go("this.exportDataObject({nLaunch: 2})", &Event::field("Mouse Up", "total", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("cName")), "{:?}", o.error); + assert!(o.requests.is_empty()); + } + + /// Only `nLaunch` 1 and 2 open the file: other values (1.5, 3, a huge number) save it, as the + /// default 0 does. + #[test] + fn export_data_object_opens_only_for_n_launch_1_and_2() { + for launch in ["1.5", "3", "1e9", "-1"] { + let o = go(&format!("this.exportDataObject({{cName: 'Guide.pdf', nLaunch: {launch}}});"), &Event::field("Mouse Up", "total", "")); + assert_eq!(o.requests, [Request::OpenAttachment { name: "Guide.pdf".into(), open: false }], "nLaunch {launch}"); + } + } + + /// In an XFA script `event.target` is the document, so `exportDataObject` works on it, while + /// `xfa.event.target` is still the node the event happened on. + #[test] + fn in_xfa_scripts_event_target_is_the_document_and_xfa_event_target_the_node() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa( + "event.target.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); xfa.event.target.name", + &event, + &XfaDoc::default(), + &super::xfa_model::form(), + Limits::default(), + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("go"), "xfa.event.target is still the button"); + assert!(o.effects.contains(&XfaEffect::OpenAttachment { name: "Guide.pdf".into(), open: true }), "{:?}", o.effects); + } +} + mod runtime_highlight { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 085262351e..ec8d7cc24d 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,6 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use crate::globals::data_object; use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; mod globals; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 50647a3426..de57d7fd49 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -3,8 +3,8 @@ use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; use super::{ - Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx, - s, scand, text, + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, data_object, function, js_string, platform, printd, + printf, printx, s, scand, text, }; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── @@ -15,6 +15,13 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { Ok(()) } +/// `event.target.exportDataObject(…)`: the document asks to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + effect(ctx, XfaEffect::OpenAttachment { name, open })?; + Ok(JsValue::undefined()) +} + /// `app.runtimeHighlight`: whether the viewer highlights form fields. fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) @@ -67,13 +74,13 @@ fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::undefined()) } -/// `xfa.host.beep()`: asks to beep. +/// `xfa.host.beep()` and `app.beep()`: ask to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } -/// `xfa.host.gotoURL(url)`: asks to open the URL. +/// `xfa.host.gotoURL(url)` and `app.launchURL(url)`: ask to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; @@ -114,50 +121,63 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// `recalculate(…)` on a node or on `xfa`: records a request to recalculate the form. pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Recalculate)?; Ok(JsValue::undefined()) } +/// `xfa.layout.relayout()`, `relayoutPageArea()` and `remerge()`: record a request to lay the +/// form out again. pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Relayout)?; Ok(JsValue::undefined()) } +/// `console.println(…)`: adds the line, cut to [`MAX_LINE`] characters, to the run's console +/// output. fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } +/// `xfa.host.response(…)` and `app.response(…)`: always return `null`, as for a canceled dialog. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::null()) } +/// `xfa.layout.page(…)`: the current page, 1-based, whatever the argument. fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) } +/// `xfa.layout.absPage(…)`: the current page, 0-based, whatever the argument. fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) } +/// `xfa.layout.pageCount()` and `absPageCount()`: the document's page count. fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) } +/// The `xfa.layout` measures `h`, `w`, `x`, `y` and `pageSpan`: always 0. fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::from(0.0)) } +/// `xfa.resolveNode(som)`, or `xfa.resolveNodes(som)` when `many`, resolving from the node the +/// script runs on. fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { let current = host(ctx)?.borrow().current; resolver(ctx, current, many) } /// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +/// `resolveNode` and more; `xfa.event`'s target is that node), a separate global `event` whose +/// target is a document offering `exportDataObject`, `app`, `console`, `util`, and the nodes in +/// scope by name, with `_name` for each repeatable one's instance manager. pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; @@ -207,24 +227,33 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); + // Two event objects, as in Acrobat's XFA scripting: `xfa.event`, whose target is the form + // node the event happened on, and Acrobat's own `event`, whose target is the document + // (`event.target.exportDataObject(…)` is how forms open their attached files). + let acrobat_doc = + ObjectInitializer::new(ctx).function(NativeFunction::from_fn_ptr(export_data_object), js_string!("exportDataObject"), 4).build(); + let event_object = |ctx: &mut Context, target: JsValue| { + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build() + }; + let ev = event_object(ctx, this_node.clone()); + let acrobat_ev = event_object(ctx, acrobat_doc.into()); let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); let template = sink(ctx)?; let resolve_one = xfa_resolve(ctx, false)?; @@ -243,7 +272,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) .build(); ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("event"), acrobat_ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 11468c04b3..212c2dd38c 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -104,6 +104,11 @@ pub enum XfaEffect { MessageBox(Message), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `event.target.exportDataObject(…)`: the attached file `name`, to open or to save. + OpenAttachment { + name: String, + open: bool, + }, /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. ShowPanel(crate::Panel), Print, @@ -217,6 +222,7 @@ pub(super) fn payload(e: &XfaEffect) -> usize { som.len().saturating_add(s.len()) } XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::OpenAttachment { name, .. } => name.len(), XfaEffect::MessageBox(m) => m.bytes(), XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 797f813332..88e0748625 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -179,7 +179,15 @@ mod native { loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); match worker.from.recv_timeout(left) { - Ok(Reply::Outcome(o)) => return *o, + Ok(Reply::Outcome(o)) => { + // A FormCalc script its own clock stopped reports as abandoned: the + // session ends as it does when the wait runs out first. + if o.abandoned { + self.dead = o.error.clone(); + self.worker = None; + } + return *o; + } Ok(Reply::Started) => { // A second call changes nothing: the script's time has already begun. if !running { diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index d5a6d80b04..54a8a9508f 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,6 +521,41 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { read } +/// Records any device colour space named directly or used as the base or alternate space of a +/// colour-space object (`Name` or `Array` such as `[/Indexed base …]`, `[/Separation _ alt …]`, +/// or `[/DeviceN _ alt …]`). +fn mark_cs_object(doc: &Document, cs: &Object, used: &mut DeviceColour, depth: u8) { + if depth > MAX_DIRECT_DEPTH { + return; + } + match cs { + Object::Ref(r) => mark_cs_object(doc, &doc.get(*r), used, depth + 1), + Object::Name(n) => used.mark(n, false), + Object::Array(a) => { + let mut kind = a.first().map(|o| doc.resolve(o)); + for _ in 0..MAX_DIRECT_DEPTH { + let Some(&Object::Ref(r)) = kind.as_deref() else { break }; + kind = Some(doc.get(r)); + } + match kind.as_deref().and_then(Object::as_name) { + Some(n) if a.len() == 1 => used.mark(n, false), + Some(b"I" | b"Indexed" | b"Pattern") => { + if let Some(base) = a.get(1) { + mark_cs_object(doc, base, used, depth + 1); + } + } + Some(b"Separation" | b"DeviceN") => { + if let Some(alt) = a.get(2) { + mark_cs_object(doc, alt, used, depth + 1); + } + } + _ => {} + } + } + _ => {} + } +} + /// Whether any content or object uses a device colour space. fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> DeviceColour { let mut used = DeviceColour::default(); @@ -536,15 +571,15 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) { used.default_rgb |= spaces.get(b"DefaultRGB").is_some(); used.default_cmyk |= spaces.get(b"DefaultCMYK").is_some(); + for (k, entry) in spaces.iter() { + if !matches!(k.as_slice(), b"DefaultGray" | b"DefaultRGB" | b"DefaultCMYK") { + mark_cs_object(doc, entry, &mut used, 0); + } + } } } - if let Some(cs) = d.get(b"ColorSpace").map(|c| doc.resolve(c)) { - match &*cs { - Object::Name(n) if n == b"DeviceGray" => used.gray = true, - Object::Name(n) if n == b"DeviceRGB" => used.rgb = true, - Object::Name(n) if n == b"DeviceCMYK" => used.cmyk = true, - _ => {} - } + if let Some(cs) = d.get(b"ColorSpace") { + mark_cs_object(doc, cs, &mut used, 0); } // Content streams only: a font program or an ICC profile is data, whatever bytes it holds. // Parsing is the expensive part, so once content has used all three spaces no more is read. @@ -947,8 +982,20 @@ fn check_objects( let Some(d) = dict_of(o) else { continue }; check_one_object(doc, level, d, o, drawing.contains(r), page_of(*r), issue); } + for (i, p) in pdfcraft_model::pages(doc).into_iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + for entry in a { + if let Object::Dict(d) = entry { + check_one_object(doc, level, d, entry, false, Some(i), issue); + } + } + } + } } +/// Checks a single object against the PDF/A rules for `level`. fn check_one_object( doc: &Document, level: Level, @@ -961,13 +1008,14 @@ fn check_one_object( let ty = dict.name(b"Type"); let subtype = dict.name(b"Subtype"); check_actions(doc, dict, issue); - if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page, issue) { + let is_annot = matches!(object, Object::Dict(_)) && is_annotation(doc, dict, ty, subtype, page.is_some()); + if is_annot && check_annotation(doc, dict, subtype, page, issue) { // A forbidden annotation stops every later check for its object, as the scan did before // it was split into helpers: the rest of its own checks and the form field, font, stream // and embedded file below all came after that `continue`. return; } - if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && !is_annot { issue("6.5.2", "A form field has additional actions", None, None, true); } // A Type 0 font's descendant is checked through it. @@ -992,8 +1040,11 @@ fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static st } } -fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { - ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +/// Whether `dict` is an annotation dictionary (`/Type /Annot`, or a dictionary with the required +/// `/Subtype` and `/Rect` array entries that either sits in a page's `/Annots` list or carries `/P`). +fn is_annotation(doc: &Document, dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>, on_page: bool) -> bool { + ty == Some(b"Annot") + || (subtype.is_some() && dict.get(b"Rect").is_some_and(|r| doc.resolve(r).as_array().is_some()) && (on_page || dict.get(b"P").is_some())) } fn check_annotation( @@ -1044,6 +1095,7 @@ fn check_annotation_appearance( } } +/// Checks a stream's compression filters, external file keys, and image/PostScript subtype rules. fn check_stream( doc: &Document, stream: &pdfcraft_cos::Stream, @@ -1053,12 +1105,12 @@ fn check_stream( let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { Some(filter) => match &*filter { Object::Name(name) => vec![name.clone()], - Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + Object::Array(filters) => filters.iter().filter_map(|filter| doc.resolve(filter).as_name().map(<[u8]>::to_vec)).collect(), _ => Vec::new(), }, None => Vec::new(), }; - if filters.iter().any(|filter| filter == b"LZWDecode") { + if filters.iter().any(|filter| filter == b"LZWDecode" || filter == b"LZW") { issue("6.1.7.2", "A stream uses LZW compression", None, None, false); } if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { @@ -1223,6 +1275,86 @@ fn strip_actions(doc: &Document, d: &mut Dict) -> bool { changed } +/// Collects every indirect annotation reference listed in any page's `/Annots` array. +fn page_annot_refs(doc: &Document) -> std::collections::HashSet { + let mut refs = std::collections::HashSet::new(); + for p in pdfcraft_model::pages(doc) { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + refs.extend(a.iter().filter_map(Object::as_ref)); + } + } + refs +} + +/// Removes annotation `r` from every page's `/Annots` list and replaces its indirect object with `Null`. +fn remove_annot_from_pages(doc: &mut Document, r: ObjRef) -> Result<(), pdfcraft_cos::CosError> { + for p in pdfcraft_model::pages(doc) { + let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + if annots.iter().any(|a| a.as_ref() == Some(r)) { + let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); + doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + } + doc.set(r, Object::Null); + Ok(()) +} + +fn fix_annot_or_field(doc: &Document, nd: &mut Dict, is_annot: bool, sub: Option<&[u8]>, actions: &mut usize, flags: &mut usize) -> bool { + let mut changed = strip_actions(doc, nd); + *actions += changed as usize; + if is_annot && sub != Some(b"Popup") { + let f = nd.int(b"F").unwrap_or(0); + let want = (f | 4) & !(1 | 2 | 32); + if want != f { + nd.set(b"F".to_vec(), Object::Int(want)); + *flags += 1; + changed = true; + } + } + changed +} + +fn convert_direct_page_annots( + doc: &mut Document, + page: &pdfcraft_model::Page, + actions: &mut usize, + flags: &mut usize, + removed: &mut usize, +) -> Result<(), pdfcraft_cos::CosError> { + let Some(annots) = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()) else { + return Ok(()); + }; + if !annots.iter().any(|a| a.as_dict().is_some()) { + return Ok(()); + } + let mut kept = Vec::with_capacity(annots.len()); + let mut changed = false; + for entry in annots { + let Object::Dict(mut nd) = entry else { + kept.push(entry); + continue; + }; + let ty = nd.name(b"Type").map(<[u8]>::to_vec); + let sub = nd.name(b"Subtype").map(<[u8]>::to_vec); + let is_annot = is_annotation(doc, &nd, ty.as_deref(), sub.as_deref(), true); + if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { + *removed += 1; + changed = true; + continue; + } + if is_annot || nd.get(b"FT").is_some() { + changed |= fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), actions, flags); + } + kept.push(Object::Dict(nd)); + } + if changed { + doc.update_dict(page.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + Ok(()) +} + /// Make `doc` conform to `level` as far as possible, then re-check it. pub fn convert(doc: &mut Document, level: Level) -> Result { let mut fixed = Vec::new(); @@ -1293,45 +1425,26 @@ pub fn convert(doc: &mut Document, level: Level) -> Result::to_vec); let sub = d.name(b"Subtype").map(<[u8]>::to_vec); - let is_annot = ty.as_deref() == Some(b"Annot"); + let is_annot = matches!(&*o, Object::Dict(_)) && is_annotation(doc, &d, ty.as_deref(), sub.as_deref(), page_annots.contains(&r)); if is_annot && sub.as_deref().is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - // Take it off its page. - for p in pdfcraft_model::pages(doc) { - let annots = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - if annots.iter().any(|a| a.as_ref() == Some(r)) { - let kept: Vec = annots.into_iter().filter(|a| a.as_ref() != Some(r)).collect(); - doc.update_dict(p.obj, |pd| pd.set(b"Annots".to_vec(), Object::Array(kept)))?; - } - } - // The object stays in the file unless it is replaced: verify scans every object, so a - // forbidden annotation left behind would still be reported (clause 6.3.1). - doc.set(r, Object::Null); + remove_annot_from_pages(doc, r)?; removed += 1; continue; } if matches!(&*o, Object::Dict(_)) && (is_annot || d.get(b"FT").is_some()) { let mut nd = d.clone(); - let mut changed = strip_actions(doc, &mut nd); - actions += changed as usize; - if is_annot && sub.as_deref() != Some(b"Popup") { - let f = nd.int(b"F").unwrap_or(0); - let want = (f | 4) & !(1 | 2 | 32); - if want != f { - nd.set(b"F".to_vec(), Object::Int(want)); - flags += 1; - changed = true; - } - } - if changed { + if fix_annot_or_field(doc, &mut nd, is_annot, sub.as_deref(), &mut actions, &mut flags) { doc.set_existing(r, Object::Dict(nd))?; } continue; diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index e0107d9f21..fca88769e0 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -495,6 +495,89 @@ fn an_output_intent_profile_header_is_read_within_a_size_cap() { } } +#[test] +fn typeless_annotations_indexed_cmyk_and_indirect_filter_elements_are_handled() { + // Annotations in /Annots that omit optional /Type /Annot and /P (while keeping required /Subtype + // and /Rect), whether indirect or direct dictionaries, are still verified and fixed by convert. + // A malformed /Annots dictionary without /Type /Annot and without /Rect is not treated as an + // annotation or destructively removed by convert. + let mut doc = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R << /Subtype /Movie /Rect [60 10 70 30] >> << /Subtype /Sound >> << /Subtype /Sound /Rect null >> << /Subtype /Sound /Rect 9 0 R >>] >>", + "<< /Subtype /Text /Rect [10 10 30 30] /P 3 0 R /AP << /N 7 0 R >> >>", + "<< /Subtype /Sound /Rect [50 10 60 30] >>", + "<< /Subtype /Widget /FT /Tx /T (f) /Rect [70 10 90 30] /F 4 /AA << /E << /S /URI /URI (https://example.com) >> >> /AP << /N 7 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + "<< /Subtype /Movie >>", + "null", + ], + "", + ); + let issues = verify(&doc, Level::A2b); + let msgs = messages(&issues); + assert!(msgs.contains(&"A Text annotation isn't set to print, or is hidden"), "{msgs:#?}"); + assert!(msgs.contains(&"Sound annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"Movie annotations aren't allowed"), "{msgs:#?}"); + assert!(msgs.contains(&"A Widget annotation has additional actions"), "{msgs:#?}"); + assert!(!msgs.contains(&"A form field has additional actions"), "{msgs:#?}"); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(r.remaining.is_empty(), "{:#?}", r.remaining); + // The two /Rect-bearing forbidden annotations (5 0 R and the inline /Movie with /Rect) were + // removed, while the /Rect-less or null-/Rect dictionaries (8 0 R and the inline /Sound + // entries) without /Type /Annot are left untouched rather than destructively stripped. + assert_eq!(&*doc.get(ObjRef::new(5, 0)), &Object::Null); + assert!(doc.get(ObjRef::new(8, 0)).as_dict().is_some()); + let page_annots = doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(page_annots.len(), 6, "{page_annots:#?}"); + assert!(page_annots.iter().any(|entry| entry.as_ref() == Some(ObjRef::new(8, 0)))); + assert_eq!(page_annots.iter().filter(|entry| entry.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Sound"))).count(), 3); + + // Indexed and Separation colour spaces over DeviceCMYK on image XObjects or /ColorSpace resources count as CMYK. + let indexed_img = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", + "<< /Length 7 >>\nstream\n/Im0 Do\nendstream", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace [/Indexed /DeviceCMYK 0 <00000000>] /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream", + ], + "", + ); + assert!(uses_cmyk(&indexed_img)); + + let res_cmyk = drawing("/CS0 cs 0 sc 0 0 10 10 re f", "/ColorSpace << /CS0 [/Indexed /DeviceCMYK 0 <00000000>] >>", &[]); + assert!(uses_cmyk(&res_cmyk)); + let pattern_cmyk = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 [/Pattern /DeviceCMYK] >>", &[]); + assert!(uses_cmyk(&pattern_cmyk)); + let chained_cs = drawing("0 0 10 10 re f", "/ColorSpace << /CS0 5 0 R >>", &["6 0 R", "[7 0 R 8 0 R]", "/Pattern", "/DeviceCMYK"]); + assert!(uses_cmyk(&chained_cs)); + + // An indirect filter name inside a /Filter array or the /LZW abbreviation is still checked for LZWDecode. + let indirect_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter [5 0 R] /Length 0 >>\nstream\n\nendstream", + "/LZWDecode", + ], + "", + ); + assert!(messages(&verify(&indirect_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); + let abbrev_lzw = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", + "<< /Filter /LZW /Length 0 >>\nstream\n\nendstream", + ], + "", + ); + assert!(messages(&verify(&abbrev_lzw, Level::A2b)).contains(&"A stream uses LZW compression")); +} + /// PDF/A-2 6.1.13 holds a file to the implementation limits of ISO 32000-1 Annex C (Table C.1): /// an integer past 32 bits, a string over 32767 bytes or a name over 127 bytes (decoded: a name /// written with `#xx` escapes counts its bytes once read), in an object or in content; and a page diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 6ffa60b813..7a0506ca8b 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] craft-print = { workspace = true } +log = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } @@ -17,3 +18,5 @@ thiserror = { workspace = true } [dev-dependencies] pdfcraft-render = { workspace = true } +# Reads the renamed programs back with a parser that isn't ours. +skrifa = "0.44" diff --git a/crates/print/README.md b/crates/print/README.md index ba1de0cac7..53155071f2 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -26,14 +26,29 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() Headless: `doc_print` with `layout: "multiple"`, `order: "cut-stack"` and `per_sheet: 4` (or any supported grid size), plus `path` or `printer`. - **Booklet**: saddle-stitch imposition padded to a multiple of 4; both sides, front or back - only; left or right binding. + only; left or right binding; creep compensation, which moves each sheet's pages toward the + fold by its place from the outside times the creep (points per sheet, up to `MAX_CREEP`), + clipped at the fold. Headless: `doc_print` with `layout: "booklet"` and `creep`. - **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. - **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print only with their Print flag, drawn from their appearance streams (Algorithm 8.1). + Existing Link appearances are document content in the first three modes; Link appearances + are not synthesized when absent, and form fields only excludes them. - **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within` kills and reaps the command at the deadline). +- **Remembered choices** (#2001): the dialog keeps two-sided (including leaving it to the + printer), grayscale, collate, page sizing (with its custom percent), Comments & forms and a + booklet's binding side across restarts, in the settings file under `print` with the names + `doc_print` takes (`names::duplex`, `names::content`, `names::size_mode` and their `parse_*` + counterparts are the one vocabulary). The paper is saved by its own setting (#2156). +- **Each job starts afresh**: copies, pages (the choice, the range, odd/even and reverse), the + layout with its own options (pages per sheet, order, borders, booklet subset, poster scale, + overlap and cut marks) and orientation reset to the defaults each time Print opens, in the + same session too; ISO 32000-2 Table 147 initialises them per document when the print dialog + opens. Picked thumbnails still open it on Selected pages. The printer picked last carries over + within a session (#3537) but is not saved. Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable annotations); sheets place them with a clip. The result is a fresh, unencrypted, @@ -59,6 +74,28 @@ orientation, zero added margins and hardware-edge clipping. Long-edge and short- use the corresponding Windows binding edges. Random, exclusively created Windows job files are removed on success and failure. Other targets offer the print-ready PDF instead. +On Windows, **Printer default** reads the selected driver's current `DEVMODEW` duplex +setting immediately before submission and carries that binding explicitly into the job +(#5130). This also applies to `doc_print` with `duplex: "printer"`; its omitted-argument +default remains `off`. Explicit Off/Long Edge/Short Edge choices, booklet binding and +single-sided cut-and-stack bypass this lookup. Drivers without `DM_DUPLEX` retain the +unspecified setting. Two-sided defaults also stay unspecified when the driver does not +confirm automatic two-sided support, preserving manual duplex preferences (#5772). +A failed lookup, settings read or invalid declared duplex value is +logged and falls back to leaving duplex unspecified, so the driver can still choose its default. +If the queue was resolved before the settings failure, the job remains pinned to that queue. CUPS still leaves the choice to the queue. The Windows backend +selects `JobDuplexAllDocumentsContiguously`, falling back to `DocumentDuplex` when +the driver exposes only document-level duplex, and validates the retained binding before submission +([craft-libs #411](https://github.com/storytold/craft-libs/pull/411)). + +Before a job reaches the Windows spooler, every embedded TrueType or OpenType program is +renamed `PdfCraftPrint` in its `name` table and in the PDF (`/BaseFont`, `/FontName`; a +subset keeps its tag). Windows otherwise draws a subset's renumbered glyphs with the installed +font of the same name, so ReportLab's Arial subsets printed as shifted letters (#3132). Glyphs, +encodings and `/ToUnicode` are unchanged, and CUPS and Save as PDF get the PDF as it was. A +program PdfCraft can't read and rewrite keeps its old name in the PDF too, so a viewer that +can't load it either still picks its substitute by that name. + The shared dependency requires Rust 1.95. Transport and native Windows regression tests live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage. The desktop UI discovers printers and submits jobs on workers, so neither operation blocks diff --git a/crates/print/src/font_names.rs b/crates/print/src/font_names.rs new file mode 100644 index 0000000000..fc3b0e3a18 --- /dev/null +++ b/crates/print/src/font_names.rs @@ -0,0 +1,388 @@ +//! Embedded TrueType and OpenType programs renamed before a print-ready PDF goes to the Windows +//! spooler, so no installed font answers to their names (#3132). +//! +//! A subset program numbers its glyphs anew: ReportLab's Arial subsets draw a space with glyph 11 +//! (Arial Bold: glyph 8), where the installed Arial's space is glyph 3. Windows' print path +//! (Windows.Data.Pdf drawing into Direct2D's print control, through craft-print) keeps the +//! subset's glyph numbers but draws them with the installed font of the same name, so every letter +//! prints as the installed glyph with that number: `Revisión de clientes,` in the bold subset comes +//! out as `Wj{nxn s%ij%hqnjsyjx1`, its `z` as `Ä`. Both Brother queues and the Adobe PDF printer +//! show it; PdfCraft's renderer, CUPS and Chrome on Windows draw the embedded program and are right. +//! +//! [`distinct`] renames each program to `PdfCraftPrint`: the identity records of its `name` +//! table, `/FontName` of its descriptors and `/BaseFont` of the fonts drawn with it (a subset keeps +//! its tag, `AAAAAA+PdfCraftPrint1`). Glyphs, glyph numbers, cmaps, encodings, widths and +//! `/ToUnicode` are not touched, so pages draw and extract as before. A program that can't be +//! read and rewritten keeps its old name everywhere. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + +use crate::PrintError; + +/// Whether [`crate::spool`] renames the programs before spooling: on Windows only. +pub const RENAME_FOR_SPOOLER: bool = cfg!(windows); + +/// Decoded size past which a program is left as it is (a whole CJK font is about 20 MB). +const MAX_PROGRAM: usize = 64 << 20; + +/// Decoded bytes the whole renaming may read, across every program. A document's fonts rarely +/// come to 100 MB; a file of many distinct compressed bombs is read only this far. +const MAX_DECODED_IN_ALL: usize = 512 << 20; + +/// More tables than any font has; a larger count is a damaged directory. +const MAX_TABLES: usize = 256; + +/// The `name` records that say which font this is: family, unique ID, full name, PostScript name, +/// typographic family, Mac compatible full name, PostScript CID name, WWS family and the +/// variations PostScript name prefix. +const IDENTITY: [u16; 9] = [1, 3, 4, 6, 16, 18, 20, 21, 25]; + +/// The font subtypes whose descriptor can hold a TrueType or OpenType program. +const DRAWN_WITH_A_PROGRAM: [&[u8]; 5] = [b"TrueType", b"Type1", b"MMType1", b"CIDFontType0", b"CIDFontType2"]; + +/// An embedded program and what it will be called. +struct Program { + name: String, + /// A `/FontFile2` program, whose `/Length1` is the decoded length. + true_type: bool, +} + +/// The fonts drawn with embedded TrueType or OpenType programs, found by scanning every object. +#[derive(Default)] +struct Found { + /// Each program, numbered in object order. + programs: BTreeMap, + /// Simple fonts and CIDFonts, with their program. + fonts: BTreeMap, + /// Indirect font descriptors, with their program. + descriptors: BTreeMap, + /// Type 0 fonts whose CIDFont is an object of its own, with that CIDFont. + type0: BTreeMap, + /// Type 0 fonts whose CIDFont is written inline, with the array object holding it when + /// `/DescendantFonts` names one, and the CIDFont's program. + inline: BTreeMap, ObjRef)>, +} + +impl Found { + /// Note the program `font` draws with, and its descriptor when that is an object of its own; + /// the program, if it has one to rename. + fn add(&mut self, doc: &Document, font: &Dict) -> Option { + if !font.name(b"Subtype").is_some_and(|s| DRAWN_WITH_A_PROGRAM.contains(&s)) { + return None; + } + let descriptor = font.get(b"FontDescriptor")?; + let (program, true_type) = doc.dict(descriptor).and_then(|d| program(doc, &d))?; + let number = self.programs.len() + 1; + self.programs.entry(program).or_insert_with(|| Program { name: format!("PdfCraftPrint{number}"), true_type }); + if let Object::Ref(descriptor) = descriptor { + self.descriptors.insert(*descriptor, program); + } + Some(program) + } +} + +/// `pdf` with every embedded TrueType or OpenType program renamed; `None` when it has none that +/// can be. +pub fn distinct(pdf: &[u8]) -> Result>, PrintError> { + distinct_within(pdf, &mut DecodeBudget::new(MAX_DECODED_IN_ALL, 0)) +} + +/// [`distinct`], decoding the programs against `budget`: once it is spent, the programs not yet +/// read keep their names, as one that can't be read does. +pub(crate) fn distinct_within(pdf: &[u8], budget: &mut DecodeBudget) -> Result>, PrintError> { + let mut doc = Document::open(Arc::new(pdf.to_vec()))?; + let mut found = find(&doc); + // A program that can't be rewritten keeps its names in the PDF too: a viewer that can't load + // it either then still picks its substitute by the original name. The lookups below skip it. + found.programs.retain(|program, about| rename_program(&mut doc, *program, about, budget)); + if found.programs.is_empty() { + return Ok(None); + } + for (descriptor, program) in &found.descriptors { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*descriptor, |d| rename(d, b"FontName", &about.name))?; + } + for (font, program) in &found.fonts { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*font, |d| rename_font(d, &about.name))?; + } + // A Type 0 font is named after its CIDFont; renaming only the CIDFont would leave the old + // name on the font the page uses. + for (font, cid) in &found.type0 { + let Some(about) = found.fonts.get(cid).and_then(|p| found.programs.get(p)) else { continue }; + doc.update_dict(*font, |d| rename(d, b"BaseFont", &about.name))?; + } + for (font, (array, program)) in &found.inline { + let Some(about) = found.programs.get(program) else { continue }; + doc.update_dict(*font, |d| { + rename(d, b"BaseFont", &about.name); + if let Some(Object::Array(descendants)) = d.get_mut(b"DescendantFonts") { + rename_first(descendants, &about.name); + } + })?; + if let Some(array) = array { + let Object::Array(mut descendants) = doc.get(*array).as_ref().clone() else { continue }; + rename_first(&mut descendants, &about.name); + doc.set(*array, Object::Array(descendants)); + } + } + Ok(Some(write_full(&doc, &SaveOptions::default())?)) +} + +fn find(doc: &Document) -> Found { + let mut found = Found::default(); + for (reference, object) in doc.scan_objects() { + let Object::Dict(font) = object.as_ref() else { continue }; + if font.name(b"Subtype") == Some(b"Type0") { + find_descendant(doc, reference, font, &mut found); + } else if let Some(program) = found.add(doc, font) { + found.fonts.insert(reference, program); + } + } + found +} + +/// Note the CIDFont a Type 0 font is named after: an object of its own, renamed when the scan +/// reaches it, or written inline, which no scan reaches, so its program is noted here. +fn find_descendant(doc: &Document, font: ObjRef, d: &Dict, found: &mut Found) { + let Some(descendants) = d.get(b"DescendantFonts") else { return }; + let array = match descendants { + Object::Ref(array) => Some(*array), + _ => None, + }; + let descendants = doc.resolve(descendants); + match descendants.as_array().and_then(|a| a.first()) { + Some(Object::Ref(cid)) => { + found.type0.insert(font, *cid); + } + Some(Object::Dict(cid)) => { + if let Some(program) = found.add(doc, cid) { + found.inline.insert(font, (array, program)); + } + } + _ => {} + } +} + +/// Rename a simple font or CIDFont, and its descriptor when that is written inline. +fn rename_font(d: &mut Dict, name: &str) { + rename(d, b"BaseFont", name); + if let Some(Object::Dict(descriptor)) = d.get_mut(b"FontDescriptor") { + rename(descriptor, b"FontName", name); + } +} + +/// Rename the CIDFont written inline at the head of `/DescendantFonts`. +fn rename_first(descendants: &mut [Object], name: &str) { + if let Some(Object::Dict(cid)) = descendants.first_mut() { + rename_font(cid, name); + } +} + +/// The descriptor's TrueType program (`/FontFile2`), else its OpenType one (`/FontFile3` with +/// `/Subtype /OpenType`), with whether it is TrueType. +fn program(doc: &Document, descriptor: &Dict) -> Option<(ObjRef, bool)> { + if let Some(Object::Ref(program)) = descriptor.get(b"FontFile2") { + return Some((*program, true)); + } + let Some(Object::Ref(program)) = descriptor.get(b"FontFile3") else { return None }; + let open_type = matches!(doc.get(*program).as_ref(), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"OpenType")); + open_type.then_some((*program, false)) +} + +/// Set `key` to `name`, keeping the subset tag the old name had. +fn rename(d: &mut Dict, key: &[u8], name: &str) { + let mut renamed = d.name(key).and_then(subset_tag).map(<[u8]>::to_vec).unwrap_or_default(); + renamed.extend_from_slice(name.as_bytes()); + d.set(key.to_vec(), Object::Name(renamed)); +} + +/// `ABCDEF+` at the start of a subset font's name (ISO 32000-2 §9.9.2). +fn subset_tag(name: &[u8]) -> Option<&[u8]> { + let tag = name.get(..7)?; + let (letters, plus) = tag.split_at(6); + (letters.iter().all(u8::is_ascii_uppercase) && plus == b"+").then_some(tag) +} + +/// Rewrite the program with its `name` table renamed; whether it was. A program that can't be +/// decoded within `budget` or isn't an sfnt with a readable `name` table stays as it is. +fn rename_program(doc: &mut Document, program: ObjRef, about: &Program, budget: &mut DecodeBudget) -> bool { + let object = doc.get(program); + let Object::Stream(stream) = object.as_ref() else { return false }; + let Ok(bytes) = budget.decode_within(stream, MAX_PROGRAM) else { return false }; + let Some(renamed) = renamed_sfnt(&bytes, &about.name) else { return false }; + let mut dict = stream.dict.clone(); + dict.remove(b"DL"); + if about.true_type || dict.get(b"Length1").is_some() { + dict.set(b"Length1".to_vec(), Object::Int(i64::try_from(renamed.len()).unwrap_or(i64::MAX))); + } + doc.set(program, Object::Stream(Stream::flate(dict, &renamed))); + true +} + +fn u16_at(bytes: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(bytes: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn checksum(data: &[u8]) -> u32 { + data.chunks(4).fold(0_u32, |sum, c| { + let mut word = [0; 4]; + for (a, b) in word.iter_mut().zip(c) { + *a = *b; + } + sum.wrapping_add(u32::from_be_bytes(word)) + }) +} + +/// `program` with the identity records of its `name` table saying `name`. The new table goes +/// after the others, as `pdfcraft_fonts` writes its editor cmap, and the directory entry and +/// `head`'s checksum adjustment are brought up to date. `None` when `program` is not a single +/// sfnt font with a `name` table that can be read. +pub(crate) fn renamed_sfnt(program: &[u8], name: &str) -> Option> { + let version = program.get(..4)?; + if version != [0, 1, 0, 0] && version != b"true" && version != b"OTTO" { + return None; + } + let count = usize::from(u16_at(program, 4)?); + if count > MAX_TABLES { + return None; + } + let (mut entry, mut head) = (None, None); + for at in (0..count).map(|i| 12 + i * 16) { + match program.get(at..at + 4)? { + b"name" => entry = Some(at), + b"head" => head = Some(usize::try_from(u32_at(program, at + 8)?).ok()?), + _ => {} + } + } + let entry = entry?; + let offset = usize::try_from(u32_at(program, entry + 8)?).ok()?; + let length = usize::try_from(u32_at(program, entry + 12)?).ok()?; + let table = renamed_table(program.get(offset..offset.checked_add(length)?)?, name)?; + let mut out = program.to_vec(); + while !out.len().is_multiple_of(4) { + out.push(0); + } + let at = u32::try_from(out.len()).ok()?; + out.get_mut(entry + 4..entry + 8)?.copy_from_slice(&checksum(&table).to_be_bytes()); + out.get_mut(entry + 8..entry + 12)?.copy_from_slice(&at.to_be_bytes()); + out.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(table.len()).ok()?.to_be_bytes()); + out.extend_from_slice(&table); + if let Some(head) = head { + let adjustment = head.checked_add(8)?..head.checked_add(12)?; + out.get_mut(adjustment.clone())?.fill(0); + let sum = checksum(&out); + out.get_mut(adjustment)?.copy_from_slice(&0xb1b0afba_u32.wrapping_sub(sum).to_be_bytes()); + } + Some(out) +} + +/// Where a record's string comes from: the old table's storage (offset and length), or the new +/// name in UTF-16BE or in single bytes. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Text { + Old(u16, u16), + Wide, + Narrow, +} + +/// A `name` table (format 0 or 1) with its identity records saying `name`. The records stay in +/// their order, which the format requires to be sorted. An identity record in an encoding that +/// can't spell `name`, or any record whose string lies outside the table, is left out; a broken +/// language tag fails the table, because records refer to the tags by position. +/// +/// Nothing is copied until the whole table has been read, and then each distinct string once: +/// records that shared a string still share it, and the strings stay within what 16-bit offsets +/// reach, so a small table can't make the copy large (#4098). +fn renamed_table(table: &[u8], name: &str) -> Option> { + let format = u16_at(table, 0)?; + if format > 1 { + return None; + } + let count = usize::from(u16_at(table, 2)?); + let storage = usize::from(u16_at(table, 4)?); + let old = |offset: u16, length: u16| table.get(storage.checked_add(usize::from(offset))?..)?.get(..usize::from(length)); + let tags_at = 6 + count * 12; + let tag_count = if format == 1 { usize::from(u16_at(table, tags_at)?) } else { 0 }; + // The records and tags come before the strings, whose offset is 16 bits: a table that claims + // more of them overlaps its own strings and is refused. + u16::try_from(tags_at + if format == 1 { 2 + tag_count * 4 } else { 0 }).ok()?; + let mut records: Vec<([u16; 4], Text)> = Vec::with_capacity(count); + for at in (0..count).map(|i| 6 + i * 12) { + let field = |i: usize| u16_at(table, at + 2 * i); + let key = [field(0)?, field(1)?, field(2)?, field(3)?]; + let [platform, encoding, _, id] = key; + let text = if IDENTITY.contains(&id) { + spelling(platform, encoding) + } else { + let (length, offset) = (field(4)?, field(5)?); + old(offset, length).map(|_| Text::Old(offset, length)) + }; + if let Some(text) = text { + records.push((key, text)); + } + } + let mut tags = Vec::with_capacity(tag_count); + for at in (0..tag_count).map(|i| tags_at + 2 + 4 * i) { + let (length, offset) = (u16_at(table, at)?, u16_at(table, at + 2)?); + old(offset, length)?; + tags.push(Text::Old(offset, length)); + } + let wide: Vec = name.encode_utf16().flat_map(u16::to_be_bytes).collect(); + let mut placed: BTreeMap = BTreeMap::new(); + let mut strings = Vec::new(); + // A string's length and offset in the new storage, storing it the first time it is asked for. + let mut place = |text: Text| -> Option<[u16; 2]> { + if let Some(at) = placed.get(&text) { + return Some(*at); + } + let bytes = match text { + Text::Old(offset, length) => old(offset, length)?, + Text::Wide => &wide, + Text::Narrow => name.as_bytes(), + }; + let at = [u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?]; + strings.extend_from_slice(bytes); + placed.insert(text, at); + Some(at) + }; + let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 }; + let mut out = Vec::with_capacity(header); + for v in [format, u16::try_from(records.len()).ok()?, u16::try_from(header).ok()?] { + out.extend_from_slice(&v.to_be_bytes()); + } + for (key, text) in &records { + for v in key.iter().chain(&place(*text)?) { + out.extend_from_slice(&v.to_be_bytes()); + } + } + if format == 1 { + out.extend_from_slice(&u16::try_from(tags.len()).ok()?.to_be_bytes()); + for tag in &tags { + for v in place(*tag)? { + out.extend_from_slice(&v.to_be_bytes()); + } + } + } + out.extend_from_slice(&strings); + Some(out) +} + +/// How `name` (ASCII) is written in a `name` record of this platform and encoding: UTF-16BE for +/// Unicode and the Windows Unicode and symbol encodings, single bytes for Macintosh (every Mac +/// script encoding starts with ASCII); `None` for the legacy Windows CJK encodings and the ISO and +/// custom platforms. +fn spelling(platform: u16, encoding: u16) -> Option { + match (platform, encoding) { + (0, _) | (3, 0 | 1 | 10) => Some(Text::Wide), + (1, _) => Some(Text::Narrow), + _ => None, + } +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 19a75af32c..d5ad4e2648 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -100,7 +100,9 @@ pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); match subtype { b"Widget" => true, - b"Link" | b"Popup" => false, + // Existing Link appearances are part of the document, not markup or fields. + b"Link" => content != Content::FormFieldsOnly, + b"Popup" => false, b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table // 171) is not printed even with its Print flag set. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 74810db8d6..84cca070da 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -20,6 +20,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +mod font_names; pub mod range; pub mod spool; #[cfg(test)] @@ -38,10 +39,122 @@ use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, pub use imposition::{impose, keep_printed, sheet_pages}; pub use settings::{ Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, MAX_GRID, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, - cups_media, media_option, + cups_media, driver_needs_grayscale, media_option, }; #[cfg(test)] use sheets::text_direction; pub use sheets::{ - LABEL_MARGIN, MARGIN, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, + LABEL_MARGIN, MARGIN, MAX_CREEP, Placement, Sheet, add_cups_orientation, cups_orientation, layout, matching_paper, text_reads_up, texts_read_up, }; + +/// The names of the Print dialog's choices, shared by the `doc_print` tool and the settings +/// file, so a saved preference reads like a tool argument and there is one vocabulary to keep. +/// Each `parse_*` returns `None` for an unknown name (settings and tool arguments are +/// untrusted input; the caller keeps its default). +pub mod names { + use super::{Binding, Content, SizeMode, spool::Duplex}; + + /// Two-sided printing as `doc_print` names it: `None` leaves it to the printer (`printer`). + pub fn duplex(duplex: Option) -> &'static str { + match duplex { + None => "printer", + Some(Duplex::Off) => "off", + Some(Duplex::LongEdge) => "long-edge", + Some(Duplex::ShortEdge) => "short-edge", + } + } + + /// [`duplex`] read back; `None` for a name it doesn't give. + pub fn parse_duplex(name: &str) -> Option> { + match name { + "printer" => Some(None), + "off" => Some(Some(Duplex::Off)), + "long-edge" => Some(Some(Duplex::LongEdge)), + "short-edge" => Some(Some(Duplex::ShortEdge)), + _ => None, + } + } + + /// Acrobat's Comments & Forms choices. + pub fn content(content: Content) -> &'static str { + match content { + Content::Document => "document", + Content::DocumentAndMarkups => "document-and-markups", + Content::DocumentAndStamps => "document-and-stamps", + Content::FormFieldsOnly => "form-fields-only", + } + } + + pub fn parse_content(name: &str) -> Option { + match name { + "document" => Some(Content::Document), + "document-and-markups" => Some(Content::DocumentAndMarkups), + "document-and-stamps" => Some(Content::DocumentAndStamps), + "form-fields-only" => Some(Content::FormFieldsOnly), + _ => None, + } + } + + /// The page sizing choices; a custom scale is named `custom` and carries its percent + /// separately (see [`parse_size_mode`]). + pub fn size_mode(mode: SizeMode) -> &'static str { + match mode { + SizeMode::Fit => "fit", + SizeMode::Actual => "actual", + SizeMode::Shrink => "shrink", + SizeMode::Custom(_) => "custom", + } + } + + /// `custom` takes `scale` as its percent; the caller has validated it. + pub fn parse_size_mode(name: &str, scale: f64) -> Option { + match name { + "fit" => Some(SizeMode::Fit), + "actual" => Some(SizeMode::Actual), + "shrink" => Some(SizeMode::Shrink), + "custom" => Some(SizeMode::Custom(scale)), + _ => None, + } + } + + /// A booklet's binding side. + pub fn binding(binding: Binding) -> &'static str { + match binding { + Binding::Left => "left", + Binding::Right => "right", + } + } + + pub fn parse_binding(name: &str) -> Option { + match name { + "left" => Some(Binding::Left), + "right" => Some(Binding::Right), + _ => None, + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn every_choice_round_trips_through_its_name_and_unknown_names_are_none() { + for d in [None, Some(Duplex::Off), Some(Duplex::LongEdge), Some(Duplex::ShortEdge)] { + assert_eq!(parse_duplex(duplex(d)), Some(d)); + } + for c in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + assert_eq!(parse_content(content(c)), Some(c)); + } + for m in [SizeMode::Fit, SizeMode::Actual, SizeMode::Shrink, SizeMode::Custom(62.5)] { + assert_eq!(parse_size_mode(size_mode(m), 62.5), Some(m)); + } + for b in [Binding::Left, Binding::Right] { + assert_eq!(parse_binding(binding(b)), Some(b)); + } + assert_eq!(parse_binding("Right"), None); + assert_eq!(parse_duplex("Long-Edge"), None); + assert_eq!(parse_content(""), None); + assert_eq!(parse_size_mode("fit-to-page", 100.0), None); + } + } +} diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index ff860f5c65..2879b70994 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -75,9 +75,12 @@ pub enum Layout { /// portrait sheet that turns to landscape when that suits it. custom: bool, }, + /// `creep` (points, per sheet) moves sheet k's pages toward the fold by `k × creep`, sheet 0 + /// being the outermost, so the margins match once the nested sheets are folded and trimmed. Booklet { subset: BookletSubset, binding: Binding, + creep: f64, }, /// Tile scale (percent), overlap (points), cut marks. Poster { @@ -178,6 +181,14 @@ impl Default for Raster { } } +/// Whether a print asked to be `grayscale` needs the printer's driver to print in grayscale. +/// Not when the pages go as grey pictures (`raster` with [`Raster::grayscale`]): they are grey +/// already, so a driver that keeps no grayscale setting (Microsoft Print to PDF, many inkjets) +/// still prints them instead of the job being refused (#5806). +pub fn driver_needs_grayscale(grayscale: bool, raster: Option) -> bool { + grayscale && !raster.is_some_and(|r| r.grayscale) +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Crop { pub rect: [f64; 4], @@ -199,7 +210,7 @@ impl Default for Settings { } /// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 21] = [ +pub const PAPERS: [(&str, (f64, f64)); 23] = [ ("US Letter", (612.0, 792.0)), ("US Legal", (612.0, 1008.0)), ("Tabloid", (792.0, 1224.0)), @@ -221,6 +232,8 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [ ("A0", (2383.94, 3370.39)), ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), + ("Folio (8.5 x 13 in)", (612.0, 936.0)), + ("F4 (210 x 330 mm)", (595.28, 935.43)), ]; /// `PAPERS` index of the 4 × 6 in shipping label. @@ -255,6 +268,8 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "A1" => "iso_a1_594x841mm", "A0" => "iso_a0_841x1189mm", "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + "Folio (8.5 x 13 in)" => "na_foolscap_8.5x13in", + "F4 (210 x 330 mm)" => "om_folio_210x330mm", _ if index == LABEL_PAPER => "w288h432", // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's // paper stands, as it did for every size before #1583. diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index cca4bdc07e..ccc0c1d737 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -168,6 +168,25 @@ fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: b Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } } +/// The most creep per sheet, in points. Paper adds a fraction of a point per sheet; more is a mistake. +pub const MAX_CREEP: f64 = 10.0; + +/// `placed` moved `shift` points across the sheet (positive is rightward) and clipped at the fold +/// at `fold`, so a page moved toward it never prints on the facing page. +fn creep_toward_fold(mut placed: Placement, shift: f64, fold: f64, s: f64) -> Placement { + if shift == 0.0 || s <= 0.0 { + return placed; + } + placed.matrix.0[4] += shift; + let at_fold = (fold - placed.matrix.0[4]) / s; + if shift > 0.0 { + placed.clip[2] = placed.clip[2].min(at_fold); + } else { + placed.clip[0] = placed.clip[0].max(at_fold); + } + placed +} + fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } } @@ -288,13 +307,19 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P sheets.push(sheet); } } - Layout::Booklet { subset, binding } => { + Layout::Booklet { subset, binding, creep } => { // Pad to a multiple of 4 with blank pages (None). let n4 = pages.len().div_ceil(4) * 4; let at = |i: usize| pages.get(i).copied(); let size = oriented(paper, Orientation::Landscape, true); let half = (size.0 - 2.0 * MARGIN) / 2.0; let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + // The innermost sheet moves furthest; it must stay within half its cell. `pages` is not + // empty (checked above), so there is at least one sheet. + let innermost = creep * (n4 / 4 - 1) as f64; + if !((0.0..=MAX_CREEP).contains(&creep) && innermost < half / 2.0) { + return Err(PrintError::Invalid("invalid booklet creep".into())); + } for k in 0..n4 / 4 { let front = (at(n4 - 1 - 2 * k), at(2 * k)); let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); @@ -308,11 +333,12 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P for (l, r) in sides { let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { + for (p, cell, toward) in [(l, cells[0], 1.0), (r, cells[1], -1.0)] { if let Some(p) = p { let d = sizes[p]; let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); + let placed = place_in(p, d, cell, s, false); + sheet.placed.push(creep_toward_fold(placed, toward * creep * k as f64, MARGIN + half, s)); } } sheets.push(sheet); diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index b16f57026d..ddeba8354a 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -6,6 +6,9 @@ pub use craft_print::{ parse_lpstat_e, parse_ppd, parse_windows_printers, ppd_text, printers_parsed, }; +#[cfg(any(windows, test))] +mod windows_duplex; + /// Windows: the in-box DLLs printing draws and describes jobs with. The executables delay-load /// them (`apps/pdfcraft{,-cli}/build.rs`, and `build.rs` for this crate's tests: the same list) /// so a Windows installation without them still starts (#2630); a call into a missing @@ -24,8 +27,9 @@ thread_local! { /// Tests: the folder the print DLLs are looked for in, instead of the system's, so the /// public entry points can be checked against a Windows that lacks them. pub(crate) static SYSTEM32_FOR_TEST: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - /// Tests: how many times [`printers`] asked the system's printer backend, so a test can tell - /// the guard answered without relying on the machine having no printers. + /// Tests: how many times [`printers`], [`printer_queues`] or [`two_sided`] asked the system's + /// printer backend, so a test can tell the guard answered without relying on the machine + /// having no printers. pub(crate) static PRINTER_BACKEND_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -59,6 +63,29 @@ pub fn printers() -> Vec { craft_print::printers() } +/// The system's print queues without asking any driver, so a Print dialog's list never waits on +/// a printer (#2192); none on a Windows installation without the print DLLs, as [`printers`]. +pub fn printer_queues() -> Vec { + if missing_print_dll().is_some() { + return Vec::new(); + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::printer_queues() +} + +/// Whether `printer`'s driver takes two-sided settings, read for that one printer; it can wait +/// on the device, so call it off the UI thread. Unknown on a Windows installation without the +/// print DLLs: reading it goes through them. +pub fn two_sided(printer: &str) -> Option { + if missing_print_dll().is_some() { + return None; + } + #[cfg(all(windows, test))] + PRINTER_BACKEND_CALLS.with(|c| c.set(c.get().saturating_add(1))); + craft_print::two_sided(printer) +} + /// The driver's job options for a printer (CUPS: its PPD, with the queue's current defaults); /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. @@ -164,10 +191,14 @@ pub(crate) fn printer_options_via_within( ) -> Vec { let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); // Retain the conservative name checks, applied to each component. Neither component is - // a path: reject dot components, backslashes and any additional separator before I/O. + // a path: reject dot components, backslashes and any additional separator before I/O. A + // leading `-` is refused too: `lpoptions` parses its argv with getopt, so `-p -E -l` would + // read `-E` as an option rather than as the destination, and `--` cannot help because it + // ends option parsing before the `-p` that names the queue. let valid = |name: &str| { !name.is_empty() && name.len() <= 64 + && !name.starts_with('-') && !matches!(name, "." | "..") && name.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b'\\' | b':' | b'=' | b'"')) }; @@ -181,11 +212,20 @@ pub(crate) fn printer_options_via_within( f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) }); let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default(); + let lpoptions_out = output_within(lpoptions.args(["-p", printer, "-l"]), limit).ok(); + let current = lpoptions_out.as_deref().map(|out| parse_lpoptions(&String::from_utf8_lossy(out))).unwrap_or_default(); + if options.is_empty() { + // The PPD is missing or unreadable (#4699). The same `lpoptions -l` listing carries every + // option with its choices and default; build a fallback list from it. + if let Some(out) = &lpoptions_out { + options = parse_lpoptions_list(&String::from_utf8_lossy(out)); + } + } + // After the fallback: a readable PPD whose `*OpenUI` blocks yielded nothing still supplies the + // translations, and `localize` leaves options it cannot translate untouched. if let Some(b) = &ppd { localize(&mut options, b, lang); } - let current = - output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).unwrap_or_default(); for (key, value) in current { if let Some(o) = options.iter_mut().find(|o| o.key == key) && o.choices.iter().any(|(c, _)| *c == value) @@ -196,6 +236,108 @@ pub(crate) fn printer_options_via_within( options } +/// Caps that match [`craft_print::parse_ppd`]: a malformed spooler answer is not allowed to +/// dominate memory or the dialog. +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_OPTIONS: usize = 400; +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_CHOICES: usize = 400; +#[cfg(all(unix, not(target_arch = "wasm32")))] +const MAX_LPOPTIONS_LABEL: usize = 256; + +/// A PPD keyword: printable ASCII without spaces, `/`, `:`, `=` or `"`. +#[cfg(all(unix, not(target_arch = "wasm32")))] +// The three name helpers below mirror `is_ppd_keyword`, `name_label` and `ppd_label` in craft-print, +// which are private there: keep them in step with that crate, or they will accept in one reader what +// the other refuses. +fn is_lpoptions_keyword(s: &str) -> bool { + !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_graphic() && !matches!(b, b'/' | b':' | b'=' | b'"')) +} + +/// `Name/Label` → (`Name`, `Label`), the label falling back to the name. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn lpoptions_name_label(s: &str) -> (&str, &str) { + match s.split_once('/') { + Some((n, l)) if !l.trim().is_empty() => (n.trim(), l.trim()), + Some((n, _)) => (n.trim(), n.trim()), + None => (s.trim(), s.trim()), + } +} + +/// `label`, cut to [`MAX_LPOPTIONS_LABEL`] characters. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn lpoptions_label(label: &str) -> String { + label.chars().take(MAX_LPOPTIONS_LABEL).collect() +} + +/// Whether `choice` is a custom-value template. For a choice the user has to type, `lpoptions -l` +/// prints `Custom`, `Custom.` (`Custom.PASSCODE`, `Custom.STRING`, …) or `Custom.WIDTHxHEIGHT` +/// in place of the choices, and the value is not something Craft can collect: it would be sent to +/// the driver as the literal text. The prefix is reserved by CUPS, case-insensitively. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn is_lpoptions_custom_template(choice: &str) -> bool { + match choice.split_once('.') { + Some((name, _)) => name.eq_ignore_ascii_case("Custom"), + None => choice.eq_ignore_ascii_case("Custom"), + } +} + +/// Build [`PrinterOption`]s from the `lpoptions -p NAME -l` listing (`Key/Label: a *b c`). This is +/// the fallback when the PPD cannot be read, so there is no group information: every option lands in +/// one unnamed group, and the listing cannot say which options sit under `InstallableOptions`, so an +/// optional-hardware entry that [`parse_ppd`] drops may appear here. The starred choice is the +/// driver's default. Options Craft handles itself ([`HANDLED_OPTIONS`]) and custom-value templates +/// ([`is_lpoptions_custom_template`]) are skipped. +/// +/// The line is split at its **last** colon, not its first: `list_group()` prints the option as +/// `"%s/%s:"` from the PPD's keyword and translated text, then every choice as a bare `" %s"`, so +/// text such as `Supporto: Tipo` carries colons while a choice keyword cannot. Splitting at the +/// first colon would turn the rest of the label into invented choices. +#[cfg(all(unix, not(target_arch = "wasm32")))] +fn parse_lpoptions_list(out: &str) -> Vec { + let mut options = Vec::new(); + for line in out.lines() { + let line = line.trim_end_matches('\r'); + let Some((head, choices)) = line.rsplit_once(':') else { + continue; + }; + let (key, label) = lpoptions_name_label(head); + if !is_lpoptions_keyword(key) || HANDLED_OPTIONS.contains(&key) { + continue; + } + let mut choice_list = Vec::new(); + let mut default = String::new(); + // Set when the choice CUPS marks is a template the dialog cannot collect. The printer keeps + // that value, so standing in the first retained choice would show a default the job does not + // use: only options changed away from the default are submitted. + let mut marked_template = false; + for token in choices.split_whitespace() { + let starred = token.starts_with('*'); + let (choice_key, choice_label) = lpoptions_name_label(token.strip_prefix('*').unwrap_or(token)); + if !is_lpoptions_keyword(choice_key) || is_lpoptions_custom_template(choice_key) { + marked_template |= starred && is_lpoptions_custom_template(choice_key); + continue; + } + if starred { + default = choice_key.to_string(); + } + if choice_list.len() < MAX_LPOPTIONS_CHOICES && choice_list.iter().all(|(c, _)| c != choice_key) { + choice_list.push((choice_key.to_string(), lpoptions_label(choice_label))); + } + } + if choice_list.is_empty() || options.len() >= MAX_LPOPTIONS_OPTIONS { + continue; + } + if default.is_empty() && !marked_template { + // No choice marked at all: the dialog needs something selected, and the listing has + // nothing better than the first one to offer. + default = choice_list.first().map(|(c, _)| c.clone()).unwrap_or_default(); + } + options.push(PrinterOption { key: key.to_string(), label: lpoptions_label(label), group: String::new(), choices: choice_list, default }); + } + options +} + /// The keys the macOS print dialog names a job's title and owner under. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName"; @@ -248,7 +390,12 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result { if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } - craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); + let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); + craft_print::submit(&pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string())) } /// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the @@ -257,7 +404,26 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz if let Some(dll) = missing_print_dll() { return Err(unavailable(dll)); } - craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) + #[cfg(windows)] + let resolved = windows_duplex::job(job); + #[cfg(windows)] + let job = resolved.as_ref(); + let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER); + craft_print::submit_with_progress(&pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string())) +} + +/// The PDF as the system spooler takes it: with `rename` (Windows), its embedded TrueType and +/// OpenType programs renamed, so Windows can't draw a subset's glyph numbers with an installed +/// font of the same name (#3132, [`crate::font_names`]). A PDF that can't be read back goes as it +/// is: the renaming only protects the text from Windows, and the job shouldn't fail over it. +pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8]> { + if !rename { + return std::borrow::Cow::Borrowed(pdf); + } + match crate::font_names::distinct(pdf) { + Ok(Some(renamed)) => std::borrow::Cow::Owned(renamed), + Ok(None) | Err(_) => std::borrow::Cow::Borrowed(pdf), + } } pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { @@ -271,7 +437,9 @@ pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> fn ppd_languages(lang: &str) -> Vec { let lang = lang.to_ascii_lowercase(); let mut parts = lang.split(['-', '_']).filter(|p| !p.is_empty()); - let Some(primary) = parts.next() else { return Vec::new() }; + let Some(primary) = parts.next() else { + return Vec::new(); + }; let region = match (primary, parts.next()) { ("zh", Some("hans")) => Some("CN".to_string()), ("zh", Some("hant")) => Some("TW".to_string()), @@ -303,7 +471,9 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { let mut choices: HashMap<(String, String), String> = HashMap::new(); let mut groups: HashMap = HashMap::new(); for line in ppd.split(|b| *b == b'\n') { - let Some(text) = std::str::from_utf8(line).ok() else { continue }; + let Some(text) = std::str::from_utf8(line).ok() else { + continue; + }; let text = text.trim_end_matches('\r'); if let Some(rest) = text.strip_prefix("*OpenGroup:") { // Group label → keyword, since options carry their group's label. @@ -313,10 +483,18 @@ pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { } continue; } - let Some(rest) = text.strip_prefix(prefix.as_str()) else { continue }; - let Some((head, _)) = rest.split_once(':') else { continue }; - let Some((main, spec)) = head.split_once(' ') else { continue }; - let Some((key, label)) = spec.split_once('/') else { continue }; + let Some(rest) = text.strip_prefix(prefix.as_str()) else { + continue; + }; + let Some((head, _)) = rest.split_once(':') else { + continue; + }; + let Some((main, spec)) = head.split_once(' ') else { + continue; + }; + let Some((key, label)) = spec.split_once('/') else { + continue; + }; let label = label.trim(); if label.is_empty() { continue; diff --git a/crates/print/src/spool/windows_duplex.rs b/crates/print/src/spool/windows_duplex.rs new file mode 100644 index 0000000000..eca09cf9a1 --- /dev/null +++ b/crates/print/src/spool/windows_duplex.rs @@ -0,0 +1,214 @@ +//! Resolve Printer default from the Windows driver's current DEVMODE. Omitting duplex +//! from a PrintTicket leaves its interpretation to the XPS driver (#5130). + +use std::borrow::Cow; + +use super::{Duplex, Job}; +use crate::PrintError; + +/// Preserve an explicit duplex choice by borrowing `job` without calling `read`. +/// Otherwise call `read` once with the selected printer (or `None` for the system default), +/// returning an owned copy with the resolved printer and duplex value. Log lookup failures +/// and leave duplex unspecified so the spooler can use its previous default behavior; +/// the original job remains unchanged even when the driver has no duplex setting. +fn resolve<'a>(job: &'a Job, read: impl FnOnce(Option<&str>) -> Result<(String, Option), PrintError>) -> Cow<'a, Job> { + if job.duplex.is_some() { + return Cow::Borrowed(job); + } + let (printer, duplex) = match read(job.printer.as_deref()) { + Ok(settings) => settings, + Err(error) => { + driver_default(Err(error)); + return Cow::Borrowed(job); + } + }; + let mut resolved = job.clone(); + resolved.printer = Some(printer); + resolved.duplex = duplex; + Cow::Owned(resolved) +} + +/// A driver default is best-effort: record the fallback and let the driver choose. +fn driver_default(result: Result, PrintError>) -> Option { + match result { + Ok(value) => value, + Err(error) => { + log::warn!("could not resolve the printer's two-sided default; leaving duplex unspecified: {error}"); + None + } + } +} + +/// Preserve manual two-sided defaults when PrintCapabilities say the driver cannot take +/// explicit two-sided settings (#5772). Unknown capabilities also keep the driver's default: +/// turning a best-effort preference into a strict request could otherwise refuse a working job. +fn supported_default(value: Option, support: impl FnOnce() -> Option) -> Option { + if matches!(value, Some(Duplex::LongEdge | Duplex::ShortEdge)) && support() != Some(true) { None } else { value } +} + +/// DEVMODEW's public dmFields (offset 72) marks dmDuplex (offset 94) as valid with +/// DM_DUPLEX. Values are DMDUP_SIMPLEX, DMDUP_VERTICAL and DMDUP_HORIZONTAL, respectively. +/// Return `None` when DM_DUPLEX is absent, or a spool error for a malformed field or an +/// unrecognized declared value. Layout: . +fn duplex(settings: &craft_print::rgb::DevMode) -> Result, PrintError> { + let bytes = settings.as_bytes(); + let invalid = || PrintError::Spool("the printer driver returned an invalid two-sided default; check its printing preferences".into()); + let fields = bytes.get(72..76).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes).ok_or_else(invalid)?; + if fields & 0x1000 == 0 { + return Ok(None); + } + match bytes.get(94..96).and_then(|b| <[u8; 2]>::try_from(b).ok()).map(i16::from_le_bytes) { + Some(1) => Ok(Some(Duplex::Off)), + Some(2) => Ok(Some(Duplex::LongEdge)), + Some(3) => Ok(Some(Duplex::ShortEdge)), + _ => Err(invalid()), + } +} + +#[cfg(windows)] +/// Resolve an unspecified duplex setting from the selected Windows printer immediately +/// before submission. An unnamed printer is resolved to the system default and pinned to +/// the returned job. Explicit choices bypass the driver lookup. Lookup failures are logged +/// and leave duplex unspecified; a settings failure retains the resolved queue. +pub(super) fn job(job: &Job) -> Cow<'_, Job> { + resolve(job, |selected| { + let failed = |e| PrintError::Spool(format!("could not read the printer's two-sided default: {e}")); + let printer = match selected { + Some(name) => name.to_owned(), + None => craft_print::rgb::default_printer() + .map_err(failed)? + .ok_or_else(|| PrintError::Spool("there is no default printer; choose a printer".into()))?, + }; + let value = craft_print::rgb::default_settings(&printer).map_err(failed).and_then(|settings| duplex(&settings)); + // Only this printer's driver is asked (craft-print `two_sided`), not every queue's, as + // `printers()` would; a networked printer in power save could hold the job (#2192). + let value = supported_default(driver_default(value), || craft_print::two_sided(&printer)); + Ok((printer, value)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(fields: u32, value: i16) -> craft_print::rgb::DevMode { + let mut bytes = vec![0; 220]; + bytes[68..70].copy_from_slice(&220u16.to_le_bytes()); + bytes[72..76].copy_from_slice(&fields.to_le_bytes()); + bytes[94..96].copy_from_slice(&value.to_le_bytes()); + craft_print::rgb::DevMode::from_bytes(bytes).unwrap() + } + + #[test] + fn printer_default_reaches_the_windows_job_for_each_binding() { + for (value, expected) in [(1, Duplex::Off), (2, Duplex::LongEdge), (3, Duplex::ShortEdge)] { + let original = Job { + printer: Some("Office".into()), + copies: 3, + collate: false, + grayscale: true, + title: "report".into(), + options: vec![("media".into(), "A4".into())], + ..Job::default() + }; + let resolved = resolve(&original, |selected| { + assert_eq!(selected, Some("Office")); + Ok(("Office".into(), duplex(&settings(0x1000, value))?)) + }); + assert_eq!(resolved.duplex, Some(expected)); + assert_eq!(resolved.printer.as_deref(), Some("Office")); + assert_eq!(resolved.copies, 3); + assert_eq!(resolved.collate, original.collate); + assert_eq!(resolved.grayscale, original.grayscale); + assert_eq!(resolved.scaling, original.scaling); + assert_eq!(resolved.options, original.options); + assert_eq!(resolved.title, "report"); + assert_eq!(original.duplex, None, "Printer default remains selected for the next job"); + } + } + + #[test] + fn explicit_choices_do_not_read_or_change_the_printer_default() { + for choice in [Duplex::Off, Duplex::LongEdge, Duplex::ShortEdge] { + let original = Job { duplex: Some(choice), ..Job::default() }; + let resolved = resolve(&original, |_| panic!("explicit choice must skip driver settings")); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, Some(choice)); + } + } + + #[test] + fn system_default_queue_is_resolved_once_and_pinned_to_the_job() { + let original = Job::default(); + let resolved = resolve(&original, |selected| { + assert_eq!(selected, None); + Ok(("System default".into(), Some(Duplex::ShortEdge))) + }); + assert_eq!(resolved.printer.as_deref(), Some("System default")); + assert_eq!(resolved.duplex, Some(Duplex::ShortEdge)); + } + + #[test] + fn missing_duplex_field_is_left_to_the_driver_and_invalid_values_fail() { + assert_eq!(duplex(&settings(0, 99)).unwrap(), None); + for value in [0, -1, 4, i16::MAX] { + assert!(duplex(&settings(0x1000, value)).is_err()); + } + let original = Job::default(); + let resolved = resolve(&original, |_| Ok(("Simplex".into(), None))); + assert_eq!(resolved.duplex, None); + } + + #[test] + fn a_queue_lookup_failure_preserves_the_original_job() { + let original = Job::default(); + let resolved = resolve(&original, |_| Err(PrintError::Spool("driver unavailable".into()))); + assert!(matches!(resolved, Cow::Borrowed(_))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer, original.printer); + } + + #[test] + fn failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue() { + for value in [Err(PrintError::Spool("driver unavailable".into())), duplex(&settings(0x1000, 99))] { + let original = Job { copies: 3, ..Job::default() }; + let resolved = resolve(&original, |_| Ok(("Resolved queue".into(), driver_default(value)))); + assert_eq!(resolved.duplex, None); + assert_eq!(resolved.printer.as_deref(), Some("Resolved queue")); + assert_eq!(resolved.copies, 3); + assert_eq!(original.printer, None); + } + } + + #[test] + fn manual_duplex_defaults_remain_unspecified_without_automatic_support() { + for binding in [2, 3] { + for support in [Some(false), None, Some(true)] { + let original = Job { printer: Some("Manual duplex".into()), ..Job::default() }; + let value = duplex(&settings(0x1000, binding)).unwrap(); + let resolved = resolve(&original, |_| Ok(("Manual duplex".into(), supported_default(value, || support)))); + assert_eq!(resolved.duplex, if support == Some(true) { value } else { None }); + assert_eq!(resolved.printer.as_deref(), Some("Manual duplex")); + assert_eq!(original.duplex, None); + } + } + assert_eq!(supported_default(Some(Duplex::Off), || panic!("simplex needs no capability query")), Some(Duplex::Off)); + assert_eq!(supported_default(None, || panic!("unspecified needs no capability query")), None); + } + + /// Reads settings only: never submits a job or changes the printer's preferences. + #[cfg(windows)] + #[test] + #[ignore = "needs PDFCRAFT_PRINT_TEST_PRINTER naming an installed Windows printer; sends no job"] + fn installed_windows_printer_default_is_carried_explicitly() { + let printer = std::env::var("PDFCRAFT_PRINT_TEST_PRINTER").unwrap(); + let original = Job { printer: Some(printer.clone()), ..Job::default() }; + let expected = supported_default(duplex(&craft_print::rgb::default_settings(&printer).unwrap()).unwrap(), || { + super::super::printers().into_iter().find(|p| p.name == printer).and_then(|p| p.two_sided) + }); + let resolved = job(&original); + assert_eq!(resolved.printer.as_deref(), Some(printer.as_str())); + assert_eq!(resolved.duplex, expected); + eprintln!("native Windows default: {expected:?}; resolved job: {:?}", resolved.duplex); + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 0cd844a53d..a80daf919b 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -288,15 +288,58 @@ fn imposing_lists_the_pages_once() { #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; - let sheets = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })).unwrap(); + let sheets = + layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 })) + .unwrap(); // 6 pages pad to 8: sheet 1 front [8,1] → [blank, 0], back [1, 6]; sheet 2 front [5, 2], back [3, 4]. assert_eq!(sheet_pages(&sheets), [vec![0], vec![1], vec![5, 2], vec![3, 4]]); assert!(sheets[0].placed[0].matrix.0[4] >= 395.9, "page 1 on the right half"); assert_eq!(sheets[0].size, (792.0, 612.0)); - let front = layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right })).unwrap(); + let front = + layout(&sizes, &settings((0..6).collect(), Layout::Booklet { subset: BookletSubset::FrontOnly, binding: Binding::Right, creep: 0.0 })) + .unwrap(); assert_eq!(sheet_pages(&front), [vec![0], vec![2, 5]], "right binding mirrors the spread"); } +/// #6777: creep moves each sheet's pages toward the fold by its place from the outside, and clips +/// them at the fold, for either binding. +#[test] +fn booklet_creep_moves_inner_sheets_toward_the_fold() { + let sizes = vec![(200.0, 300.0); 8]; + let booklet = + |binding, creep| layout(&sizes, &settings((0..8).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding, creep })).unwrap(); + for binding in [Binding::Left, Binding::Right] { + let (flat, crept) = (booklet(binding, 0.0), booklet(binding, 2.0)); + assert_eq!(sheet_pages(&flat), sheet_pages(&crept)); + let fold = 792.0 / 2.0; + for (side, (a, b)) in flat.iter().zip(&crept).enumerate() { + let shift = if side < 2 { 0.0 } else { 2.0 }; + let (left, right) = ((&a.placed[0], &b.placed[0]), (&a.placed[1], &b.placed[1])); + assert!((left.1.matrix.0[4] - left.0.matrix.0[4] - shift).abs() < 1e-9, "{binding:?} side {side}: the left page moves right"); + assert!((right.1.matrix.0[4] - right.0.matrix.0[4] + shift).abs() < 1e-9, "{binding:?} side {side}: the right page moves left"); + let s = left.1.matrix.0[0]; + assert!(left.1.matrix.0[4] + left.1.clip[2] * s <= fold + 1e-9, "{binding:?} side {side}: the left page stops at the fold"); + assert!(right.1.matrix.0[4] + right.1.clip[0] * s >= fold - 1e-9, "{binding:?} side {side}: the right page starts at the fold"); + } + } +} + +#[test] +fn booklet_creep_is_refused_when_it_is_not_a_small_positive_amount() { + let sizes = vec![(200.0, 300.0); 8]; + let booklet = |sizes: &[(f64, f64)], creep| { + layout(sizes, &settings((0..sizes.len()).collect(), Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep })) + }; + for creep in [f64::NAN, f64::INFINITY, -1.0, MAX_CREEP + 0.1] { + assert!(matches!(booklet(&sizes, creep), Err(PrintError::Invalid(_))), "{creep}"); + } + assert!(booklet(&sizes, MAX_CREEP).is_ok()); + // 400 pages fold into 100 sheets: 5 pt each would move the innermost past half its cell. + let thick = vec![(200.0, 300.0); 400]; + assert!(booklet(&thick, 1.0).is_ok()); + assert!(matches!(booklet(&thick, 5.0), Err(PrintError::Invalid(_))), "the innermost sheet moves too far"); +} + #[test] fn posters_tile_with_overlap() { // A 200×300 page at 400% = 800×1200 on Letter with 18 pt margins (576×756 tiles), overlap 36. @@ -709,9 +752,11 @@ fn landscape_sheets_ask_cups_to_turn_the_page() { // A landscape Size sheet, and a booklet (always landscape sheets), are both turned. let page = layout(&[(300.0, 200.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); assert_eq!(cups_orientation(&page), ask); - let booklet = - layout(&[(200.0, 300.0); 4], &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left })) - .unwrap(); + let booklet = layout( + &[(200.0, 300.0); 4], + &settings(vec![0, 1, 2, 3], Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }), + ) + .unwrap(); assert_eq!(cups_orientation(&booklet), ask); // Portrait sheets, mixed jobs and nothing at all say nothing. let portrait = layout(&[(200.0, 300.0)], &settings(vec![0], Layout::Size(SizeMode::Fit))).unwrap(); @@ -759,7 +804,9 @@ mod cups_options { /// The child process stands in for lpoptions, recording its real arguments. #[test] fn stand_in_for_lpoptions() { - let Some(record) = std::env::var_os(RECORD) else { return }; + let Some(record) = std::env::var_os(RECORD) else { + return; + }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); let answer = std::env::var(ANSWER).unwrap(); @@ -877,6 +924,13 @@ mod cups_options { long_queue.as_str(), long_queue_instance.as_str(), long_instance.as_str(), + // A destination cannot be an argument: `lpoptions` would take these as options. + "-Office", + "-E", + "-", + "--", + "Office/-l", + "Office/--", ] { assert!( spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT, "en").is_empty(), @@ -886,6 +940,120 @@ mod cups_options { } std::fs::remove_dir_all(dir).unwrap(); } + + /// #4699: when the queue's PPD is not readable, build the option list from `lpoptions -l` + /// instead of returning an empty dialog. + #[test] + fn unreadable_ppd_falls_back_to_lpoptions_list() { + let dir = folder(); + // No PPD file: the PPD path is unreadable, just as on Arch where cupsd keeps it at + // 0640 root:cups. + let lpoptions_out = "MediaType/Media Type: *Stationery PhotographicGlossy StationeryInkjet Com.brotherBp71\ncupsPrintQuality/cupsPrintQuality: Draft *Normal High\nPageSize/Page Size: A4 Letter\nDuplex/Duplex: *None DuplexNoTumble\nno colon on this line\nEmpty/Empty:\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["MediaType", "cupsPrintQuality"], "PageSize is handled by the dialog and must be skipped"); + + let media_type = options.iter().find(|o| o.key == "MediaType").unwrap(); + assert_eq!(media_type.label, "Media Type"); + assert_eq!(media_type.default, "Stationery"); + assert_eq!( + media_type.choices, + [ + ("Stationery".into(), "Stationery".into()), + ("PhotographicGlossy".into(), "PhotographicGlossy".into()), + ("StationeryInkjet".into(), "StationeryInkjet".into()), + ("Com.brotherBp71".into(), "Com.brotherBp71".into()), + ] + ); + + let quality = options.iter().find(|o| o.key == "cupsPrintQuality").unwrap(); + assert_eq!(quality.label, "cupsPrintQuality"); + assert_eq!(quality.default, "Normal"); + assert_eq!(quality.choices, [("Draft".into(), "Draft".into()), ("Normal".into(), "Normal".into()), ("High".into(), "High".into())]); + + // The starring decides the fallback default, wherever the starred choice sits. + let lpoptions_out = "MediaType/Media Type: Plain *Glossy Matte\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].default, "Glossy", "the starred choice is the fallback default"); + assert_eq!(options[0].choices, [("Plain".into(), "Plain".into()), ("Glossy".into(), "Glossy".into()), ("Matte".into(), "Matte".into())]); + + // A PPD path that exists but cannot be opened at all, for a reason other than NotFound: + // ENOTDIR fails for root as well, where a 0o000 file would still be readable. + let blocker = dir.join("not-a-directory"); + std::fs::write(&blocker, b"x").unwrap(); + let options = spool::printer_options_via_within("Office", &blocker, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1, "an unopenable PPD falls back as an unreadable one does"); + + // A readable PPD whose *OpenUI blocks yield nothing (driverless queues): the fallback still + // fills the list, and the PPD's translations apply to it. + std::fs::write(dir.join("Empty.ppd"), b"*PPD-Adobe: \"4.3\"\n*cupsLanguages: \"it\"\n*it.Translation MediaType/Tipo di supporto: \"\"\n") + .unwrap(); + let options = spool::printer_options_via_within("Empty", &dir, command(&dir, lpoptions_out), LIMIT, "it"); + assert_eq!(options.len(), 1, "a readable PPD without options falls back too"); + assert_eq!(options[0].label, "Tipo di supporto", "the readable PPD still translates the fallback list"); + + // A queue instance reaches lpoptions as the destination, and falls back the same way. + let options = spool::printer_options_via_within("Office/Instance", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + let recorded = std::fs::read_to_string(dir.join("args")).unwrap(); + assert!(recorded.contains("Office/Instance"), "the instance is the destination: {recorded}"); + + std::fs::remove_dir_all(dir).unwrap(); + } + + /// CUPS prints a custom option's template instead of its choices — `Custom`, `Custom.PASSCODE`, + /// `Custom.STRING`, `Custom.WIDTHxHEIGHT`: the dialog has no field to collect the value, so + /// offering the template would send that literal text to the driver. + #[test] + fn custom_value_templates_are_not_offered() { + let dir = folder(); + let lpoptions_out = "Pin/Pin: *Custom.PASSCODE\nJobPassword/Job Password: 1234 *5678 Custom.PASSWORD\nScale/Scale: *Custom.REAL\nSolo/Solo: *Custom\nMediaType/Media Type: *Plain Custom.Glossy Matte\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + assert_eq!(keys, ["JobPassword", "MediaType"], "an option whose choices are all templates is dropped"); + + let password = options.iter().find(|o| o.key == "JobPassword").unwrap(); + assert_eq!(password.choices, [("1234".into(), "1234".into()), ("5678".into(), "5678".into())]); + assert_eq!(password.default, "5678", "a template is never a choice"); + + let media_type = options.iter().find(|o| o.key == "MediaType").unwrap(); + assert_eq!(media_type.choices, [("Plain".into(), "Plain".into()), ("Matte".into(), "Matte".into())]); + assert_eq!(media_type.default, "Plain"); + + // A starred template is never offered as a choice, and never replaced by an invented + // default: the printer keeps the template, so standing the first real choice in its + // place would show a value the job does not use. + let options = spool::printer_options_via_within("Office", &dir, command(&dir, "Pin/Pin: *Custom.PASSCODE 1234\n"), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].choices, [("1234".into(), "1234".into())]); + assert_eq!(options[0].default, "", "an uncollectable default is not a choice"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// `list_group()` prints `Keyword/Text:` and then bare choice keywords, so the text may carry a + /// colon while the choices cannot: the split belongs at the last one. + #[test] + fn an_option_text_may_contain_a_colon() { + let dir = folder(); + let lpoptions_out = "Media/Supporto: Tipo: *A B\n"; + let options = spool::printer_options_via_within("Office", &dir, command(&dir, lpoptions_out), LIMIT, "en"); + assert_eq!(options.len(), 1); + assert_eq!(options[0].key, "Media"); + assert_eq!(options[0].label, "Supporto: Tipo", "the text keeps its colon"); + assert_eq!(options[0].choices, [("A".into(), "A".into()), ("B".into(), "B".into())], "no choice is invented from the text"); + assert_eq!(options[0].default, "A"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn the_listing_is_capped_the_way_the_ppd_is() { + let dir = folder(); + let listing: String = (0..500).map(|i| format!("Option{i}/Option {i}: *A B\n")).collect(); + let options = spool::printer_options_via_within("Office", &dir, command(&dir, &listing), LIMIT, "en"); + assert_eq!(options.len(), 400, "the fallback honours the same cap as parse_ppd"); + std::fs::remove_dir_all(dir).unwrap(); + } } #[test] @@ -985,10 +1153,26 @@ fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { ("A0", "iso_a0_841x1189mm"), ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ("Folio (8.5 x 13 in)", "na_foolscap_8.5x13in"), + ("F4 (210 x 330 mm)", "om_folio_210x330mm"), ] ); } +/// #4777: Folio (8.5 × 13 in) and F4 (210 × 330 mm) presets are offered, fed by their +/// PWG 5101.1 media names (`na_foolscap_8.5x13in` and `om_folio_210x330mm`) and laid out at their +/// proper dimensions. +#[test] +fn folio_and_f4_paper_sizes_are_available() { + let folio = PAPERS.iter().position(|(name, _)| *name == "Folio (8.5 x 13 in)").expect("Folio present"); + assert_eq!(cups_media(folio), Some("na_foolscap_8.5x13in")); + assert_eq!(PAPERS[folio].1, (612.0, 936.0)); + + let f4 = PAPERS.iter().position(|(name, _)| *name == "F4 (210 x 330 mm)").expect("F4 present"); + assert_eq!(cups_media(f4), Some("om_folio_210x330mm")); + assert_eq!(PAPERS[f4].1, (595.28, 935.43)); +} + /// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at /// its own size. #[test] @@ -1193,6 +1377,7 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() { } mod appearances; +mod font_names; #[test] fn repeated_content_references_share_one_decode_budget() { @@ -1407,7 +1592,7 @@ fn missing_print_dlls_are_named() { #[cfg(windows)] #[test] fn printing_without_the_print_dlls_is_refused_before_the_spooler() { - use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printers, submit, submit_with_progress}; + use crate::spool::{PRINT_DLLS, PRINTER_BACKEND_CALLS, SYSTEM32_FOR_TEST, printer_queues, printers, submit, submit_with_progress, two_sided}; let dir = std::env::temp_dir().join(format!("pdfcraft-print-no-dlls-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -1424,6 +1609,8 @@ fn printing_without_the_print_dlls_is_refused_before_the_spooler() { // Not only an empty list, which a machine without printers gives anyway: the backend wasn't asked. PRINTER_BACKEND_CALLS.with(|c| c.set(0)); assert!(printers().is_empty(), "no printers are listed"); + assert!(printer_queues().is_empty(), "no queues are listed"); + assert_eq!(two_sided("PdfCraft test: no such printer"), None, "two-sided support is unknown"); assert_eq!(PRINTER_BACKEND_CALLS.with(|c| c.get()), 0, "the printer backend wasn't asked"); SYSTEM32_FOR_TEST.with(|d| *d.borrow_mut() = None); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index 0d3107d1d7..f63ed14733 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -85,6 +85,44 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() } +/// Existing Link artwork prints as document content only when its flags allow printing. +#[test] +fn printable_link_appearance_survives_vector_print() { + for flags in [0, 2, 4, 6, 5] { + for content in [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"F".to_vec(), Object::Int(flags)); + }) + .unwrap(); + let before = saved(&doc); + let expected = flags & 4 != 0 && flags & 2 == 0 && content != Content::FormFieldsOnly; + let printed = render(impose(&doc, &print_settings(content)).unwrap()); + let color = if expected { [0, 255, 0, 255] } else { [255, 255, 255, 255] }; + assert_eq!(pixel(&printed, 30, 70), color, "flags={flags}, content={content:?}"); + let mut raster_source = doc.clone(); + keep_printed(&mut raster_source, content); + let page = &pdfcraft_model::pages(&raster_source)[0]; + let annots = raster_source.resolve(page.dict.get(b"Annots").unwrap()); + assert_eq!(annots.as_array().unwrap().len(), usize::from(expected), "raster flags={flags}, content={content:?}"); + assert_eq!(saved(&doc), before, "source remains unchanged"); + } + } + for subtype in ["Link", "Popup"] { + let (mut doc, _, annot) = widget_fixture(Some(Object::name("Form")), None); + doc.update_dict(annot, |d| { + d.set(b"Subtype".to_vec(), Object::name(subtype)); + if subtype == "Link" { + d.remove(b"AP"); + } + }) + .unwrap(); + let printed = render(impose(&doc, &print_settings(Content::DocumentAndMarkups)).unwrap()); + assert_eq!(pixel(&printed, 30, 70), [255, 255, 255, 255], "{subtype} must not acquire an appearance"); + } +} + /// Printing keeps occupied page resource names and distinct annotation appearances intact, /// whether the page resources are direct or inherited from the page tree. #[test] diff --git a/crates/print/src/tests/font_names.rs b/crates/print/src/tests/font_names.rs new file mode 100644 index 0000000000..200d35ebee --- /dev/null +++ b/crates/print/src/tests/font_names.rs @@ -0,0 +1,507 @@ +//! #3132: embedded TrueType programs go to the Windows spooler under names no installed font has. + +use super::*; +use crate::font_names::{distinct, distinct_within, renamed_sfnt}; +use crate::spool::spooled_pdf; +use pdfcraft_cos::DecodeBudget; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use skrifa::raw::TableProvider as _; +use skrifa::string::StringId; +use skrifa::{FontRef, MetadataProvider as _}; + +/// Inter (OFL, `ATTRIBUTION.toml`) stands in for the subset ReportLab embeds as `AAAAAA+ArialMT`. +const INTER: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + +fn name_of(program: &[u8], id: StringId) -> Option { + FontRef::new(program).unwrap().localized_strings(id).english_or_first().map(|s| s.to_string()) +} + +/// A TrueType font over `program`, named `base_font`, as ReportLab writes one; its descriptor is +/// indirect. +fn true_type_font(doc: &mut Document, base_font: &str, program: ObjRef) -> ObjRef { + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"Flags".to_vec(), Object::Int(32)); + descriptor.set(b"FontBBox".to_vec(), Object::Array([-500, -300, 1500, 1000].into_iter().map(Object::Int).collect())); + for (key, value) in [(b"ItalicAngle".as_slice(), 0), (b"Ascent", 900), (b"Descent", -200), (b"CapHeight", 700), (b"StemV", 80)] { + descriptor.set(key.to_vec(), Object::Int(value)); + } + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let descriptor = doc.add(Object::Dict(descriptor)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"BaseFont".to_vec(), Object::name(base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + font.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor)); + doc.add(Object::Dict(font)) +} + +fn program_stream(doc: &mut Document) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(INTER.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, INTER))) +} + +/// One 200×300 page writing "Hola" in a regular and a bold Arial subset, the way the report's +/// proposal is set, and its print-ready PDF. +fn print_ready() -> Vec { + let mut doc = fixture(1); + let (regular, bold) = (program_stream(&mut doc), program_stream(&mut doc)); + let regular = true_type_font(&mut doc, "AAAAAA+ArialMT", regular); + let bold = true_type_font(&mut doc, "AAAAAA+Arial-BoldMT", bold); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(regular)); + fonts.set(b"F2".to_vec(), Object::Ref(bold)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 40 Tf 10 200 Td (Hola) Tj ET BT /F2 40 Tf 10 100 Td (Hola) Tj ET"))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + d.remove(b"Annots"); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + }) + .unwrap(); + let settings = Settings { pages: vec![0], paper: (200.0, 300.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() }; + impose(&doc, &settings).unwrap() +} + +fn pixels(pdf: &[u8]) -> Vec { + let page = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + page.rgba[..].to_vec() +} + +/// Each TrueType font in `pdf`: its `/BaseFont`, its descriptor's `/FontName`, its decoded +/// program and that program's `/Length1`. +fn true_type_fonts(pdf: &[u8]) -> Vec<(String, String, Vec, i64)> { + let doc = Document::open(Arc::new(pdf.to_vec())).unwrap(); + let mut fonts = Vec::new(); + for (_, object) in doc.scan_objects() { + let Object::Dict(font) = object.as_ref() else { continue }; + if font.name(b"Subtype") != Some(b"TrueType") { + continue; + } + let descriptor = doc.dict(font.get(b"FontDescriptor").unwrap()).unwrap(); + let program = doc.resolve(descriptor.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("no program") }; + let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap(); + fonts.push((name(font, b"BaseFont"), name(&descriptor, b"FontName"), program.decoded().unwrap(), program.dict.int(b"Length1").unwrap())); + } + fonts.sort(); + fonts +} + +#[test] +fn windows_gets_embedded_subsets_under_names_no_installed_font_has() { + let pdf = print_ready(); + let before = true_type_fonts(&pdf); + assert_eq!(before.iter().map(|f| f.0.as_str()).collect::>(), ["AAAAAA+Arial-BoldMT", "AAAAAA+ArialMT"]); + + let spooled = spooled_pdf(&pdf, true); + let after = true_type_fonts(&spooled); + + // Each program has a name of its own, in the PDF and in its `name` table; the subset tag stays. + let names: Vec<_> = after.iter().map(|f| (f.0.as_str(), f.1.as_str())).collect(); + assert_eq!(names, [("AAAAAA+PdfCraftPrint1", "AAAAAA+PdfCraftPrint1"), ("AAAAAA+PdfCraftPrint2", "AAAAAA+PdfCraftPrint2")]); + for (n, (_, _, program, length1)) in after.iter().enumerate() { + let name = format!("PdfCraftPrint{}", n + 1); + for id in [StringId::FAMILY_NAME, StringId::FULL_NAME, StringId::POSTSCRIPT_NAME, StringId::UNIQUE_ID] { + assert_eq!(name_of(program, id).as_deref(), Some(name.as_str()), "{id:?}"); + } + assert_eq!(name_of(program, StringId::SUBFAMILY_NAME).as_deref(), Some("Regular")); + assert_eq!(*length1, program.len() as i64); + // Nothing that draws a glyph changed: the same glyph for every character, and the same + // tables but `name` and `head`'s checksum adjustment. + let (old, new) = (FontRef::new(INTER).unwrap(), FontRef::new(program).unwrap()); + assert_eq!(old.charmap().mappings().collect::>(), new.charmap().mappings().collect::>()); + let table = |font: &FontRef, tag| { + let mut data = font.table_data(tag).unwrap().as_bytes().to_vec(); + if &tag == b"head" { + data[8..12].fill(0); + } + data + }; + for tag in old.table_directory.table_records().iter().map(|r| r.tag()).filter(|t| t != b"name") { + assert_eq!(table(&old, tag), table(&new, tag), "{tag}"); + } + // `head`'s checksum adjustment holds for the new file. + let sum = program.chunks(4).fold(0_u32, |s, c| s.wrapping_add(u32::from_be_bytes([0, 1, 2, 3].map(|i| c.get(i).copied().unwrap_or(0))))); + assert_eq!(sum, 0xb1b0afba); + } + assert!(before.iter().all(|f| name_of(&f.2, StringId::FAMILY_NAME).as_deref() == Some("Inter"))); + + // The page draws exactly as before, and draws something. + let (old, new) = (pixels(&pdf), pixels(&spooled)); + assert!(old.chunks(4).any(|p| p[0] < 128), "the text is drawn"); + assert!(old == new, "renaming changed what prints"); +} + +#[test] +fn other_platforms_and_pdfs_without_embedded_programs_spool_as_they_are() { + let pdf = print_ready(); + assert!(matches!(spooled_pdf(&pdf, false), std::borrow::Cow::Borrowed(p) if p == pdf.as_slice())); + let helvetica = impose(&fixture(1), &Settings { pages: vec![0], paper: (200.0, 300.0), ..Settings::default() }).unwrap(); + assert_eq!(distinct(&helvetica).unwrap(), None); + assert!(matches!(spooled_pdf(&helvetica, true), std::borrow::Cow::Borrowed(_))); + // Not a PDF: it goes as it is, and the spooler reports what it makes of it. + assert!(matches!(spooled_pdf(b"not a pdf", true), std::borrow::Cow::Borrowed(b"not a pdf"))); +} + +#[test] +fn a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too() { + // A viewer that can't load the program either picks a substitute by the PDF's name, so that + // name must still say Arial. + let mut doc = fixture(1); + let good = program_stream(&mut doc); + let mut d = Dict::new(); + d.set(b"Length1".to_vec(), Object::Int(10)); + let broken = doc.add(Object::Stream(Stream::flate(d, b"not a font"))); + let good = true_type_font(&mut doc, "AAAAAA+ArialMT", good); + let broken = true_type_font(&mut doc, "BAAAAA+Arial-BoldMT", broken); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(good)); + fonts.set(b"F2".to_vec(), Object::Ref(broken)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + let names: Vec<_> = true_type_fonts(&spooled_pdf(&pdf, true)).into_iter().map(|f| (f.0, f.1, f.2 == b"not a font")).collect(); + assert_eq!( + names, + [ + ("AAAAAA+PdfCraftPrint1".to_owned(), "AAAAAA+PdfCraftPrint1".to_owned(), false), + ("BAAAAA+Arial-BoldMT".to_owned(), "BAAAAA+Arial-BoldMT".to_owned(), true), + ] + ); + // With nothing it can rewrite, the PDF goes as it is. + let mut fonts = Dict::new(); + fonts.set(b"F2".to_vec(), Object::Ref(broken)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + assert_eq!(distinct(&write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(), None); +} + +#[test] +fn programs_past_the_shared_decode_budget_keep_their_names() { + // Each program decodes to Inter's whole length; the budget covers one and a half. + let pdf = print_ready(); + let mut budget = DecodeBudget::new(INTER.len() * 3 / 2, 0); + let renamed = distinct_within(&pdf, &mut budget).unwrap().unwrap(); + let names: Vec<_> = true_type_fonts(&renamed).into_iter().map(|f| (f.0, name_of(&f.2, StringId::FAMILY_NAME).unwrap())).collect(); + let (new, old): (Vec<_>, Vec<_>) = names.iter().partition(|(base_font, _)| base_font.contains("PdfCraftPrint")); + assert_eq!((new.len(), old.len()), (1, 1), "{names:?}"); + assert!(new[0].0.starts_with("AAAAAA+PdfCraftPrint") && new[0].1.starts_with("PdfCraftPrint"), "{names:?}"); + assert!(old[0].0.starts_with("AAAAAA+Arial") && old[0].1 == "Inter", "{names:?}"); + // With the default allowance both are renamed. + assert_eq!(true_type_fonts(&distinct(&pdf).unwrap().unwrap()).iter().filter(|f| f.0.contains("PdfCraftPrint")).count(), 2); +} + +/// A CIDFontType2 named `base_font` over `program`, written as a dictionary with its descriptor +/// inline, as some producers put it straight into `/DescendantFonts`. +fn inline_cid_font(base_font: &str, program: ObjRef) -> Dict { + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(base_font)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name(base_font)); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + cid +} + +fn type0_font(doc: &mut Document, base_font: &str, descendants: Object) -> ObjRef { + let mut type0 = Dict::new(); + type0.set(b"Type".to_vec(), Object::name("Font")); + type0.set(b"Subtype".to_vec(), Object::name("Type0")); + type0.set(b"BaseFont".to_vec(), Object::name(base_font)); + type0.set(b"Encoding".to_vec(), Object::name("Identity-H")); + type0.set(b"DescendantFonts".to_vec(), descendants); + doc.add(Object::Dict(type0)) +} + +#[test] +fn a_cidfont_written_inside_its_type0_font_is_renamed_with_it() { + let mut doc = fixture(1); + let (shared, own) = (program_stream(&mut doc), program_stream(&mut doc)); + let simple = true_type_font(&mut doc, "AAAAAA+ArialMT", shared); + // In `/DescendantFonts` itself, sharing the simple font's program; and in an array object of + // its own, over a program nothing else uses. + let direct = type0_font(&mut doc, "BAAAAA+ArialMT-Identity-H", Object::Array(vec![Object::Dict(inline_cid_font("BAAAAA+ArialMT", shared))])); + let array = doc.add(Object::Array(vec![Object::Dict(inline_cid_font("CAAAAA+ArialMT", own))])); + let in_array = type0_font(&mut doc, "CAAAAA+ArialMT-Identity-H", Object::Ref(array)); + let mut fonts = Dict::new(); + for (key, font) in [(b"F1", simple), (b"F2", direct), (b"F3", in_array)] { + fonts.set(key.to_vec(), Object::Ref(font)); + } + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap(); + let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap(); + let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap(); + let simple = renamed.dict(fonts.get(b"F1").unwrap()).unwrap(); + for (key, tag) in [(b"F2", "BAAAAA+"), (b"F3", "CAAAAA+")] { + let type0 = renamed.dict(fonts.get(key).unwrap()).unwrap(); + let descendants = renamed.resolve(type0.get(b"DescendantFonts").unwrap()); + let Some(Object::Dict(cid)) = descendants.as_array().and_then(|a| a.first()) else { panic!("inline CIDFont") }; + let Some(Object::Dict(descriptor)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") }; + let program = renamed.resolve(descriptor.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("program") }; + let family = name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).unwrap(); + // The Type 0 font, its CIDFont and the descriptor all say the program's new name. + let expected = format!("{tag}{family}"); + assert!(family.starts_with("PdfCraftPrint"), "{family}"); + assert_eq!( + (name(&type0, b"BaseFont"), name(cid, b"BaseFont"), name(descriptor, b"FontName")), + (expected.clone(), expected.clone(), expected) + ); + } + // The shared program has one name, whichever font led to it. + let shared_name = name(&simple, b"BaseFont").replace("AAAAAA+", ""); + assert_eq!(name(&renamed.dict(fonts.get(b"F2").unwrap()).unwrap(), b"BaseFont"), format!("BAAAAA+{shared_name}")); +} + +#[test] +fn fonts_sharing_a_program_share_its_name_and_type0_fonts_follow_their_cidfont() { + let mut doc = fixture(1); + let program = program_stream(&mut doc); + let simple = true_type_font(&mut doc, "BCDEFG+Calibri", program); + let again = true_type_font(&mut doc, "Calibri", program); + // A Type 0 font over a CIDFontType2 whose descriptor is written inline. + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("HIJKLM+Calibri")); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::name("Font")); + cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + cid.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri")); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let cid = doc.add(Object::Dict(cid)); + let mut type0 = Dict::new(); + type0.set(b"Type".to_vec(), Object::name("Font")); + type0.set(b"Subtype".to_vec(), Object::name("Type0")); + type0.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri-Identity-H")); + type0.set(b"Encoding".to_vec(), Object::name("Identity-H")); + type0.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)])); + let type0 = doc.add(Object::Dict(type0)); + let mut fonts = Dict::new(); + for (key, font) in [(b"F1", simple), (b"F2", again), (b"F3", type0)] { + fonts.set(key.to_vec(), Object::Ref(font)); + } + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let pdf = write_full(&doc, &SaveOptions::default()).unwrap(); + + // The file is written anew, so the fonts are found again through the page. + let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap(); + let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap(); + let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap(); + let font = |key: &[u8]| renamed.dict(fonts.get(key).unwrap()).unwrap(); + let base_font = |d: &Dict| String::from_utf8(d.name(b"BaseFont").unwrap().to_vec()).unwrap(); + assert_eq!(base_font(&font(b"F1")), "BCDEFG+PdfCraftPrint1"); + assert_eq!(base_font(&font(b"F2")), "PdfCraftPrint1"); + assert_eq!(base_font(&font(b"F3")), "HIJKLM+PdfCraftPrint1"); + let Some(Object::Array(descendants)) = font(b"F3").get(b"DescendantFonts").cloned() else { panic!("descendants") }; + let cid = renamed.dict(&descendants[0]).unwrap(); + assert_eq!(base_font(&cid), "HIJKLM+PdfCraftPrint1"); + let Some(Object::Dict(inline)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") }; + assert_eq!(inline.name(b"FontName"), Some(b"HIJKLM+PdfCraftPrint1".as_slice())); + // Renamed once, not once per font. + let program = renamed.resolve(inline.get(b"FontFile2").unwrap()); + let Object::Stream(program) = program.as_ref() else { panic!("program") }; + assert_eq!(name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1")); +} + +/// A `name` table of `format` with the given records `(platform, encoding, language, id, text)` +/// and language tags, inside a minimal sfnt with only that table. +fn sfnt_with_name(format: u16, records: &[(u16, u16, u16, u16, &[u8])], tags: &[&[u8]]) -> Vec { + let mut table = Vec::new(); + let mut strings = Vec::new(); + table.extend_from_slice(&format.to_be_bytes()); + table.extend_from_slice(&(records.len() as u16).to_be_bytes()); + let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 }; + table.extend_from_slice(&(header as u16).to_be_bytes()); + for (platform, encoding, language, id, text) in records { + for v in [*platform, *encoding, *language, *id, text.len() as u16, strings.len() as u16] { + table.extend_from_slice(&v.to_be_bytes()); + } + strings.extend_from_slice(text); + } + if format == 1 { + table.extend_from_slice(&(tags.len() as u16).to_be_bytes()); + for tag in tags { + table.extend_from_slice(&(tag.len() as u16).to_be_bytes()); + table.extend_from_slice(&(strings.len() as u16).to_be_bytes()); + strings.extend_from_slice(tag); + } + } + table.extend_from_slice(&strings); + sfnt_with_table(&table) +} + +/// A minimal sfnt with `table` as its only table, `name`. +fn sfnt_with_table(table: &[u8]) -> Vec { + let mut sfnt = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + sfnt.extend_from_slice(b"name"); + sfnt.extend_from_slice(&0_u32.to_be_bytes()); + sfnt.extend_from_slice(&28_u32.to_be_bytes()); + sfnt.extend_from_slice(&(table.len() as u32).to_be_bytes()); + sfnt.extend_from_slice(table); + sfnt +} + +/// A format-0 `name` table whose `copies` copyright records all point at one `length`-byte +/// string, then a family record after it; `storage` overrides where the table says its strings +/// start. +fn shared_string_table(copies: usize, length: usize, storage: Option) -> Vec { + let count = copies + 1; + let mut table = Vec::new(); + for v in [0, count as u16, storage.unwrap_or((6 + count * 12) as u16)] { + table.extend_from_slice(&v.to_be_bytes()); + } + let copyright = [3, 1, 0x409, 0, length as u16, 0]; + for v in (0..copies).flat_map(|_| copyright).chain([3, 1, 0x409, 1, 2, length as u16]) { + table.extend_from_slice(&v.to_be_bytes()); + } + table.extend(std::iter::repeat_n(b'c', length)); + table.extend_from_slice(&[0, b'A']); + table +} + +#[test] +fn records_that_share_one_string_keep_sharing_it() { + // A thousand records over one 60,000-byte string. Each given its own copy they would take + // 60 MB and lie past what 16-bit offsets reach; stored once, the table is as small as before. + let table = shared_string_table(1024, 60_000, None); + let renamed = renamed_sfnt(&sfnt_with_table(&table), "PdfCraftPrint1").unwrap(); + let font = FontRef::new(&renamed).unwrap(); + let name = font.name().unwrap(); + let copyright: std::collections::BTreeSet<_> = + name.name_record().iter().filter(|r| r.name_id().to_u16() == 0).map(|r| (r.string_offset().to_u32(), r.length())).collect(); + assert_eq!(copyright.len(), 1, "every copyright record points at the one string"); + assert!(font.table_data(skrifa::raw::types::Tag::new(b"name")).unwrap().len() <= table.len() + 64); + assert_eq!(name_of(&renamed, StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1")); + // 60,000 bytes of UTF-16BE: 30,000 characters. + assert_eq!(name_of(&renamed, StringId::COPYRIGHT_NOTICE).map(|c| c.chars().count()), Some(60_000 / 2)); +} + +#[test] +fn a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused() { + // The case reported on #4098: 65,535 records over one 65,535-byte string, the strings said to + // start inside the records. Copying each record's string before checking took about 4 GiB. + let table = shared_string_table(65_534, 65_535, Some(6)); + assert_eq!(renamed_sfnt(&sfnt_with_table(&table), "X"), None); +} + +/// A `name` record: platform, encoding, language, name ID and the string's bytes. +type Record = (u16, u16, u16, u16, Vec); + +/// The records and language tags of the renamed font's `name` table. +fn records_of(sfnt: &[u8]) -> (Vec, Vec>) { + let font = FontRef::new(sfnt).unwrap(); + let name = font.name().unwrap(); + let data = name.string_data(); + let text = |length: u16, offset: u16| data.as_bytes()[offset as usize..][..length as usize].to_vec(); + let records = name + .name_record() + .iter() + .map(|r| (r.platform_id(), r.encoding_id(), r.language_id(), r.name_id().to_u16(), text(r.length(), r.string_offset().to_u32() as u16))) + .collect(); + let tags = name.lang_tag_record().unwrap_or_default().iter().map(|t| text(t.length(), t.lang_tag_offset().to_u32() as u16)).collect(); + (records, tags) +} + +#[test] +fn identity_records_are_respelled_per_platform_and_the_rest_are_kept() { + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + let arial = utf16("Arial"); + let copyright = utf16("© The Monotype Corporation"); + let sjis_family: &[u8] = &[0x82, 0xa0]; + let sfnt = sfnt_with_name( + 1, + &[ + (1, 0, 0, 1, b"Arial"), + (1, 0, 0, 2, b"Bold"), + (3, 1, 0x409, 0, ©right), + (3, 1, 0x409, 1, &arial), + (3, 1, 0x409, 6, &utf16("Arial-BoldMT")), + (3, 2, 0x411, 1, sjis_family), + (3, 1, 0x8000, 4, &utf16("Arial Bold")), + ], + &[b"\0e\0n"], + ); + let renamed = renamed_sfnt(&sfnt, "PdfCraftPrint7").unwrap(); + let (records, tags) = records_of(&renamed); + assert_eq!( + records, + [ + (1, 0, 0, 1, b"PdfCraftPrint7".to_vec()), + (1, 0, 0, 2, b"Bold".to_vec()), + (3, 1, 0x409, 0, copyright), + (3, 1, 0x409, 1, utf16("PdfCraftPrint7")), + (3, 1, 0x409, 6, utf16("PdfCraftPrint7")), + // The Shift-JIS family can't be respelled in ASCII here, so it goes. + (3, 1, 0x8000, 4, utf16("PdfCraftPrint7")), + ] + ); + assert_eq!(tags, [b"\0e\0n".to_vec()]); +} + +#[test] +fn damaged_programs_are_left_alone_and_never_panic() { + let good = sfnt_with_name(0, &[(3, 1, 0x409, 1, &[0, b'A'])], &[]); + assert!(renamed_sfnt(&good, "X").is_some()); + // Every truncation, of the small font and of the start of a real one. + for n in 0..good.len() { + let _ = renamed_sfnt(&good[..n], "X"); + } + for n in (0..2048).chain([INTER.len() - 1]) { + let _ = renamed_sfnt(&INTER[..n], "X"); + } + // A collection, a CFF-in-name-only header, an absurd table count, a `name` entry past the + // end, a format-2 `name` table and a string past the table's end. + let mut collection = good.clone(); + collection[..4].copy_from_slice(b"ttcf"); + assert_eq!(renamed_sfnt(&collection, "X"), None); + let mut many = good.clone(); + many[4..6].copy_from_slice(&u16::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&many, "X"), None); + let mut past = good.clone(); + past[20..24].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&past, "X"), None); + let mut format2 = good.clone(); + format2[28..30].copy_from_slice(&2_u16.to_be_bytes()); + assert_eq!(renamed_sfnt(&format2, "X"), None); + let mut long = sfnt_with_name(0, &[(3, 1, 0x409, 0, &[0, b'C']), (3, 1, 0x409, 1, &[0, b'A'])], &[]); + let first_length = 28 + 6 + 8; + long[first_length..first_length + 2].copy_from_slice(&u16::MAX.to_be_bytes()); + let (records, _) = records_of(&renamed_sfnt(&long, "X").unwrap()); + assert_eq!(records, [(3, 1, 0x409, 1, vec![0, b'X'])], "the broken copyright record is left out"); + // A broken language tag fails the table, since records point at tags by position. + let mut tagged = sfnt_with_name(1, &[(3, 1, 0x409, 1, &[0, b'A'])], &[b"\0e"]); + let tag_length = 28 + 6 + 12 + 2; + tagged[tag_length..tag_length + 2].copy_from_slice(&u16::MAX.to_be_bytes()); + assert_eq!(renamed_sfnt(&tagged, "X"), None); +} diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index c3bc3bd542..0cc457c492 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -932,11 +932,7 @@ pub(crate) fn remove_counted( let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() }; if fields { // Fields without appearances get one first, so their values stay visible. - for f in pdfcraft_forms::fields(doc) { - if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { - pdfcraft_forms::redraw_field(doc, &f.name)?; - } - } + pdfcraft_forms::redraw_fields(doc, |doc, f| f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))))?; let n = pdfcraft_model::pages(doc).len(); pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; doc.update_dict(root, |d| { diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 1097da1997..cd88021951 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -269,7 +269,9 @@ pub struct RenderedPage { /// A non-fatal condition reported while interpreting a page. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RenderWarning { - /// Content was skipped after the per-page decoded-content budget was exhausted. + /// Content was left out at one of the renderer's limits: content past the page's + /// decoded-content budget or its JBIG2 allowance, or a picture whose JBIG2 mask is past a + /// decoder limit. ContentTruncated, /// 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. diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 32a428c1e5..86d655677b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -4234,7 +4234,7 @@ fn inline_images_with_ei_in_their_data_parse_in_linear_time() { /// From the nightly `cargo xtask fuzz`: a JBIG2 image whose stream declares a page and a /// generic region of 65535 × 65535 pixels (each side within JBIG2's limit) behind a small /// /Width and /Height: decoding its 4.3 billion pixels one by one took minutes. Vendored -/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and 2^26 per symbol dictionary. +/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and as many per symbol dictionary. #[test] fn jbig2_regions_of_billions_of_pixels_are_refused() { // Embedded JBIG2 segments (ISO 14492 §7.2): page information, then an immediate generic @@ -4302,9 +4302,14 @@ fn jbig2_segment(out: &mut Vec, number: u32, kind: u8, refs: &[u8], data: &[ /// The page information of a 16 × 2 JBIG2 page whose default pixel is black, so that the image, if /// it decodes, draws black whatever its other segments hold. fn black_jbig2_page() -> Vec { + black_jbig2_page_of(16, 2) +} + +/// [`black_jbig2_page`], `width` × `height`. +fn black_jbig2_page_of(width: u32, height: u32) -> Vec { let mut page = Vec::new(); - page.extend_from_slice(&16u32.to_be_bytes()); - page.extend_from_slice(&2u32.to_be_bytes()); + page.extend_from_slice(&width.to_be_bytes()); + page.extend_from_slice(&height.to_be_bytes()); page.extend_from_slice(&[0; 8]); // resolution page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping let mut stream = Vec::new(); @@ -4495,6 +4500,289 @@ fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { assert_eq!(other, (true, false), "and a page with nothing left says nothing of a mask that isn't JBIG2"); } +/// A symbol dictionary may hold as many pixels as the largest page bitmap. It was held to a +/// quarter of that, and the soft mask of a scanned drawing, one dictionary of 7,559 symbols, passed +/// it: refused, the mask left its picture drawn whole, which covered the page in noise (#6722). +#[test] +fn a_symbol_dictionary_holds_as_many_pixels_as_a_page() { + let stream = |heights: &[u32]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &wide_jbig2_dictionary(65535, heights)); + stream + }; + assert!(jbig2_image_draws(&stream(&[1100])), "72,088,500 pixels, past 2^26"); + assert!(jbig2_image_draws(&stream(&[2047, 2048])), "268,365,825 pixels, within 2^28"); + assert!(!jbig2_image_draws(&stream(&[2048, 2049])), "268,496,895 pixels, past 2^28"); +} + +/// A JBIG2 mask one of the decoder's limits refuses takes its picture with it, as one left out for +/// the page's allowance does: the file isn't to blame, and a scan's colour layer drawn without its +/// mask covers the page in noise (#6722). A mask the file damaged leaves its picture whole, as +/// upstream draws it. A size the file damaged can read as past a limit, and is taken for one. +#[test] +fn a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn() { + let draws = |entry: &str, (width, height): (u32, u32), mask: &[u8]| { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice( + format!("5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray {entry} 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n").as_bytes(), + ); + let kind = if entry == "/Mask" { "/ImageMask true" } else { "/ColorSpace /DeviceGray" }; + pdf.extend_from_slice( + format!( + "6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 {kind} /Filter /JBIG2Decode /Length {} >> stream\n", + mask.len() + ) + .as_bytes(), + ); + pdf.extend_from_slice(mask); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + let shown = match &page.rgba[((25 * 40 + 15) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither the picture nor the page: {other:?}"), + }; + (shown, page.warnings.contains(&RenderWarning::ContentTruncated)) + }; + let out = (false, true); + // A page a column wider than the decoder's bound on a side, as the mask's dictionary says too. + let wide = black_jbig2_page_of(65536, 2); + assert_eq!(draws("/SMask", (65536, 2), &wide), out, "a soft mask past the decoder's bound on a side"); + assert_eq!(draws("/Mask", (65536, 2), &wide), out, "a stencil mask past it"); + // A page of 65,535 rows and columns, past the pixels the renderer decodes, as the dictionary + // says too. + let large = black_jbig2_page_of(65535, 65535); + assert_eq!(draws("/SMask", (65535, 65535), &large), out, "a soft mask past the renderer's bound on pixels"); + assert_eq!(draws("/Mask", (65535, 65535), &large), out, "a stencil mask past it"); + // A dictionary of more symbol pixels than a page holds. + let mut dictionary = black_jbig2_page(); + jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049])); + assert_eq!(draws("/SMask", (16, 2), &dictionary), out, "a soft mask whose dictionary is past its bound"); + // Cut short in its dictionary: the file's damage, not a limit. + let mut damaged = black_jbig2_page(); + jbig2_segment(&mut damaged, 1, 0, &[], &wide_jbig2_dictionary(100, &[100])[..12]); + assert_eq!(draws("/SMask", (16, 2), &damaged), (true, false), "a damaged mask is no mask"); + // Control: the stream's page is far larger than the dictionary says. That is damage, which + // reads as past the decoder's bound on pixels, and is taken for it: it can't be told from a + // hostile size, and leaving the picture out is the safe side. + let disagreeing = black_jbig2_page_of(65535, 65535); + assert_eq!(draws("/SMask", (16, 2), &disagreeing), out, "a size damaged past a limit is taken for one"); + + // A stencil mask a limit refused is drawn on its own: it takes nothing else out, and is a + // failed image, not content left out (`a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`). + // Nor does it leave anything behind for the next picture: one whose mask is never decoded (it + // has no /Width) is drawn whole, as upstream draws it. + let content = b"q 4 0 0 4 30 30 cm /St Do Q q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R /St 7 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n"); + let mask = black_jbig2_page(); + pdf.extend_from_slice(format!("6 0 obj << /Type /XObject /Subtype /Image /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", mask.len()).as_bytes()); + pdf.extend_from_slice(&mask); + pdf.extend_from_slice(format!("\nendstream endobj\n7 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /ImageMask true /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()).as_bytes()); + pdf.extend_from_slice(&wide); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((25 * 40 + 15) * 4)..][..4], [0, 0, 0, 255], "the picture after the stencil is drawn whole"); + assert!(page.warnings.is_empty(), "the stencil left nothing out: {:?}", page.warnings); +} + +/// A JBIG2 image drawn on its own that a limit refuses, a stencil mask included, takes nothing else +/// out, so it is a failed image, as it was before #6722: only a picture's mask refused by a limit +/// is reported as content left out, with the picture it takes along (patch 73). Refused by the +/// decoder's bound on a side or by the renderer's bound on pixels, each image's `/Width` and +/// `/Height` those of its JBIG2 page. +#[test] +fn a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image() { + 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}; + + /// Asks each image for its pixels, which decodes it, and draws nothing. + struct Pixels; + impl<'a> Device<'a> for Pixels { + 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, '_>, _: Affine) { + match image { + Image::Raster(r) => r.with_rgba(|_, _| {}, Some((20, 20))), + Image::Stencil(s) => s.with_stencil(|_, _| {}, Some((20, 20))), + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + // The warnings drawing one image reports. + let warnings = |kind: &str, (width, height): (u32, u32)| { + let stream = black_jbig2_page_of(width, height); + let content = "q 20 0 0 20 5 5 cm /Im Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} {kind} /Filter /JBIG2Decode /Length {} >> stream\n", + content.len(), + stream.len() + ) + .into_bytes(); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let reported = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let reported = reported.clone(); + Arc::new(move |warning| reported.lock().unwrap().push(format!("{warning:?}"))) + }, + ..InterpreterSettings::default() + }; + let mut ctx = Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 40.0), &cache, page.xref(), settings); + interpret_page(page, &mut ctx, &mut Pixels); + reported.lock().unwrap().clone() + }; + let stencil = "/ImageMask true"; + let raster = "/BitsPerComponent 1 /ColorSpace /DeviceGray"; + let failed = ["ImageDecodeFailure"]; + assert_eq!(warnings(stencil, (65536, 2)), failed, "a stencil mask past the decoder's bound on a side"); + assert_eq!(warnings(stencil, (65535, 65535)), failed, "a stencil mask past the renderer's bound on pixels"); + assert_eq!(warnings(raster, (65536, 2)), failed, "an image past the decoder's bound on a side"); + assert_eq!(warnings(raster, (65535, 65535)), failed, "an image past the renderer's bound on pixels"); +} + +/// What refused one image says nothing of the next: after a picture one of the decoder's limits +/// refused, a stencil mask and a soft mask whose filters fail before JBIG2 is reached (LZW data that +/// doesn't decode) are bad, not refused by a limit, on that page and on the next. The decoder's +/// note of the refusal was read before a decode had set it, and outlived its page: the stencil +/// said content was left out, and the picture under the soft mask wasn't drawn. +#[test] +fn a_limit_refusal_says_nothing_of_the_next_image() { + let refused = black_jbig2_page_of(65536, 2); + let bad = [0xff; 64]; + 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 << /Big 7 0 R /St 8 0 R /Im 9 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 object = |number: u8, dict: &str, data: &[u8]| { + let mut object = format!("{number} 0 obj << {dict} /Length {} >> stream\n", data.len()).into_bytes(); + object.extend_from_slice(data); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + 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)); + pdf.extend(content(5, "q 10 0 0 10 30 30 cm /Big Do Q q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q")); + pdf.extend(content(6, "q 10 0 0 10 30 0 cm 0 g /St Do Q q 20 0 0 20 5 5 cm /Im Do Q")); + pdf.extend(object( + 7, + "/Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode", + &refused, + )); + pdf.extend(object(8, "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ImageMask true /Filter [/LZWDecode /JBIG2Decode]", &bad)); + pdf.extend(object(9, "/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 10 0 R", &[0])); + pdf.extend(object( + 10, + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter [/LZWDecode /JBIG2Decode]", + &bad, + )); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + // Both pages from one renderer, on one thread. + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let [first, second] = + [0, 1].map(|page| renderer.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })); + let drawn = |page: &RenderedPage| { + assert!(page.error.is_none(), "{:?}", page.error); + (page.rgba[((25 * 40 + 15) * 4)..][..4] == [0, 0, 0, 255], page.warnings.contains(&RenderWarning::ContentTruncated)) + }; + // The next page first: on the page of the refusal, the stale note shows whether or not it + // outlives the page. + assert_eq!(drawn(&second), (true, false), "on the next page, the picture is drawn and nothing was left out"); + assert_eq!(drawn(&first), (true, false), "on its page, the picture is drawn and nothing was left out"); +} + +/// A JPX picture is decoded again for each size a page draws it at, while a soft mask far larger +/// than where it is drawn is averaged down once and kept for the sizes it serves (patch 82). A +/// JBIG2 mask a decoder limit refused is kept refused, so the picture drawn at another size is left +/// out again, not drawn whole because the mask wasn't decoded that time. The scans of #6722 are +/// JPX under such a mask. +#[test] +fn a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size() { + // A page a column wider than the decoder's bound on a side, as the mask's dictionary says too. + let wide = black_jbig2_page_of(65536, 2); + // A 64 × 64 grey JPX codestream, every sample 128 (as `jpx_midpoint_pdf`'s, larger): larger + // than where it is drawn, it is decoded for each size it is drawn at. + let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ, Rsiz. + for value in [64_u32, 64, 0, 0, 64, 64, 0, 0] { + codestream.extend_from_slice(&value.to_be_bytes()); + } + codestream.extend_from_slice(&[0, 1, 7, 1, 1]); // One component, eight bits. + // COD: one decomposition level, so it decodes at half its size for a small target; QCD: no + // quantization, the four subbands' exponents. + codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 1, 4, 4, 0, 1]); + codestream.extend_from_slice(&[0xff, 0x5c, 0, 7, 0x40, 0x40, 0x48, 0x48, 0x50]); + codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 16, 0, 1, 0xff, 0x93, 0, 0]); // SOT, SOD, two empty packets. + codestream.extend_from_slice(&[0xff, 0xd9]); // EOC. + // At 10, 20 and 10 units square: the second draw is a new size for the picture. + let content = b"q 10 0 0 10 2 2 cm /Im1 Do Q q 20 0 0 20 16 16 cm /Im1 Do Q q 10 0 0 10 2 28 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice( + format!("\nendstream endobj\n5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 6 0 R /Filter /JPXDecode /Length {} >> stream\n", codestream.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&codestream); + pdf.extend_from_slice( + format!("\nendstream endobj\n6 0 obj << /Type /XObject /Subtype /Image /Width 65536 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", wide.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&wide); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + // The middle of each draw, in rows from the top. + let shown = [(33, 7), (14, 26), (7, 7)].map(|(row, column)| page.rgba[(row * 40 + column) * 4..][..4] != [255, 255, 255, 255]); + assert_eq!(shown, [false; 3], "drawn at 10, 20 and 10"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated)); +} + /// A page's allowance ends with it, so what decodes on its thread afterwards has no page to run out /// on, and a `Context` used again starts afresh. #[test] @@ -4747,6 +5035,40 @@ fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { dictionary } +/// A Huffman-coded symbol dictionary of one white symbol of `width` columns in each height class +/// of `heights` (rising), none exported: wide symbols hold many pixels in few retained words. +fn wide_jbig2_dictionary(width: u32, heights: &[u32]) -> Vec { + let mut bits = Bits::default(); + let mut previous = 0; + for &height in heights { + match height - previous { + // Height class delta (table B.4): 1, or the upper line. + 1 => bits.put(0, 1), + delta => { + bits.put(0b11111, 5); + bits.put(delta - 76, 32); + } + } + previous = height; + bits.put(0b111110, 6); // width delta, the upper line of table B.2 + bits.put(width - 75, 32); + bits.put(0b111111, 6); // end of the class (B.2) + let rows = height.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row + bits.table_b1(rows); // BMSIZE (B.1) + bits.align(); + for _ in 0..rows { + bits.put(0xFF, 8); + } + } + let symbols = u32::try_from(heights.len()).unwrap(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + /// An intermediate generic region, white, every row typical: it decodes in a few bytes however /// large it is. fn jbig2_white_region(width: u32, height: u32) -> Vec { diff --git a/crates/render/src/raster/tests/failed_image_decodes.rs b/crates/render/src/raster/tests/failed_image_decodes.rs index bd0329ed31..5e8995f23a 100644 --- a/crates/render/src/raster/tests/failed_image_decodes.rs +++ b/crates/render/src/raster/tests/failed_image_decodes.rs @@ -237,3 +237,114 @@ fn a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page() { 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); } + +/// A JBIG2 mask its decoding work refuses: four typical-row regions of 16,384 × 16,384 take more +/// than a stream may. +fn jbig2_mask_past_its_work() -> Vec { + use super::{black_jbig2_page, jbig2_segment, jbig2_white_region}; + let mut mask = black_jbig2_page(); + for number in 1..=4 { + jbig2_segment(&mut mask, number, 38, &[], &jbig2_white_region(16384, 16384)); // immediate generic region + } + mask +} + +/// What drawing 64 × 64 pictures under JBIG2 soft masks did, with the page's JBIG2 allowance +/// `work`: `/P` under `mask` (`/Width` × `/Height` `size`) and `/W` under +/// [`jbig2_mask_past_its_work`], each of `draws` a name and a size (units square). The decodes the +/// cache ran, how many times content was said to be left out, and what the device was handed. +fn draw_under_jbig2_masks(mask: &[u8], size: (u32, u32), draws: &[(&str, u32)], work: u64) -> (usize, usize, Draws) { + let pixels = vec![0; 64 * 64 * 3]; + let picture = |mask: u8| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask {mask} 0 R /Length {} >>", + pixels.len() + ) + }; + let soft_mask = |(width, height): (u32, u32), data: &[u8]| { + format!( + "<< /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /Length {} >>", + data.len() + ) + }; + let past_its_work = jbig2_mask_past_its_work(); + let content = draws.iter().map(|(name, s)| format!("q {s} 0 0 {s} 10 10 cm /{name} Do Q")).collect::>().join(" "); + let bytes = image_cache_pdf( + &content, + "<< /XObject << /P 5 0 R /W 7 0 R >> >>", + &[(&picture(6), &pixels), (&soft_mask(size, mask), mask), (&picture(8), &pixels), (&soft_mask((16, 2), &past_its_work), &past_its_work)], + ); + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let truncated = Arc::new(AtomicUsize::new(0)); + let others = Arc::new(Mutex::new(Vec::new())); + let settings = InterpreterSettings { + warning_sink: { + let (truncated, others) = (truncated.clone(), others.clone()); + Arc::new(move |warning| match warning { + InterpreterWarning::ContentTruncated => { + truncated.fetch_add(1, Ordering::Relaxed); + } + other => others.lock().unwrap().push(format!("{other:?}")), + }) + }, + jbig2_work: work, + ..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 device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + assert!(others.lock().unwrap().is_empty(), "reported {:?}", others.lock().unwrap()); + (cache.decoded_image_decodes(), truncated.load(Ordering::Relaxed), device) +} + +/// A picture whose JBIG2 soft mask a decoder limit refuses isn't drawn (patch 73), and the limit +/// is the stream's own: decoding it again refuses it again. So the refusal is kept with the picture +/// that went with it (patch 91), and a page drawing it a hundred times decodes them once, as for +/// one draw. Kept as content left out, every draw still says so. The refusal wasn't kept, and the +/// picture, of any size, was decoded again at every draw: 2.45 s for a hundred draws of a 4,096 × +/// 4,096 JPX picture, against 0.23 s under a mask the file damaged. A mask larger than its picture +/// is averaged down for each size it is drawn at, from one kept at an intermediate size (patch 82), +/// so a second size finds the refusal kept there and keeps it for that size too, also after +/// another picture's mask was refused for its work, which isn't kept. +#[test] +fn a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn() { + use super::{black_jbig2_page, black_jbig2_page_of, jbig2_segment, wide_jbig2_dictionary}; + let mut dictionary = black_jbig2_page(); + jbig2_segment(&mut dictionary, 1, 0, &[], &wide_jbig2_dictionary(65535, &[2048, 2049])); + let masks = [ + ("past the decoder's bound on a side", black_jbig2_page_of(65536, 2), (65536, 2)), + ("past the renderer's bound on pixels", black_jbig2_page_of(65535, 65535), (65535, 65535)), + ("whose dictionary is past its bound", dictionary, (16, 2)), + ]; + let work = InterpreterSettings::default().jbig2_work; + for (what, mask, size) in masks { + let (once, _, _) = draw_under_jbig2_masks(&mask, size, &[("P", 20)], work); + let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, size, &[("P", 20); 100], work); + assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out"); + assert_eq!(decodes, once, "a mask {what}: a hundred draws decode as one does"); + assert_eq!(truncated, 100, "a mask {what}: every draw says content was left out"); + // With no page allowance to run out of, so that the mask past its work is refused by its + // own limit and the page's allowance doesn't stop what is kept. + let each = [("P", 20), ("W", 20), ("P", 40)]; + let (once_each, _, _) = draw_under_jbig2_masks(&mask, size, &each, u64::MAX); + let (decodes, _, draws) = draw_under_jbig2_masks(&mask, size, &[&each[..], &[("P", 40); 2]].concat(), u64::MAX); + assert_eq!(draws.rasters, 0, "a mask {what} leaves the picture out at either size"); + assert_eq!(decodes, once_each, "a mask {what}: drawn again at its second size, decoded as once at each"); + } +} + +/// A JBIG2 mask refused for the work its decoding takes isn't kept: that work may be what its page +/// had left, which another page has afresh. So the picture under it is decoded at every draw, here +/// with no page allowance to run out of, where the stream's own limit is what refuses it. +#[test] +fn a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw() { + let mask = super::black_jbig2_page(); + let (once, _, _) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20)], u64::MAX); + let (decodes, truncated, draws) = draw_under_jbig2_masks(&mask, (16, 2), &[("W", 20); 3], u64::MAX); + assert_eq!(draws.rasters, 0, "the picture is left out"); + assert_eq!(decodes, 3 * once, "decoded at every draw"); + assert_eq!(truncated, 3, "every draw says content was left out"); +} diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index bfb04a26cb..a72d91ad2a 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -17,6 +17,7 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror.workspace = true +zeroize.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } base64 = "0.23" diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index 0521c5d914..b5d466ae81 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -4,6 +4,8 @@ //! (constant-time; ADR-0009: the `rsa` crate is never used for private keys there). ECDSA //! signing uses RustCrypto with deterministic nonces (RFC 6979). +use zeroize::Zeroizing; + use crate::SignError; use crate::der::{self, Tlv, tag}; @@ -588,7 +590,7 @@ pub struct PrivateKey { inner: Inner, public: PublicKey, /// The PKCS#8 `PrivateKeyInfo` it came from (to write it back into a PKCS#12 file). - pkcs8: Vec, + pub(crate) pkcs8: Zeroizing>, } impl std::fmt::Debug for PrivateKey { @@ -601,7 +603,7 @@ impl PrivateKey { /// A key held outside PdfCraft's memory (its public half is `public`). It can't be /// exported to a PKCS #12 file. pub fn external(public: PublicKey, key: std::sync::Arc) -> PrivateKey { - PrivateKey { inner: Inner::External(key), public, pkcs8: Vec::new() } + PrivateKey { inner: Inner::External(key), public, pkcs8: Zeroizing::new(Vec::new()) } } /// Whether the key lives outside the app (and so can't be exported). @@ -631,7 +633,7 @@ impl PrivateKey { ); #[cfg(target_arch = "wasm32")] let inner = Inner::Rsa; - Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner, public: PublicKey::Rsa { n, e }, pkcs8: Zeroizing::new(info.to_vec()) }) } oid::EC => { // ECPrivateKey: version, privateKey OCTET STRING, [0] parameters, [1] publicKey. @@ -650,12 +652,12 @@ impl PrivateKey { oid::P256 => { let k = p256::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-256 key".into()))?; let public = PublicKey::P256(k.verifying_key().to_sec1_point(false).as_bytes().to_vec()); - Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner: Inner::P256(k), public, pkcs8: Zeroizing::new(info.to_vec()) }) } oid::P384 => { let k = p384::ecdsa::SigningKey::from_slice(d).map_err(|_| SignError::Malformed("P-384 key".into()))?; let public = PublicKey::P384(k.verifying_key().to_sec1_point(false).as_bytes().to_vec()); - Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: info.to_vec() }) + Ok(PrivateKey { inner: Inner::P384(k), public, pkcs8: Zeroizing::new(info.to_vec()) }) } other => Err(SignError::Unsupported(format!("elliptic curve {other}"))), } @@ -689,13 +691,15 @@ impl PrivateKey { /// A new P-256 key. pub fn generate_p256() -> Result { loop { - let mut d = [0u8; 32]; - getrandom::fill(&mut d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?; - if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&d) { + let mut d = Zeroizing::new([0u8; 32]); + getrandom::fill(&mut *d).map_err(|e| SignError::Crypto(format!("no randomness: {e}")))?; + if let Ok(k) = p256::ecdsa::SigningKey::from_slice(&*d) { let point = k.verifying_key().to_sec1_point(false).as_bytes().to_vec(); // PKCS#8 with an ECPrivateKey (RFC 5915) carrying the public key. - let ec = der::seq(&[&der::int(1), &der::octets(&d), &der::explicit(1, &der::bit_string(&point))]); - let info = der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &der::octets(&ec)]); + let d_octets = Zeroizing::new(der::octets(&*d)); + let ec = Zeroizing::new(der::seq(&[&der::int(1), &d_octets, &der::explicit(1, &der::bit_string(&point))])); + let ec_octets = Zeroizing::new(der::octets(&ec)); + let info = Zeroizing::new(der::seq(&[&der::int(0), &der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &ec_octets])); return PrivateKey::from_pkcs8(&info); } } diff --git a/crates/sign/src/pkcs12.rs b/crates/sign/src/pkcs12.rs index a71d5c8584..77a1019ff6 100644 --- a/crates/sign/src/pkcs12.rs +++ b/crates/sign/src/pkcs12.rs @@ -20,6 +20,7 @@ use base64::Engine as _; use cbc::cipher::block_padding::Pkcs7; use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyIvInit}; use hmac::Mac; +use zeroize::Zeroizing; use crate::SignError; use crate::der::{self, Tlv, tag}; @@ -100,14 +101,14 @@ fn bad(what: &str) -> SignError { } /// The password as a BMPString with its terminating NUL (RFC 7292 Appendix B.1). -fn bmp(password: &str) -> Vec { - let mut v: Vec = password.encode_utf16().flat_map(u16::to_be_bytes).collect(); +fn bmp(password: &str) -> Zeroizing> { + let mut v = Zeroizing::new(password.encode_utf16().flat_map(u16::to_be_bytes).collect::>()); v.extend([0, 0]); v } /// The PKCS #12 key derivation function (RFC 7292 Appendix B.2). -fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Vec { +fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: u32, n: usize) -> Zeroizing> { // Callers only pass the SHA-1/SHA-2 digests PKCS #12 files use (`open` restricts the MAC digest). let (u, v) = match alg { DigestAlg::Sha1 => (20, 64), @@ -115,25 +116,25 @@ fn pkcs12_kdf(alg: DigestAlg, password: &[u8], salt: &[u8], id: u8, iterations: DigestAlg::Sha256 => (32, 64), DigestAlg::Sha384 => (48, 128), DigestAlg::Sha512 => (64, 128), - _ => return Vec::new(), + _ => return Zeroizing::new(Vec::new()), }; let _ = u; - let fill = |s: &[u8]| -> Vec { + let fill = |s: &[u8]| -> Zeroizing> { if s.is_empty() { - return Vec::new(); + return Zeroizing::new(Vec::new()); } let len = v * s.len().div_ceil(v); - s.iter().cycle().take(len).copied().collect() + Zeroizing::new(s.iter().cycle().take(len).copied().collect()) }; let d = vec![id; v]; - let mut i: Vec = [fill(salt), fill(password)].concat(); - let mut out = Vec::with_capacity(n); + let mut i = Zeroizing::new([fill(salt).as_slice(), fill(password).as_slice()].concat()); + let mut out = Zeroizing::new(Vec::with_capacity(n)); while out.len() < n { - let mut a = alg.digest(&[&d, &i]); + let mut a = Zeroizing::new(alg.digest(&[&d, &i])); for _ in 1..iterations.max(1) { - a = alg.digest(&[&a]); + a = Zeroizing::new(alg.digest(&[&a])); } - let b: Vec = a.iter().cycle().take(v).copied().collect(); + let b = Zeroizing::new(a.iter().cycle().take(v).copied().collect::>()); for chunk in i.chunks_mut(v) { // chunk = (chunk + b + 1) mod 2^(8v) let mut carry = 1u16; @@ -169,7 +170,7 @@ impl Cipher { } } - fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result, SignError> { + fn decrypt(self, key: &[u8], iv: &[u8], ct: &[u8]) -> Result>, SignError> { let wrong = || SignError::WrongPassword; let r = match self { Cipher::Aes128 => cbc::Decryptor::::new_from_slices(key, iv).map_err(|_| wrong())?.decrypt_padded_vec::(ct), @@ -181,7 +182,7 @@ impl Cipher { cbc::Decryptor::::inner_iv_slice_init(c, iv).map_err(|_| wrong())?.decrypt_padded_vec::(ct) } }; - r.map_err(|_| wrong()) + r.map(Zeroizing::new).map_err(|_| wrong()) } } @@ -205,8 +206,8 @@ fn hmac(alg: DigestAlg, key: &[u8], data: &[u8]) -> Result, SignError> { } } -fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result, SignError> { - let mut out = vec![0u8; len]; +fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) -> Result>, SignError> { + let mut out = Zeroizing::new(vec![0u8; len]); match prf { DigestAlg::Sha1 => pbkdf2::pbkdf2_hmac::(password, salt, rounds, &mut out), DigestAlg::Sha224 => pbkdf2::pbkdf2_hmac::(password, salt, rounds, &mut out), @@ -219,7 +220,7 @@ fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize) } /// Decrypt `ct` with the password-based scheme `alg` (an AlgorithmIdentifier). -fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result, SignError> { +fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result>, SignError> { let parts = alg.children()?; let o = parts.first().ok_or_else(|| bad("algorithm"))?.oid()?; let params = parts.get(1).ok_or_else(|| bad("algorithm parameters"))?; @@ -280,8 +281,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len); if key.len() == 16 && matches!(cipher, Cipher::TripleDes) { // Two-key 3DES: K1 K2 K1. - let k1 = key[..8].to_vec(); - key.extend(k1); + key.extend_from_within(..8); } let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 2, rounds, iv_len); cipher.decrypt(&key, &iv, ct) @@ -297,7 +297,7 @@ struct Bag { } enum BagKind { - Key(Vec), + Key(Zeroizing>), Cert(Vec), } @@ -319,7 +319,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec, budget: &mut B } } let kind = match id.as_str() { - KEY_BAG => BagKind::Key(value.raw.to_vec()), + KEY_BAG => BagKind::Key(Zeroizing::new(value.raw.to_vec())), SHROUDED_KEY_BAG => { let p = value.children()?; let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) }; @@ -549,4 +549,16 @@ mod tests { assert_eq!(budget.spend(DigestAlg::Sha1, 0, 20).ok(), Some(0)); assert_eq!(budget.0, OPEN_BUDGET - 1); } + + #[test] + fn key_and_kdf_buffers_are_zeroizing() { + let key = PrivateKey::generate_p256().unwrap(); + let _: &Zeroizing> = &key.pkcs8; + let pw: Zeroizing> = bmp("secret12"); + assert_eq!(&*pw, &[0, b's', 0, b'e', 0, b'c', 0, b'r', 0, b'e', 0, b't', 0, b'1', 0, b'2', 0, 0]); + let kdf: Zeroizing> = pkcs12_kdf(DigestAlg::Sha256, &pw, &[1, 2, 3, 4], 1, 1, 32); + assert_eq!(kdf.len(), 32); + let derived: Zeroizing> = pbkdf2(DigestAlg::Sha256, b"secret12", &[1, 2, 3, 4], 1, 32).unwrap(); + assert_eq!(derived.len(), 32); + } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7f9f39894e..b4e7022220 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -34,6 +34,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "acces serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +zeroize = { workspace = true } # UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono # (no hand-rolled calendar code); std-only, so it stays wasm-safe. chrono = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f95aaf36d4..db5f2edd6d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -133,6 +133,7 @@ impl PdfCraftApp { printer_source: None, options_source: None, spooler: None, + two_sided_source: None, print_tasks: print_ui::PrintTasks::default(), palette_open: false, palette_query: String::new(), diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 986ec745d4..058280e32b 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -61,6 +61,21 @@ impl PdfCraftApp { pub(crate) fn select_tool(&mut self, id: &'static str) { // The tool's panel shows, not the expanded All tools view over it. self.all_tools_full = false; + // Edit a PDF turns on Edit text & images at once, as the Edit tab does (`enter_mode`), so + // the page is editable without a second click. Leaving it for another tool from All tools + // puts editing down again; under the Edit tab the workspace keeps it, as before. Read mode + // hides the quick toolbar and keeps every tool down (`enter_mode`), so the control + // channel's `tool edit` there leaves editing off. + if id == "edit" { + if self.mode != Mode::Read { + self.quick_tool = QuickTool::EditText; + } + } else if self.mode != Mode::Edit + && self.left == LeftPanel::Tool("edit") + && matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) + { + self.put_down_edit_text(); + } self.left = LeftPanel::Tool(id); self.reveal_tools(); if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) { diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 9381664527..a545b4165a 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -161,6 +161,7 @@ impl PdfCraftApp { if let Some(Dialog::Marks(kind)) = self.dialog { self.marks_draft.opened(kind); } + dialogs::discard_inactive_password_drafts(self); } ("tools", _) if value == "full" => self.expand_all_tools(), ("tools", _) => { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 1a873a7c11..20e67b756a 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -484,7 +484,9 @@ impl PdfCraftApp { } } - /// Save an attachment to disk, or open a PDF attachment in a new tab. + /// Save an attachment to disk, or open a PDF attachment in a new tab (or go to the tab it is + /// already open in). Only bytes that are a PDF open, whatever the file's name says; anything + /// else asked to open is offered to save. pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; @@ -493,13 +495,30 @@ impl PdfCraftApp { 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) => { + (Ok(bytes), true) if looks_like_pdf(&bytes) => { // The tab name later names files written from the tab (Split, Extract pages). - if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); + let name = crate::files::attachment_file_name(&att.name); + // Already open: the tab opened from this attachment of this document (its bytes + // may have grown since, as a form laid out on open appends its layout). + let origin = (doc, att.name.clone()); + let open_in = self.views.iter().position(|v| v.from_attachment.as_ref() == Some(&origin) && self.session.get(v.id).is_some()); + if let Some(i) = open_in { + self.active = Some(i); + return; + } + let before = self.views.len(); + match self.open_bytes(&name, None, bytes) { + // Opening bytes adds their tab at once (unless a password is asked for first). + Ok(()) if self.views.len() > before => { + if let Some(v) = self.views.last_mut() { + v.from_attachment = Some(origin); + } + } + Ok(()) => {} + Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), } } - (Ok(bytes), false) => { + (Ok(bytes), _) => { #[cfg(not(target_arch = "wasm32"))] self.ask_one( pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))), @@ -627,11 +646,12 @@ impl PdfCraftApp { /// Answer the password prompt (`None` cancels). pub fn submit_password(&mut self, password: Option) { - let Some(p) = self.password_prompt.take() else { return }; - let Some(pw) = password else { + let Some(mut p) = self.password_prompt.take() else { return }; + zeroize::Zeroize::zeroize(&mut p.input); + let Some(pw) = password.map(zeroize::Zeroizing::new) else { // A cancelled recovery goes back on offer; its snapshot stays in the store and must // not attach itself to whatever document is unlocked next (#813). - if let Some(meta) = p.recovered { + if let Some(meta) = p.recovered.take() { self.recoverable.push(meta); } #[cfg(not(target_arch = "wasm32"))] @@ -669,7 +689,9 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] let (path_for_watch, stamp_for_watch) = (p.path.clone(), p.stamp); let after_open = p.after_open.clone(); - match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) { + let opened = self.try_open(&p.name, p.path.take(), p.bytes.clone(), Some(&pw), p.activate); + drop(pw); + match opened { Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]), // A recovered encrypted document is open once its prompt is gone. Ok(()) if self.password_prompt.is_none() => { @@ -706,7 +728,7 @@ impl PdfCraftApp { // and the tool waiting for it. Ok(()) => { if let Some(prompt) = self.password_prompt.as_mut() { - prompt.recovered = p.recovered; + prompt.recovered = p.recovered.take(); prompt.then = p.then; prompt.after_open = after_open; prompt.display_override = p.display_override; @@ -726,8 +748,11 @@ impl PdfCraftApp { /// Answer the permissions password prompt (`None` cancels). pub fn submit_unlock(&mut self, password: Option) { let Some(mut p) = self.unlock_prompt.take() else { return }; - let Some(pw) = password else { return }; - match self.session.unlock(p.doc, &pw) { + zeroize::Zeroize::zeroize(&mut p.input); + let Some(mut pw) = password else { return }; + let unlocked = self.session.unlock(p.doc, &pw); + zeroize::Zeroize::zeroize(&mut pw); + match unlocked { // Carry on with what asked for the password, if its document is still the one shown. Ok(()) => match p.then { Some(command) if self.active_ids().is_some_and(|(_, id)| id == p.doc) => { @@ -736,7 +761,6 @@ impl PdfCraftApp { _ => self.notify_tr("Restrictions lifted"), }, Err(pdfcraft_engine::EditError::WrongPassword) => { - p.input.clear(); p.error = Some("Incorrect password. Try again.".to_string()); self.unlock_prompt = Some(p); } @@ -1023,3 +1047,8 @@ impl PdfCraftApp { } } } + +/// Whether `bytes` are a PDF: a `%PDF-` header within the first 1024 bytes, as readers accept. +fn looks_like_pdf(bytes: &[u8]) -> bool { + bytes.get(..1024).unwrap_or(bytes).windows(5).any(|w| w == b"%PDF-") +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 3455029a0d..04f6958ff2 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,5 @@ use super::*; +use pdfcraft_engine::print::names as print_names; /// [`PdfCraftApp::date_text`] for callers that already borrow other parts of the app. pub(crate) fn date_text(session: &Session, fmt: Option<&str>) -> Result { @@ -75,6 +76,19 @@ impl PdfCraftApp { /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); + // The Print dialog's choices that describe how this computer prints (#2001), named as + // `doc_print` names them. Copies, pages, layout and orientation are one job's. + let print = serde_json::json!({ + "duplex": print_names::duplex(self.print_draft.duplex), + "grayscale": self.print_draft.grayscale, + "collate": self.print_draft.collate, + "size": print_names::size_mode(self.print_draft.size), + "custom_scale": self.print_draft.custom_scale, + "comments_forms": print_names::content(self.print_draft.content), + "binding": print_names::binding(self.print_draft.binding), + "as_image": self.print_draft.as_image, + "dpi": self.print_draft.dpi, + }); let mut v = serde_json::json!({ "side_panels": self.side_panels.to_json(self.left_open), "docking_right": self.right, @@ -173,6 +187,10 @@ impl PdfCraftApp { v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language. v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + // Also set outside json! above, which is at serde_json's macro recursion limit. + v["print"] = print; + // Each printer's paper, sizing and two-sided choice, the one printed to last first (#2001). + v["print_printers"] = crate::print_ui::setups_to_json(&self.print_draft.setups); v.to_string() } @@ -300,6 +318,9 @@ impl PdfCraftApp { { self.print_draft.paper = paper; } + if let Some(setups) = crate::print_ui::setups_from_json(&v["print_printers"]) { + self.print_draft.setups = setups; + } if let Some(on) = v["highlight_fields"].as_bool() { self.view_defaults.highlight_fields = on; } @@ -309,6 +330,41 @@ impl PdfCraftApp { if let Some(closed) = v["right_panel_closed"].as_bool() { self.right_panel_closed = closed; } + // The Print dialog's remembered choices (#2001). Settings are untrusted: an unknown name + // or a scale outside the dialog's range keeps the default. The scale is read before the + // size mode, which carries it. + let print = &v["print"]; + let draft = &mut self.print_draft; + if let Some(duplex) = print["duplex"].as_str().and_then(print_names::parse_duplex) { + draft.duplex = duplex; + } + if let Some(on) = print["grayscale"].as_bool() { + draft.grayscale = on; + } + if let Some(on) = print["collate"].as_bool() { + draft.collate = on; + } + if let Some(scale) = print["custom_scale"].as_f64().filter(|s| print_ui::CUSTOM_SCALE.contains(s)) { + draft.custom_scale = scale; + } + if let Some(size) = print["size"].as_str().and_then(|name| print_names::parse_size_mode(name, draft.custom_scale)) { + draft.size = size; + } + if let Some(content) = print["comments_forms"].as_str().and_then(print_names::parse_content) { + draft.content = content; + } + // A booklet's binding side, as Acrobat keeps it (#2707). + if let Some(binding) = print["binding"].as_str().and_then(print_names::parse_binding) { + draft.binding = binding; + } + // Print as image and its resolution (#6392), so a printer that needs pictures keeps getting + // them; a resolution the dialog doesn't offer keeps the default. + if let Some(on) = print["as_image"].as_bool() { + draft.as_image = on; + } + if let Some(dpi) = print["dpi"].as_f64().filter(|d| print_ui::IMAGE_DPI.contains(d)) { + draft.dpi = dpi; + } // Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip // flags); unknown or malformed values keep the defaults. self.ocr_draft.apply_pref(&v["ocr"]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eebcece9b2..587917cef9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -102,39 +102,15 @@ impl PainterExt for egui::Painter { } } -/// User-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect. +/// User-space editing frame: explicit PDF/editor crops apply, but the page edge does not +/// truncate a selected image's handles. Body hit testing still respects page visibility. pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] { - if let Some(clip) = im.clip_rect { - let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])]; - if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] { - return r; - } - } - im.visible_rect.unwrap_or(im.rect) + im.frame_rect().unwrap_or(im.rect) } -/// Screen-space selectable bounding box for an image object. -/// If the image object has an active clipping path applied to it (the crop box), -/// the final selectable `egui::Rect` is the mathematical intersection of the image's -/// full CTM-derived rect and the clipping path's rect. +/// Screen-space editing frame, including selected handles outside the page. pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect_f64(info, page, im.rect); - if let Some(clip) = im.clip_rect { - let clip_r = xf.user_rect_f64(info, page, clip); - let inter = full.intersect(clip_r); - if inter.is_positive() { - return inter; - } - } - if let Some(vr) = im.visible_rect { - let r = xf.user_rect_f64(info, page, vr); - if r.is_positive() { - return r; - } - } - full + xf.user_rect_f64(info, page, image_selectable_bounds(im)) } /// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space, @@ -507,6 +483,9 @@ pub struct DocView { /// views of one document apart (`crate::panes`). pub(crate) pane: crate::panes::ViewPane, pub id: DocId, + /// The document and attached file this tab was opened from, when a form or the Attachments + /// panel opened it: asking for that attachment again comes back to this tab. + pub from_attachment: Option<(DocId, String)>, pub zoom: f32, pub fit: Fit, pub layout: PageLayout, @@ -1036,6 +1015,7 @@ impl DocView { Self { pane: Default::default(), id, + from_attachment: None, zoom: defaults.zoom, fit: defaults.fit, layout: defaults.layout, @@ -1158,7 +1138,7 @@ impl DocView { page_widget: None, pan_hold_until: 0.0, slow_pages: HashSet::new(), - comments: Default::default(), + comments: crate::comments::CommentView::from_info(info), measure: Default::default(), forms: Default::default(), prepare: Default::default(), @@ -1216,6 +1196,7 @@ impl DocView { // Thumbnail drags hold page indexes from before the change. self.panel_drag = None; self.org_drag = None; + self.comments.document_changed(info); self.reset_coarse_state(); } @@ -1860,7 +1841,12 @@ fn draw_pages( if delta != Vec2::ZERO && delta.is_finite() { ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + // A resized handle can sit in the window beside a page that does not fill it. The + // response has to cover that visible area or the next press never starts: the pointer + // is reported only inside this rectangle once the previous drag has been released. + // Page positions stay on `content_w`/`content_h`; the extra area is not a new page. + let response_size = vec2(content_w.max(viewport.width()), content_h.max(viewport.height())); + let (resp_rect, resp) = ui.allocate_exact_size(response_size, Sense::click_and_drag()); view.page_widget = Some(resp.id); // A drag lasts only while egui reports it, so the mark can't outlive one. if !resp.dragged_by(egui::PointerButton::Primary) { @@ -3484,7 +3470,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // One crop, then back to selecting (as Acrobat does). let cropped = view.pending_edit.as_ref().is_some_and(|e| matches!(e, pdfcraft_engine::Edit::SetPageBox { .. })); let mut tool = app.quick_tool; - comments::keys(ui.ctx(), view, &mut tool, allowed); + comments::keys(ui.ctx(), view, info, &mut tool, allowed); if preparing { crate::prepare::keys(ui.ctx(), view); } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index ac52586062..1ad3e0f5ed 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -113,7 +113,7 @@ impl PdfCraftApp { /// Last-resort guard (AGENTS.md §4): a command that panics is reported and the app, with its /// open documents, keeps running. Edits are applied to a copy, so the document is unchanged. pub fn execute(&mut self, id: &str) -> bool { - match pdfcraft_engine::guard(|| self.execute_unguarded(id)) { + let done = 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 @@ -122,7 +122,9 @@ impl PdfCraftApp { self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]); true } - } + }; + crate::dialogs::discard_inactive_password_drafts(self); + done } /// Why the command can't run right now, for the user. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0f93ae8e2d..b389ca431d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,11 +26,22 @@ pub(super) mod word_count_ui; #[cfg(test)] mod tests; -pub(crate) use prompts::{PromptButton, save_prompt_key}; +pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle}; use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock}; const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// Drop (and zeroize) password drafts as soon as their owning dialog is inactive. +/// Commands and control requests also call this: close/reopen can happen between frames. +pub(crate) fn discard_inactive_password_drafts(app: &mut PdfCraftApp) { + if app.dialog != Some(Dialog::Protect) { + app.protect_draft = Default::default(); + } + if app.dialog != Some(Dialog::Sign) { + app.sign_draft = None; + } +} + /// What the dialog's widgets asked for this frame; acted on after the modal is drawn. #[derive(Default)] struct Pending { @@ -106,6 +117,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if app.dialog != Some(Dialog::FieldTable) { app.field_table_draft = None; } + discard_inactive_password_drafts(app); // A movable window, not this modal: the page under it shows the table as it changes. if app.dialog == Some(Dialog::FieldTable) { crate::field_table_ui::show(app, ctx); @@ -2214,6 +2226,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: } else { app.dialog = Some(req.next.unwrap_or(dialog)); } + discard_inactive_password_drafts(app); if let Some(cmd) = req.link_command { app.execute(cmd); } @@ -2282,3 +2295,134 @@ fn draft_changes(app: &PdfCraftApp) -> Option> { fn yes(b: bool) -> String { tl!(if b { "Yes" } else { "No" }).to_string() } + +#[cfg(test)] +mod password_draft_tests { + use super::*; + use egui_kittest::Harness; + + fn seed_password_drafts(app: &mut PdfCraftApp) { + app.start_signing(0, None, None, None); + let sign = app.sign_draft.as_mut().unwrap(); + sign.step = crate::sign_ui::SignStep::Choose; + sign.password = "sign password".into(); + sign.new_id.password = "new ID password".into(); + sign.new_id.confirm = "new ID password".into(); + sign.new_id.file_password = "import password".into(); + app.protect_draft.password = "view password".into(); + app.protect_draft.confirm = "view password".into(); + app.protect_draft.protection.open_password = Some("open password".into()); + app.protect_draft.protection.permissions_password = Some("owner password".into()); + } + + fn assert_protect_cleared(app: &PdfCraftApp) { + assert_eq!(app.protect_draft, crate::protect::ProtectDraft::default()); + } + + #[test] + fn inactive_password_drafts_are_cleared_before_early_returns() { + // None and FieldTable return before the ordinary modal-close path; About replaces it. + for dialog in [None, Some(Dialog::FieldTable), Some(Dialog::About)] { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + seed_password_drafts(&mut app); + app.dialog = dialog; + app + }); + h.run_steps(2); + assert_protect_cleared(h.state()); + assert!(h.state().sign_draft.is_none()); + } + } + + #[test] + fn rendering_preserves_only_the_active_password_draft() { + for dialog in [Dialog::Protect, Dialog::Sign] { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + seed_password_drafts(&mut app); + app.dialog = Some(dialog); + app + }); + h.run_steps(2); + if dialog == Dialog::Protect { + assert_eq!(h.state().protect_draft.password, "view password"); + assert_eq!(h.state().protect_draft.confirm, "view password"); + assert!(h.state().sign_draft.is_none()); + } else { + assert_protect_cleared(h.state()); + let sign = h.state().sign_draft.as_ref().unwrap(); + assert_eq!(sign.password, "sign password"); + assert_eq!(sign.new_id.password, "new ID password"); + assert_eq!(sign.new_id.confirm, "new ID password"); + assert_eq!(sign.new_id.file_password, "import password"); + } + } + } + + #[test] + fn control_close_and_reopen_clears_passwords_without_a_render_between_requests() { + let mut app = PdfCraftApp::new(); + // The control queue can handle several ui.set requests before the next rendered frame. + // Repeat both close and replacement flows so stale drafts cannot return on later opens. + for _ in 0..2 { + for replacement in ["none", "about", "sign", "certify"] { + app.set_option("dialog", "protect").unwrap(); + app.protect_draft.password = "old password".into(); + app.protect_draft.confirm = "old password".into(); + app.set_option("dialog", replacement).unwrap(); + assert_protect_cleared(&app); + app.set_option("dialog", "protect").unwrap(); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + } + for replacement in ["none", "about", "protect"] { + seed_password_drafts(&mut app); + app.set_option("dialog", replacement).unwrap(); + assert!(app.sign_draft.is_none(), "inactive Sign draft retained after {replacement}"); + app.set_option("dialog", "sign").unwrap(); + let sign = app.sign_draft.as_ref().unwrap(); + assert!(sign.password.is_empty()); + assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default()); + } + } + } + + #[test] + fn selecting_the_active_protect_dialog_keeps_its_password() { + let mut app = PdfCraftApp::new(); + app.set_option("dialog", "protect").unwrap(); + app.protect_draft.password = "current password".into(); + app.protect_draft.confirm = "current password".into(); + app.set_option("dialog", "protect").unwrap(); + assert_eq!(app.protect_draft.password, "current password"); + assert_eq!(app.protect_draft.confirm, "current password"); + } + + #[test] + fn commands_discard_replaced_password_drafts_before_the_next_control_request() { + let mut app = PdfCraftApp::new(); + for _ in 0..2 { + seed_password_drafts(&mut app); + app.dialog = Some(Dialog::Protect); + assert!(app.execute("help.about")); + assert_eq!(app.dialog, Some(Dialog::About)); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + app.set_option("dialog", "protect").unwrap(); + assert_protect_cleared(&app); + + seed_password_drafts(&mut app); + assert!(app.execute("help.shortcuts")); + assert_eq!(app.dialog, Some(Dialog::Shortcuts)); + assert_protect_cleared(&app); + assert!(app.sign_draft.is_none()); + app.set_option("dialog", "sign").unwrap(); + let sign = app.sign_draft.as_ref().unwrap(); + assert!(sign.password.is_empty()); + assert_eq!(sign.new_id, crate::sign_ui::NewIdDraft::default()); + } + } +} diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 91e069bca3..b1aa162790 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -130,7 +130,7 @@ pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { /// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and /// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one. -fn shorten_middle(name: &str, max: usize) -> String { +pub(crate) fn shorten_middle(name: &str, max: usize) -> String { let count = name.chars().count(); if count <= max { return name.to_string(); @@ -351,7 +351,7 @@ pub(super) fn password(app: &mut PdfCraftApp, ctx: &egui::Context) { let message = crate::i18n::fmt(tl!("“{name}” is protected. Enter a password to open it."), &[("name", &prompt.name)]); match password_modal(ctx, "password", tl!("Password required"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Open")) { Some(PasswordAnswer::Submit) => { - let pw = prompt.input.clone(); + let pw = std::mem::take(&mut prompt.input); app.submit_password(Some(pw)); } Some(PasswordAnswer::Cancel) => app.submit_password(None), @@ -366,7 +366,7 @@ pub(super) fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) { crate::i18n::fmt(tl!("“{name}” has security restrictions. Enter its permissions password to lift them."), &[("name", &prompt.name)]); match password_modal(ctx, "unlock", tl!("Permissions password"), message, &mut prompt.input, prompt.error.as_deref(), tl!("Unlock")) { Some(PasswordAnswer::Submit) => { - let pw = prompt.input.clone(); + let pw = std::mem::take(&mut prompt.input); app.submit_unlock(Some(pw)); } Some(PasswordAnswer::Cancel) => app.submit_unlock(None), diff --git a/crates/ui-egui/src/dialogs/protect.rs b/crates/ui-egui/src/dialogs/protect.rs index ffc8b53f8e..cc79f3c82c 100644 --- a/crates/ui-egui/src/dialogs/protect.rs +++ b/crates/ui-egui/src/dialogs/protect.rs @@ -25,6 +25,20 @@ impl Default for ProtectDraft { } } +impl Drop for ProtectDraft { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.password.zeroize(); + self.confirm.zeroize(); + if let Some(pw) = &mut self.protection.open_password { + pw.zeroize(); + } + if let Some(pw) = &mut self.protection.permissions_password { + pw.zeroize(); + } + } +} + impl ProtectDraft { /// Why Apply is disabled, if it is. pub fn problem(&self) -> Option<&'static str> { @@ -251,4 +265,40 @@ mod tests { assert_eq!(strength("Abcdefg1").0, "Medium"); assert_eq!(strength("Correct-Horse-9").0, "Strong"); } + + #[test] + fn closing_the_protect_dialog_clears_the_password_draft() { + use egui_kittest::{Harness, kittest::Queryable}; + + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.dialog = Some(crate::Dialog::Protect); + app.protect_draft.password = "TopSecret123!".into(); + app.protect_draft.confirm = "TopSecret123!".into(); + app + }); + h.run_steps(2); + + // Clicking Cancel closes Dialog::Protect and clears the draft passwords. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().protect_draft.password.is_empty(), "password must not linger after cancelling Dialog::Protect"); + assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after cancelling Dialog::Protect"); + + // Pressing Escape on Dialog::Protect also clears the draft passwords. + { + let app = h.state_mut(); + app.dialog = Some(crate::Dialog::Protect); + app.protect_draft.password = "AnotherSecret!".into(); + app.protect_draft.confirm = "AnotherSecret!".into(); + } + h.run_steps(2); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().protect_draft.password.is_empty(), "password must not linger after closing Dialog::Protect"); + assert!(h.state().protect_draft.confirm.is_empty(), "confirm must not linger after closing Dialog::Protect"); + } } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 089b58ddeb..4b649f9b5b 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -28,6 +28,16 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } +/// Clear an unfinished page-anchored drawing or composer when a page edit shifts or removes `page`. +fn clear_shifted_page_comment_state(view: &mut crate::DocView, shifted_from: usize) { + if matches!(view.comments.gesture, Some(crate::comments::Gesture::Draw { page, .. }) if page >= shifted_from) { + view.comments.gesture = None; + } + if view.comments.composer.as_ref().is_some_and(|c| !matches!(c.kind, crate::comments::ComposerKind::Edit(_)) && c.page >= shifted_from) { + view.comments.composer = None; + } +} + /// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` /// is the page's annotations before a delete or a paste of comments (empty for other edits). fn select_after_edit( @@ -39,17 +49,27 @@ fn select_after_edit( ) { match edit { Edit::MovePages { pages, to } => { + clear_shifted_page_comment_state(view, 0); let n = pages.iter().collect::>().len(); let to = (*to).min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } Edit::DuplicatePages { pages, at: Some(at) } => { + clear_shifted_page_comment_state(view, *at); let n = pages.iter().collect::>().len(); let at = (*at).min(info.pages.len().saturating_sub(n)); view.select_pages(&(at..at + n).collect::>()); } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]), - Edit::DeletePages { .. } => view.select_pages(&[]), + Edit::InsertBlankPage { at, .. } => { + clear_shifted_page_comment_state(view, *at); + view.select_pages(&[(*at).min(info.pages.len() - 1)]); + } + Edit::DeletePages { pages } => { + if let Some(&first) = pages.iter().min() { + clear_shifted_page_comment_state(view, first); + } + view.select_pages(&[]); + } // A stroke drawn with the pen stays unselected, so the selection box and // author popup don't sit over the next stroke (#429). Edit::AddAnnotation(a) @@ -76,7 +96,6 @@ fn select_after_edit( view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), // A group moved together stays selected (its positions don't change), so it // can be moved or copied again; anything else in a batch clears the selection. Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {} @@ -207,25 +226,15 @@ impl PdfCraftApp { .map(|before| form_edit_pages(&before, &doc.form)); let one_page = comment_page(&edit).or_else(|| content_page(&edit)); if signatures_before.is_some_and(|before| crate::canvas::signature_states_changed(&before, &doc.signatures)) { - // A signature shows its state on its own page, which an edit elsewhere can - // change (a certification forbidding it, say): every page redraws. An edit that - // moves no page (a form edit, or one to a page's comments or content) keeps the - // values typed, in it and before it, showing until their pages redraw. - let waiting = (form_edit.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked)); - view.document_changed(info); - if let Some(waiting) = waiting { - view.forms.unbaked = waiting; - } - if let Some((_, unbaked)) = form_edit { - view.forms.unbaked.extend(unbaked); - } + signatures_changed_after_edit(view, &edit, info, &annots_before, form_edit.map(|(_, u)| u), one_page); } else if let Some(page) = one_page { - one_page_changed(view, &edit, page); + one_page_changed(view, &edit, page, &annots_before, info); } else if let Some((changed_pages, unbaked)) = form_edit { view.forms.unbaked.extend(unbaked); for page in changed_pages { view.page_changed(page); } + view.comments.document_changed(info); } else { view.document_changed(info); } @@ -1109,13 +1118,51 @@ fn content_page(edit: &Edit) -> Option { } } +/// A signature shows its state on its own page, which an edit elsewhere can change (a +/// certification forbidding it, say): every page redraws. An edit that moves no page (a form edit, +/// or one to a page's comments or content) keeps the values typed, in it and before it, showing +/// until their pages redraw. +fn signatures_changed_after_edit( + view: &mut crate::DocView, + edit: &Edit, + info: &pdfcraft_render::DocInfo, + annots_before: &[pdfcraft_render::Annotation], + form_unbaked: Option>, + one_page: Option, +) { + let waiting = (form_unbaked.is_some() || one_page.is_some()).then(|| std::mem::take(&mut view.forms.unbaked)); + if let Edit::DeleteAnnotation { page, index } = edit { + // Remap surviving comments before generic slot pruning loses their state. + view.comments.annotation_deleted(annots_before, info, (*page, *index)); + } + view.document_changed(info); + if let Some(waiting) = waiting { + view.forms.unbaked = waiting; + } + if let Some(unbaked) = form_unbaked { + view.forms.unbaked.extend(unbaked); + } +} + /// A comment or content edit changes one page, `page`: keep every other raster. Opening, closing /// or moving a pop-up changes none: the window is drawn over the page, and a /Popup annotation /// paints nothing in the raster (ISO 32000-2 §12.5.6.14). -fn one_page_changed(view: &mut crate::DocView, edit: &Edit, page: usize) { +fn one_page_changed( + view: &mut crate::DocView, + edit: &Edit, + page: usize, + annots_before: &[pdfcraft_render::Annotation], + info: &pdfcraft_render::DocInfo, +) { if !matches!(edit, Edit::SetAnnotationPopup { .. }) { view.page_changed(page); } + if let Edit::DeleteAnnotation { page, index } = edit { + // Remap surviving comments before generic slot pruning loses their state. + view.comments.annotation_deleted(annots_before, info, (*page, *index)); + } else { + view.comments.document_changed(info); + } } fn comment_page(edit: &Edit) -> Option { diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index b53ac49305..1f205ae759 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -375,6 +375,40 @@ fn save_commits_the_open_text_box() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn moving_pages_cancels_an_in_progress_drawing() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, pdf).unwrap(); + let drawing = + || Some(crate::comments::Gesture::Draw { page: 0, tool: crate::comments::CommentTool::Ink, points: vec![[10.0, 10.0], [30.0, 20.0]] }); + app.views[0].comments.gesture = drawing(); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.views[0].comments.gesture.is_some(), "rotating the page keeps the drawing on it"); + app.views[0].comments.gesture = drawing(); + assert!(app.apply_edit(pdfcraft_engine::Edit::MovePages { pages: vec![1], to: 0 })); + assert!(app.views[0].comments.gesture.is_none(), "the drawing's page index would now be a different page"); +} + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2ff2253d36..78435914ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -59,7 +59,10 @@ mod home; mod hotbar; mod panels; pub mod panes; -pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Spooler, Which as PrintWhich, system_printers}; +pub use print_ui::{ + Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSetup, PrinterSource, Spooler, TwoSidedSource, Which as PrintWhich, system_printers, + system_two_sided, +}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; @@ -420,6 +423,9 @@ pub struct PdfCraftApp { pub options_source: Option, /// What print jobs go to: the system spooler unless a test stands in ([`Spooler`]). pub spooler: Option, + /// Reads the picked printer's two-sided support in the background (the desktop app sets it; + /// see [`TwoSidedSource`]). Without one the dialog goes by the printer list alone. + pub two_sided_source: Option, // A browser has no background print work: the type is empty there. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) print_tasks: print_ui::PrintTasks, diff --git a/crates/ui-egui/src/platform/open_worker.rs b/crates/ui-egui/src/platform/open_worker.rs index 274071ea78..94052f6368 100644 --- a/crates/ui-egui/src/platform/open_worker.rs +++ b/crates/ui-egui/src/platform/open_worker.rs @@ -37,7 +37,7 @@ struct Job { context: OpenContext, name: String, path: Option, - password: Option, + password: Option>, input: Input, reply: Sender, #[cfg(test)] @@ -80,7 +80,7 @@ impl OpenWorker { context: OpenContext, name: String, path: Option, - password: Option, + password: Option>, input: Input, ) -> Result, String> { let (reply, receiver) = mpsc::channel(); @@ -178,7 +178,7 @@ fn prepare(job: Job) -> ResultKind { if matches!(pdfcraft_engine::source_kind(&job.name, &bytes), Some(SourceKind::Image | SourceKind::Text)) { return ResultKind::Conversion(bytes); } - let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref()); + let result = job.context.prepare_open(job.name, job.path, bytes.clone(), job.password.as_deref().map(String::as_str)); ResultKind::Prepared { bytes, result: Box::new(result) } } diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index 2f6630afdb..808f06ed5e 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -30,6 +30,9 @@ pub(crate) struct Updates { /// How many times `on_exit` called [`Updates::exiting`]. #[cfg(test)] exits: u32, + /// Stands in for the close that a finished installation queues in the `Updater`. + #[cfg(test)] + close_queued: bool, } impl Default for Updates { @@ -44,6 +47,8 @@ impl Default for Updates { check_requested: false, #[cfg(test)] exits: 0, + #[cfg(test)] + close_queued: false, } } } @@ -58,6 +63,34 @@ impl Updates { self.exits += 1; } } + + /// Draws the startup prompt and returns what it asks for. `can_start` is false while unsaved + /// work holds back a ready update. + #[cfg(not(target_arch = "wasm32"))] + fn show_prompt(&mut self, ctx: &egui::Context, can_start: bool) -> Action { + #[cfg(test)] + if can_start && std::mem::take(&mut self.close_queued) { + return Action::Close; + } + self.updater.show_if(ctx, can_start) + } + + /// Asks the next frame that may close to close the app, so that a ready update installs. + #[cfg(not(target_arch = "wasm32"))] + fn queue_close(&mut self) { + self.updater.restart_to_update(); + #[cfg(test)] + { + self.close_queued = true; + } + } + + /// Takes the close that a finished installation queued, so a save after Cancel does not close + /// the app. `show_prompt` draws an open startup prompt, so this is for when none is open. + #[cfg(not(target_arch = "wasm32"))] + fn take_queued_close(&mut self, ctx: &egui::Context) { + let _ = self.show_prompt(ctx, true); + } } impl PdfCraftApp { @@ -110,7 +143,7 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { { // The restart helper is scheduled before the app closes, so no update starts over unsaved work. let clean = app.first_dirty().is_none(); - match app.updates.updater.show_if(ctx, clean) { + match app.updates.show_prompt(ctx, clean) { Action::None => {} Action::OpenUrl(url) => app.open_url(&url), Action::Close => { @@ -123,27 +156,167 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { } if app.updates.open { let dialog = CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")); - match dialog.show(ctx, app.updates.updater.status()) { - CheckResultResponse::None => {} - CheckResultResponse::Close => { - app.updates.open = false; - app.updates.updater.dismiss_status(); - } - CheckResultResponse::CheckAgain => app.updates.check_requested = true, - CheckResultResponse::RestartToUpdate => { - app.updates.updater.restart_to_update(); - } - } + let response = dialog.show(ctx, app.updates.updater.status()); + answer_result(app, ctx, response); } } #[cfg(target_arch = "wasm32")] let _ = (app, ctx); } +/// Carries out the answer to the Updates dialog. +#[cfg(not(target_arch = "wasm32"))] +fn answer_result(app: &mut PdfCraftApp, ctx: &egui::Context, response: CheckResultResponse) { + match response { + CheckResultResponse::None => {} + CheckResultResponse::Close => { + app.updates.open = false; + app.updates.updater.dismiss_status(); + } + CheckResultResponse::CheckAgain => app.updates.check_requested = true, + CheckResultResponse::RestartToUpdate => restart_to_update(app, ctx), + } +} + +/// Restart to Update. With unsaved changes the Updates dialog closes and the quit path asks about +/// them; Cancel there leaves the update ready. An open startup prompt keeps its own Restart to +/// Update and save-first note, so the app stays open. +#[cfg(not(target_arch = "wasm32"))] +fn restart_to_update(app: &mut PdfCraftApp, ctx: &egui::Context) { + if app.first_dirty().is_none() { + app.updates.queue_close(); + return; + } + app.updates.open = false; + app.updates.updater.dismiss_status(); + if app.updates.updater.prompt_open() { + return; + } + app.updates.take_queued_close(ctx); + app.request_quit(ctx); +} + #[cfg(test)] mod tests { use super::*; + use crate::CloseRequest; + use crate::editing::SaveTarget; use eframe::App as _; + use pdfcraft_engine::{Edit, FieldValue}; + use std::path::PathBuf; + use std::sync::Arc; + + /// An app with one open document holding a form, and with `dirty` an unsaved change to it. + fn app(dirty: bool) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", None, include_bytes!("../../tests/data/form.pdf").to_vec()).unwrap(); + if dirty { + assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) })); + assert!(app.first_dirty().is_some()); + } + app.updates.open = true; + app + } + + /// An app with an unsaved change to a document in a new temporary folder, which Save writes + /// without asking where. Returns the document's path too. + fn app_with_file(test: &str) -> (PdfCraftApp, PathBuf) { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-updates-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("source.pdf"); + let bytes = include_bytes!("../../tests/data/form.pdf").to_vec(); + std::fs::write(&path, &bytes).unwrap(); + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", Some(path.to_string_lossy().into_owned()), bytes).unwrap(); + assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Changed".into()) })); + app.updates.open = true; + (app, path) + } + + #[test] + fn restart_to_update_with_unsaved_changes_opens_the_save_prompt() { + let mut app = app(true); + let ctx = egui::Context::default(); + answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate); + assert!(!app.updates.open, "the modal Updates dialog closes"); + assert_eq!(app.close_request, Some(CloseRequest::Quit), "the save prompt opens"); + assert!(!app.allow_quit); + app.resolve_close(&ctx, None); + assert!(app.close_request.is_none(), "Cancel closes the prompt"); + assert!(!app.allow_quit, "Cancel keeps the app open"); + } + + #[test] + fn restart_to_update_with_unsaved_changes_dismisses_the_check_result() { + let mut app = app(true); + app.set_update_source(Arc::new(|| Ok(None))); + let ctx = egui::Context::default(); + app.updates.updater.check_now(&ctx); + assert!(!matches!(app.updates.updater.status(), Status::Idle)); + answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate); + assert!(matches!(app.updates.updater.status(), Status::Idle)); + } + + #[test] + fn saving_at_the_restart_prompt_writes_the_file_and_quits() { + let (mut app, path) = app_with_file("save"); + let ctx = egui::Context::default(); + answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate); + app.resolve_close(&ctx, Some(true)); + assert!(app.allow_quit, "the app quits once the document is saved"); + + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("source.pdf", Some(path.to_string_lossy().into_owned()), std::fs::read(&path).unwrap()).unwrap(); + let doc = reopened.session.get(reopened.views[0].id).unwrap(); + let name = doc.form.iter().find(|f| f.name == "name").and_then(|f| f.value.first().cloned()); + assert_eq!(name.as_deref(), Some("Changed"), "the change is in the file"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn saving_after_canceling_the_restart_keeps_the_app_open() { + let (mut app, path) = app_with_file("cancel"); + app.updates.close_queued = true; + let ctx = egui::Context::default(); + answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate); + app.resolve_close(&ctx, None); + assert!(app.save_active(SaveTarget::InPlace)); + assert!(app.first_dirty().is_none()); + dialog(&mut app, &ctx); + assert!(!app.allow_quit, "the save does not close the app"); + assert!(app.close_request.is_none()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn restart_to_update_with_nothing_unsaved_leaves_the_close_to_the_updater() { + let mut app = app(false); + let ctx = egui::Context::default(); + answer_result(&mut app, &ctx, CheckResultResponse::RestartToUpdate); + assert!(app.updates.open, "the dialog stays until the updater closes the app"); + assert!(app.close_request.is_none()); + assert!(!app.allow_quit); + dialog(&mut app, &ctx); + assert!(app.allow_quit, "the next frame closes the app"); + } + + #[test] + fn restart_to_update_with_the_startup_prompt_open_leaves_the_app_open() { + let mut app = app(true); + app.updates.updater.offer(craft_update::Release { + tag: "v99.0.0".into(), + version: craft_update::parse_version("99.0.0"), + name: "PdfCraft 99.0.0".into(), + html_url: "https://github.com/storytold/pdfcraft/releases/tag/v99.0.0".into(), + ..Default::default() + }); + assert!(app.updates.updater.prompt_open()); + answer_result(&mut app, &egui::Context::default(), CheckResultResponse::RestartToUpdate); + assert!(!app.updates.open, "the result dialog closes"); + assert!(app.updates.updater.prompt_open(), "the prompt stays"); + assert!(app.close_request.is_none(), "the prompt carries the restart"); + } #[test] fn on_exit_calls_the_update_exit_hook_once() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d4f5e09be4..9acbc370f5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -356,13 +356,19 @@ pub struct PasswordPrompt { pub(crate) display_override: Option<(canvas::PageLayout, bool)>, } +impl Drop for PasswordPrompt { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.input); + } +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) struct OpenRequest { pub(crate) name: String, pub(crate) path: Option, pub(crate) stamp: Option, pub(crate) input: open_worker::Input, - pub(crate) password: Option, + pub(crate) password: Option>, pub(crate) activate: bool, pub(crate) after_open: Option, pub(crate) recovered: Option, @@ -380,6 +386,12 @@ pub struct UnlockPrompt { pub then: Option<&'static str>, } +impl Drop for UnlockPrompt { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.input); + } +} + impl UnlockPrompt { /// Ask for document `doc`'s permissions password, then run command `then`, if any. pub(crate) fn new(doc: &pdfcraft_engine::Document, then: Option<&'static str>) -> Self { diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 842a5dfc84..4dad8d4c26 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -116,7 +116,9 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, )); let text = if typewriter_edit { c.text } else { c.text.trim_end().to_string() }; match c.kind { - ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }), + ComposerKind::Edit(index) => { + info.annotations.iter().any(|a| a.page == page && a.index == index).then_some(Edit::SetAnnotationContents { page, index, text }) + } ComposerKind::Note => { view.comments.tool_done = true; Some(new_comment(&cx, CommentTool::Note, Shape::Note { at: c.at, icon: NoteIcon::Comment }, text)) diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index cfaa4967ce..6e27e89537 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -740,7 +740,7 @@ impl Gestures<'_> { } /// Delete / Escape handling for comments (only while no text field has focus). -pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { +pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, tool: &mut QuickTool, allowed: bool) { if ctx.egui_wants_keyboard_input() { return; } @@ -756,21 +756,19 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool *tool = QuickTool::Select; } } - if allowed - && cv.selected.is_some() - && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) - && let Some(edit) = delete_selected(cv) - { - view.pending_edit = Some(edit); + if allowed && cv.selected.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + view.pending_edit = delete_selected(cv, info); } } /// The edit that deletes every selected comment (one undo step for several), and clears the /// selection. Later positions go first, so each deletion leaves the earlier ones where they are. -pub(crate) fn delete_selected(cv: &mut CommentView) -> Option { +pub(crate) fn delete_selected(cv: &mut CommentView, info: &DocInfo) -> Option { // A group's member picked on the page, with its group the selection alone, goes by itself, as // Acrobat deletes the member clicked ([`CommentView::target`]). - let mut keys = if cv.also.is_empty() { cv.target().into_iter().collect() } else { cv.selection() }; + let raw = if cv.also.is_empty() { cv.target().into_iter().collect() } else { cv.selection() }; + let mut keys: Vec<(usize, usize)> = + raw.into_iter().filter(|(page, index)| info.annotations.iter().any(|a| a.page == *page && a.index == *index && !a.locked)).collect(); cv.clear_selection(); keys.sort_unstable_by(|a, b| b.cmp(a)); keys.dedup(); diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 4ee49921e4..d8da11fcc3 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -148,7 +148,7 @@ pub(crate) fn context_menu( let (dpage, dindex) = view.comments.target().unwrap_or((page, index)); let deletes = deletable(dpage, dindex) && view.comments.also.iter().all(|&(p, i)| deletable(p, i)); if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() { - action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e))); + action = delete_selected(&mut view.comments, info).map(|e| CanvasAction::Edit(Box::new(e))); ui.close(); } if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 62d6e9db36..acd869aa9c 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -207,6 +207,17 @@ pub struct CommentView { pub tool_done: bool, /// What the inline editor (`editing`) does with the keyboard the next time it is drawn. pub(crate) editor_focus: Option, + /// Snapshot of `(page, index)` comment slot identity from the last `DocInfo`, used to detect + /// when an edit removes or shifts `/Annots` entries under an active selection or editor. + pub(crate) slots: std::collections::HashMap<(usize, usize), CommentSlot>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CommentSlot { + subtype: String, + name: Option, + page_ord: usize, + page_count: usize, } /// A one-off change to the keyboard focus of the Comments panel's inline editor. The editor @@ -221,6 +232,78 @@ pub(crate) enum EditorFocus { } impl CommentView { + /// Initial commenting state for `info`, recording its current annotation slots. + pub(crate) fn from_info(info: &DocInfo) -> Self { + Self { slots: Self::snapshot_slots(info), ..Default::default() } + } + + /// Captures `(page, index)` comment slot identity for every comment in `info`. + fn snapshot_slots(info: &DocInfo) -> std::collections::HashMap<(usize, usize), CommentSlot> { + let mut by_page = std::collections::HashMap::>::new(); + for a in &info.annotations { + by_page.entry(a.page).or_default().push(a.index); + } + for indices in by_page.values_mut() { + indices.sort_unstable(); + indices.dedup(); + } + let mut slots = std::collections::HashMap::with_capacity(info.annotations.len()); + for a in &info.annotations { + let indices = by_page.get(&a.page).map(Vec::as_slice).unwrap_or_default(); + let page_ord = indices.binary_search(&a.index).unwrap_or(0); + let page_count = indices.len(); + slots.insert((a.page, a.index), CommentSlot { subtype: a.subtype.clone(), name: a.name.clone(), page_ord, page_count }); + } + slots + } + + /// Prunes comment state whose `(page, index)` slot was removed or shifted by a document change. + pub(crate) fn document_changed(&mut self, info: &DocInfo) { + let next = Self::snapshot_slots(info); + let page_count = info.pages.len(); + let same_slot = |page: usize, index: usize| -> bool { + let Some(cur) = next.get(&(page, index)) else { + return false; + }; + if self.slots.is_empty() { + return true; + } + self.slots.get(&(page, index)).is_some_and(|old| { + cur.subtype == old.subtype && cur.name == old.name && cur.page_ord == old.page_ord && cur.page_count >= old.page_count + }) + }; + if self.selected.is_some_and(|(p, i)| !same_slot(p, i)) { + self.selected = None; + } + self.also.retain(|&(p, i)| same_slot(p, i)); + if self.selected.is_none() { + self.selected = self.also.pop(); + } + if self.member.is_some_and(|((mp, mi), (gp, gi))| Some((gp, gi)) != self.selected || !same_slot(mp, mi)) { + self.member = None; + } + if self.editing.as_ref().is_some_and(|(p, i, _)| !same_slot(*p, *i)) { + self.editing = None; + self.editor_focus = None; + } + if self.composer.as_ref().is_some_and(|c| match c.kind { + ComposerKind::Edit(i) => !same_slot(c.page, i), + _ => c.page >= page_count, + }) { + self.composer = None; + } + if self.gesture.as_ref().is_some_and(|g| match g { + Gesture::Draw { page, .. } => *page >= page_count, + Gesture::Move { page, index, .. } | Gesture::Resize { page, index, .. } | Gesture::Reshape { page, index, .. } => { + !same_slot(*page, *index) + } + Gesture::Place { .. } => false, + }) { + self.gesture = None; + } + self.slots = next; + } + /// Every selected comment, the one the handles belong to first. pub fn selection(&self) -> Vec<(usize, usize)> { let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect(); @@ -333,24 +416,33 @@ impl CommentView { } } - /// The annotation at `gone` was just deleted; `before` and `after` are the document's + /// The annotation at `gone` was just deleted; `before` and `info` hold the document's /// annotations until then and now. The selection and the inline editor stay on what they /// were on, where it now is (deleting a reply keeps its comment selected), and close when it /// went too. - pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) { - self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k)); - self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect(); - self.member = None; - self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text))); - if self.editing.is_none() { - self.editor_focus = None; - } - // What is typed in a pop-up, and a window being dragged or resized, follow theirs too. + pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], info: &DocInfo, gone: (usize, usize)) { + let after = &info.annotations; let moved = |k: (usize, usize)| after_delete(before, after, gone, k); - self.popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); - self.popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); - self.popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at))); - self.popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size))); + let selected = self.selected.and_then(moved); + let also: Vec<(usize, usize)> = std::mem::take(&mut self.also).into_iter().filter_map(moved).collect(); + let editing = self.editing.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + let focus = self.editor_focus.take(); + // What is typed in a pop-up, and a window being dragged or resized, follow theirs too. + let popup_text = self.popup_text.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + let popup_reply = self.popup_reply.take().and_then(|(p, i, text)| moved((p, i)).map(|(p, i)| (p, i, text))); + let popup_drag = self.popup_drag.and_then(|(p, i, at)| moved((p, i)).map(|(p, i)| (p, i, at))); + let popup_resize = self.popup_resize.and_then(|(p, i, size)| moved((p, i)).map(|(p, i)| (p, i, size))); + // Other transient state still needs conservative pruning; refresh the slot snapshot too. + self.document_changed(info); + self.selected = selected; + self.also = also; + self.member = None; + self.editor_focus = editing.as_ref().and(focus); + self.editing = editing; + self.popup_text = popup_text; + self.popup_reply = popup_reply; + self.popup_drag = popup_drag; + self.popup_resize = popup_resize; } /// Undo or Redo just ran. Either may have put annotations back or taken them away, on any diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 6c3e4ea163..63fa8cc312 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -165,11 +165,17 @@ fn a_kept_comment_is_found_where_a_delete_moved_it() { let changed = vec![listed(0, 0, "something else", None), listed(0, 1, "B", Some((0, 0)))]; assert_eq!(after_delete(&before, &changed, gone, (0, 2)), None); - let mut view = CommentView { selected: Some((0, 2)), editing: Some((0, 3, "typed".into())), ..Default::default() }; - view.annotation_deleted(&before, &after, gone); + let info = DocInfo { annotations: before.clone(), ..Default::default() }; + let mut view = CommentView::from_info(&info); + view.selected = Some((0, 2)); + view.editing = Some((0, 3, "typed".into())); + view.annotation_deleted(&before, &DocInfo { annotations: after.clone(), ..Default::default() }, gone); + view.document_changed(&DocInfo { annotations: after.clone(), ..Default::default() }); assert_eq!((view.selected, view.editing), (Some((0, 0)), Some((0, 1, "typed".into()))), "both follow"); - let mut view = CommentView { editing: Some((0, 4, "typed".into())), editor_focus: Some(EditorFocus::Take), ..Default::default() }; - view.annotation_deleted(&before, &after, gone); + let mut view = CommentView::from_info(&info); + view.editing = Some((0, 4, "typed".into())); + view.editor_focus = Some(EditorFocus::Take); + view.annotation_deleted(&before, &DocInfo { annotations: after.clone(), ..Default::default() }, gone); assert_eq!((view.editing, view.editor_focus), (None, None), "the editor closes with the reply it was open on"); } @@ -554,6 +560,137 @@ fn text_boxes_fit_their_text() { assert!(long[3] - long[1] > 50.0); } +#[test] +fn stale_comment_state_is_cleared_and_delete_never_hits_non_comment_slots() { + let annot = |index: usize, name: &str| Annotation { + page: 0, + index, + subtype: "Text".into(), + rect: [10.0, 10.0, 30.0, 30.0], + color: None, + author: None, + contents: Some(name.into()), + modified: None, + in_reply_to: None, + parent: None, + grouped: false, + name: Some(name.into()), + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + text_box: None, + popup: None, + }; + // Page 0 has a Widget at index 0 (not in `info.annotations`), and Text comments at 1 and 2. + let info = DocInfo { + pages: vec![PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }], + annotations: vec![annot(1, "c1"), annot(2, "c2")], + ..Default::default() + }; + let mut view = DocView::new(pdfcraft_engine::DocId(1), &info, crate::canvas::ViewDefaults::default()); + view.comments.selected = Some((0, 2)); + view.comments.open_editor(0, 2, "draft".into()); + view.comments.composer = Some(Composer { page: 0, at: [10.0, 10.0], kind: ComposerKind::Edit(2), text: "body".into(), focus: false }); + view.comments.gesture = Some(Gesture::Move { page: 0, index: 2, from: pos2(0.0, 0.0) }); + + // Moving/editing in place or appending a comment at index 3 preserves (0, 2) selection/editing/gesture. + let mut appended = info.clone(); + appended.annotations.push(annot(3, "c3")); + view.document_changed(&appended); + assert_eq!(view.comments.selected, Some((0, 2))); + assert!(view.comments.editing.is_some()); + assert!(view.comments.composer.is_some()); + assert!(view.comments.gesture.is_some()); + + // Deleting comment 1 shifts comment 2 down to index 1: stale state at (0, 2) and shifted (0, 1) must clear. + let mut shifted = info.clone(); + shifted.annotations = vec![annot(1, "c2")]; + view.comments.selected = Some((0, 1)); + view.comments.open_editor(0, 1, "for c1, not c2".into()); + view.comments.composer = Some(Composer { page: 0, at: [10.0, 10.0], kind: ComposerKind::Edit(1), text: "for c1".into(), focus: false }); + view.comments.gesture = Some(Gesture::Move { page: 0, index: 1, from: pos2(0.0, 0.0) }); + view.document_changed(&shifted); + assert_eq!(view.comments.selected, None); + assert_eq!(view.comments.editing, None); + assert_eq!(view.comments.composer, None); + assert_eq!(view.comments.gesture, None); + + // Pressing Delete when `selected` holds a widget index (0, 0) or stale index (0, 2) emits no edit. + let ctx = egui::Context::default(); + for bad_index in [0, 2] { + view.comments.selected = Some((0, bad_index)); + let mut input = egui::RawInput::default(); + input.events.push(egui::Event::Key { + key: egui::Key::Delete, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + let mut out = ctx.run_ui(input, |ui| { + let mut tool = QuickTool::Select; + keys(ui.ctx(), &mut view, &shifted, &mut tool, true); + }); + out.textures_delta.clear(); + assert_eq!(view.comments.selected, None); + assert_eq!(view.pending_edit, None, "index {bad_index} must not emit DeleteAnnotation"); + } +} + +/// An unnamed comment has no object name to tell it from the one that slid into its index. +/// Deleting the earlier one still drops selection, editing, the composer and a gesture that +/// pointed at that index. +#[test] +fn an_unnamed_shift_clears_state_for_the_old_index() { + let annot = |index: usize| Annotation { + page: 0, + index, + subtype: "Text".into(), + rect: [10.0, 10.0, 30.0, 30.0], + color: None, + author: None, + contents: None, + modified: None, + in_reply_to: None, + parent: None, + grouped: false, + name: None, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + text_box: None, + popup: None, + }; + let info = DocInfo { + pages: vec![ + PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }, + PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }, + ], + annotations: vec![annot(1), annot(2)], + ..Default::default() + }; + let mut view = DocView::new(pdfcraft_engine::DocId(1), &info, crate::canvas::ViewDefaults::default()); + view.comments.selected = Some((0, 1)); + view.comments.open_editor(0, 1, "the first note".into()); + view.comments.composer = Some(Composer { page: 0, at: [10.0, 10.0], kind: ComposerKind::Edit(1), text: "the first note".into(), focus: false }); + view.comments.gesture = Some(Gesture::Move { page: 0, index: 1, from: pos2(0.0, 0.0) }); + let mut shifted = info.clone(); + shifted.annotations = vec![annot(1)]; + view.document_changed(&shifted); + assert_eq!(view.comments.selected, None); + assert_eq!(view.comments.editing, None); + assert_eq!(view.comments.composer, None); + assert_eq!(view.comments.gesture, None); +} + /// Review on #4358: a Replace Text group is selected by its caret, the group's primary (ISO /// 32000-2 §12.5.6.2), and Make Current Properties Default on it updates the Replace Text tool. A /// caret of its own (Insert Text) still updates the Insert Text tool. diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index ddb4fdf88a..3f18348598 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -275,29 +275,30 @@ impl MoveBackground { return; } let r = screen_rect(xf, info, page, from); + let base = self.settling.map_or(r, |s| screen_rect(xf, info, page, s)); let to = if let Some((_, grab, start)) = cv.grab { let Some(p) = pointer else { return }; - dragged(r, grab, p - start, self.picture) - } else if let Some(to) = self.settling { - let to = screen_rect(xf, info, page, to); + dragged(base, grab, p - start, self.picture) + } else if self.settling.is_some() { // A moved text box keeps its top edge (its height follows its lines). - if self.picture { to } else { r.translate(to.left_top() - r.left_top()) } + if self.picture { base } else { r.translate(base.left_top() - r.left_top()) } } else { return; }; let painter = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); - // A pixel around the box, so its edges come along. + // A pixel around the box, so its edges come along. Clip the source to the page raster so + // an item extending past the page border never samples clamped edge texels (#3938, #4477). let (r, to) = (r.expand(1.0), to.expand(1.0)); + let clipped = r.intersect(xf.rect); + if !clipped.is_positive() || r.width() <= 0.0 || r.height() <= 0.0 { + return; + } + let map = |p: Pos2| Pos2::new(to.min.x + (p.x - r.min.x) / r.width() * to.width(), to.min.y + (p.y - r.min.y) / r.height() * to.height()); let mut mesh = egui::Mesh::with_texture(item.id()); - for (a, b) in [ - (r.left_top(), to.left_top()), - (r.right_top(), to.right_top()), - (r.right_bottom(), to.right_bottom()), - (r.left_bottom(), to.left_bottom()), - ] { + for a in [clipped.left_top(), clipped.right_top(), clipped.right_bottom(), clipped.left_bottom()] { let (u, v) = xf.screen_to_norm(a); - mesh.vertices.push(egui::epaint::Vertex { pos: b, uv: Pos2::new(u, v), color: Color32::WHITE }); + mesh.vertices.push(egui::epaint::Vertex { pos: map(a), uv: Pos2::new(u, v), color: Color32::WHITE }); } mesh.add_triangle(0, 1, 2); mesh.add_triangle(0, 2, 3); @@ -344,7 +345,11 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { Grab::Corner(left, top) => { let (mut dx, mut dy) = (if left { -d.x } else { d.x }, if top { -d.y } else { d.y }); if keep_aspect && r.width() > 0.0 && r.height() > 0.0 { - let k = ((r.width() + dx) / r.width()).max((r.height() + dy) / r.height()).max(0.05); + // Use the axis with the larger relative pointer change so shrinking along one axis + // does not freeze when the other axis has not moved inward (#4477). + let (rx, ry) = (dx / r.width(), dy / r.height()); + let delta = if rx.abs() > ry.abs() { rx } else { ry }; + let k = (1.0 + delta).max(0.05); dx = r.width() * k - r.width(); dy = r.height() * k - r.height(); } @@ -373,7 +378,8 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect(); let selected = view.content.selected.filter(|s| s.0 == page).and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)); if let Some((index, a)) = selected - && let Some((_, left, top)) = handles(screen_rect(xf, info, page, a.content.rect())).into_iter().find(|(c, ..)| c.distance(p) <= 6.0) + && let Some((_, left, top)) = + handles(screen_rect(xf, info, page, a.content.rect()).expand(1.0)).into_iter().find(|(c, ..)| c.distance(p) <= 6.0) { let icon = if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }; return Some(Hit::new(Owner::Added { index: *index, corner: Some((left, top)) }, Layer::Handle, icon)); @@ -1596,4 +1602,14 @@ mod tests { assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too"); assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits"); } + + /// #4477: dragging a corner handle predominantly inward on one axis must still shrink an image + /// proportionally instead of freezing because the other axis did not shrink. + #[test] + fn corner_resize_shrinks_image_when_dragged_inward_along_one_axis() { + let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0)); + let shrunk = dragged(r, Grab::Corner(false, false), egui::vec2(-40.0, 2.0), true); + assert!((shrunk.width() - 60.0).abs() < 1e-3, "width shrinks by 40%: {shrunk:?}"); + assert!((shrunk.height() - 30.0).abs() < 1e-3, "height shrinks proportionally by 40%: {shrunk:?}"); + } } diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index ba3601d0da..c8e1294854 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -528,12 +528,19 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture for r in blank { clipped.rect_filled(*r, CornerRadius::ZERO, Color32::WHITE); } + // The raster has no pixels beyond the page. Clip the source and its corresponding + // destination together, rather than stretching clamped edge texels into the preview. + let source = from.intersect(xf.rect); + if !source.is_positive() || !from.is_positive() { + return; + } + let target = egui::emath::RectTransform::from_to(from, to).transform_rect(source); mapped_picture( &clipped, xf, tex, - [from.left_top(), from.right_top(), from.right_bottom(), from.left_bottom()], - [to.left_top(), to.right_top(), to.right_bottom(), to.left_bottom()], + [source.left_top(), source.right_top(), source.right_bottom(), source.left_bottom()], + [target.left_top(), target.right_top(), target.right_bottom(), target.left_bottom()], ); } diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs index 994fbaa8b5..d184506948 100644 --- a/crates/ui-egui/src/tools/prepare/draft.rs +++ b/crates/ui-egui/src/tools/prepare/draft.rs @@ -141,6 +141,9 @@ pub struct FieldDraft { pub format: Format, pub validate: Validate, pub calculate: Calculate, + /// Calculate tab: the last edit of the custom script was refused for its length (said until + /// the next edit is taken, another calculation is chosen, or the dialog is opened again). + pub script_refused: bool, /// Every other field's name (the Calculate tab picks from them). pub others: Vec, /// Actions tab: each trigger's action, and the one being added. @@ -210,7 +213,13 @@ impl FieldDraft { check_style: None, format: f.actions.format.clone(), validate: f.actions.validate.clone(), - calculate: f.actions.calculate.clone(), + calculate: match (&f.actions.calculate, &f.actions.scripts.calculate) { + // A script that is no sum or notation is the field's custom calculation script: + // shown as one, and written back only when it is changed (#6630). + (Calculate::None, Some(js)) => Calculate::Custom(js.clone()), + (c, _) => c.clone(), + }, + script_refused: false, others: Vec::new(), actions: Vec::new(), new_action: ActionDraft::default(), diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index 04831353bd..b3a9052401 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -325,17 +325,30 @@ pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::them }); } +/// The longest custom calculation script the Calculate tab offers to edit. A file can carry far +/// more (forms read up to 64 MiB of script), and laying that out in a text box every frame would +/// hang the dialog; of a longer one the start is shown, read-only, and the script is kept as it is +/// unless another calculation is chosen. The JavaScript engine runs no script past 256 KiB either. +const MAX_EDITED_SCRIPT: usize = 256 * 1024; + pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { #[derive(PartialEq, Clone, Copy)] enum Mode { None, Simple, Notation, + Custom, } + // Choosing Custom again brings back the script the field had, not an empty box. + let custom = |d: &FieldDraft| match d.original.as_ref().as_ref().map(|o| &o.calculate) { + Some(Calculate::Custom(js)) => Calculate::Custom(js.clone()), + _ => Calculate::Custom(String::new()), + }; let mode = match d.calculate { Calculate::None => Mode::None, Calculate::Simple { .. } => Mode::Simple, Calculate::Notation(_) => Mode::Notation, + Calculate::Custom(_) => Mode::Custom, }; let mut m = mode; widgets::radio_value(ui, &mut m, Mode::None, tl!("Value is not calculated")); @@ -345,6 +358,7 @@ pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::th Mode::None => Calculate::None, Mode::Simple => Calculate::Simple { op: CalcOp::Sum, fields: Vec::new() }, Mode::Notation => Calculate::Notation(String::new()), + Mode::Custom => custom(d), }; } if let Calculate::Simple { op, fields } = &mut d.calculate { @@ -383,4 +397,58 @@ pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::th ui.add(crate::widgets::block(expr).hint_text("Price * Quantity").desired_rows(2).desired_width(420.0)); ui.label(egui::RichText::new(tl!("Field names with + - * / and parentheses; put \\ before spaces in names.")).small().color(t.text_faint)); } + let mut m3 = match d.calculate { + Calculate::Custom(_) => Mode::Custom, + _ => m2, + }; + let radio = widgets::radio_value(ui, &mut m3, Mode::Custom, tl!("Custom calculation script:")); + if radio.clicked() && !matches!(d.calculate, Calculate::Custom(_)) { + d.calculate = custom(d); + } + if !matches!(d.calculate, Calculate::Custom(_)) { + d.script_refused = false; + } + if let Calculate::Custom(js) = &mut d.calculate { + // The box is named by its radio, and a note about it is its description too, so a screen + // reader on the box hears it. + let script_box = |ui: &mut egui::Ui, text: &mut dyn egui::TextBuffer| { + let edit = crate::widgets::block(text).code_editor().hint_text("event.value = …;").desired_rows(6).desired_width(420.0); + let scroll = egui::ScrollArea::vertical().id_salt("calc-script").max_height(180.0); + scroll.show(ui, |ui| ui.add(edit.char_limit(MAX_EDITED_SCRIPT + 1)).labelled_by(radio.id).id).inner + }; + let describe = |ui: &egui::Ui, id: egui::Id, note: &str| { + let note = note.to_owned(); + ui.ctx().accesskit_node_builder(id, |node| node.set_description(note)); + }; + if js.len() > MAX_EDITED_SCRIPT { + // Too long to edit: its start is shown, read-only (selectable, so it can be copied). + let kib = (js.len() / 1024).to_string(); + let note = + crate::i18n::fmt(tl!("This script is too long to edit here ({kib} KiB); its start is shown. It is kept as it is."), &[("kib", &kib)]); + let mut start: &str = &js[..js.floor_char_boundary(MAX_EDITED_SCRIPT)]; + let id = script_box(ui, &mut start); + describe(ui, id, ¬e); + ui.label(egui::RichText::new(note).small().color(t.text_faint)); + } else { + // The box edits a copy: an edit that would take the script past the limit (a large + // paste) is refused and said so, rather than hiding the box the user is typing in. + // The box itself takes at most one character past the limit, so a huge paste is cut + // there before it is laid out, and is still over the limit to refuse. The limit is on + // bytes (`len`), as the JavaScript engine's is; the cut counts characters, which is + // what laying out costs. + let mut draft = js.clone(); + let id = script_box(ui, &mut draft); + if draft != *js { + d.script_refused = draft.len() > MAX_EDITED_SCRIPT; + if !d.script_refused { + *js = draft; + } + } + if d.script_refused { + let refusal = tl!("A script edited here can be at most 256 KiB long; that change wasn't made."); + describe(ui, id, refusal); + ui.colored_label(ui.visuals().error_fg_color, refusal); + } + } + } } diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index 299f66329c..56410eeb33 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -90,6 +90,15 @@ impl Default for NewIdDraft { } } +impl Drop for NewIdDraft { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.password.zeroize(); + self.confirm.zeroize(); + self.file_password.zeroize(); + } +} + /// What a visible signature shows on its left (Acrobat: Configure Signature Appearance ▸ Text, /// Draw or Image). The one last signed with is remembered. #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] @@ -152,6 +161,13 @@ impl SignDraft { } } +impl Drop for SignDraft { + fn drop(&mut self) { + use zeroize::Zeroize as _; + self.password.zeroize(); + } +} + /// What a page asks of the signing flow. #[derive(Clone, Debug, Default, PartialEq)] pub struct SignView { @@ -426,51 +442,57 @@ impl PdfCraftApp { /// Configure New Digital ID ▸ Create: a self-signed ID saved as a .p12 next to the app data /// (or in `export_dir_override`). Returns its list index. fn create_digital_id(&mut self) -> Result { - let Some(d) = self.sign_draft.as_ref().map(|d| d.new_id.clone()) else { return Err("nothing to create".into()) }; - if d.name.trim().is_empty() { - return Err("Enter a name.".into()); - } - // The message is a catalogue key, so it spells the number out; keep the two in step. - const _: () = assert!(sign::pkcs12::MIN_PASSWORD_CHARS == 8); - if d.password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS { - return Err("The password must have at least 8 characters.".into()); - } - if d.password != d.confirm { - return Err("The passwords do not match.".into()); - } - let key = match KEY_ALGORITHMS[d.key.min(KEY_ALGORITHMS.len() - 1)].1 { - "rsa3072" => PrivateKey::generate_rsa(3072), - "rsa4096" => PrivateKey::generate_rsa(4096), - "p256" => PrivateKey::generate_p256(), - _ => PrivateKey::generate_rsa(2048), - } - .map_err(|e| e.to_string())?; - let now = self.session.now_secs(); - let dn = Name::build(&d.name, &d.unit, &d.organization, &d.email, &d.country); - let serial = sign::keys::DigestAlg::Sha256.digest(&[d.name.as_bytes(), &now.to_be_bytes()])[..8].to_vec(); - let cert = Certificate::self_signed(&dn, &key, sign::Time::from_unix(now), 5, &serial).map_err(|e| e.to_string())?; - let id = DigitalId { key, certificate: cert.clone(), chain: Vec::new(), friendly_name: Some(d.name.trim().to_string()) }; - let p12 = sign::pkcs12::write(&id, &d.password).map_err(|e| e.to_string())?; - let dir = match &self.export_dir_override { - Some(p) => Some(PathBuf::from(p)), - None => id_dir(), - } - .ok_or("There is no folder to save the digital ID in.")?; - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let stem: String = d.name.trim().chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = save_new_id_file(&dir, &stem, &p12).map_err(|e| e.to_string())?; + let (path, cert) = { + let Some(d) = self.sign_draft.as_ref().map(|d| &d.new_id) else { return Err("nothing to create".into()) }; + if d.name.trim().is_empty() { + return Err("Enter a name.".into()); + } + // The message is a catalogue key, so it spells the number out; keep the two in step. + const _: () = assert!(sign::pkcs12::MIN_PASSWORD_CHARS == 8); + if d.password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS { + return Err("The password must have at least 8 characters.".into()); + } + if d.password != d.confirm { + return Err("The passwords do not match.".into()); + } + let key = match KEY_ALGORITHMS[d.key.min(KEY_ALGORITHMS.len() - 1)].1 { + "rsa3072" => PrivateKey::generate_rsa(3072), + "rsa4096" => PrivateKey::generate_rsa(4096), + "p256" => PrivateKey::generate_p256(), + _ => PrivateKey::generate_rsa(2048), + } + .map_err(|e| e.to_string())?; + let now = self.session.now_secs(); + let dn = Name::build(&d.name, &d.unit, &d.organization, &d.email, &d.country); + let serial = sign::keys::DigestAlg::Sha256.digest(&[d.name.as_bytes(), &now.to_be_bytes()])[..8].to_vec(); + let cert = Certificate::self_signed(&dn, &key, sign::Time::from_unix(now), 5, &serial).map_err(|e| e.to_string())?; + let id = DigitalId { key, certificate: cert.clone(), chain: Vec::new(), friendly_name: Some(d.name.trim().to_string()) }; + let p12 = sign::pkcs12::write(&id, &d.password).map_err(|e| e.to_string())?; + let dir = match &self.export_dir_override { + Some(p) => Some(PathBuf::from(p)), + None => id_dir(), + } + .ok_or("There is no folder to save the digital ID in.")?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let stem: String = d.name.trim().chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let path = save_new_id_file(&dir, &stem, &p12).map_err(|e| e.to_string())?; + (path, cert) + }; Ok(self.add_digital_id(&path.to_string_lossy(), &cert)) } /// Configure New Digital ID ▸ Use a Digital ID from a file. fn import_digital_id(&mut self) -> Result { - let Some(d) = self.sign_draft.as_ref().map(|d| d.new_id.clone()) else { return Err("nothing to import".into()) }; - let bytes = std::fs::read(&d.file).map_err(|e| format!("{}: {e}", d.file))?; - let id = sign::pkcs12::open(&bytes, &d.file_password).map_err(|e| match e { - sign::SignError::WrongPassword => "The password is incorrect.".to_string(), - e => e.to_string(), - })?; - Ok(self.add_digital_id(&d.file, &id.certificate)) + let (file, cert) = { + let Some(d) = self.sign_draft.as_ref().map(|d| &d.new_id) else { return Err("nothing to import".into()) }; + let bytes = std::fs::read(&d.file).map_err(|e| format!("{}: {e}", d.file))?; + let id = sign::pkcs12::open(&bytes, &d.file_password).map_err(|e| match e { + sign::SignError::WrongPassword => "The password is incorrect.".to_string(), + e => e.to_string(), + })?; + (d.file.clone(), id.certificate) + }; + Ok(self.add_digital_id(&file, &cert)) } /// Sign as …: open the ID, sign, save the signed file (Save As), and show it. @@ -481,7 +503,7 @@ impl PdfCraftApp { return Err(String::new()); } let Some((_, doc_id)) = self.active_ids() else { return Err("no document".into()) }; - let d = self.sign_draft.clone().ok_or("nothing to sign")?; + let d = self.sign_draft.as_ref().ok_or("nothing to sign")?; let entry = d.selected.and_then(|i| self.digital_ids.get(i)).filter(|e| e.unusable.is_none()).cloned().ok_or("Choose a digital ID.")?; let id = if entry.path.starts_with("keychain:") { keychain_id(&entry.path)? @@ -781,8 +803,10 @@ mod tests { let mut draft = SignDraft::new(0, None, None, None, &[]); // P-256 keeps the test fast. let key = KEY_ALGORITHMS.iter().position(|k| k.1 == "p256").unwrap(); - draft.new_id = - NewIdDraft { name: "Grace Hopper".into(), key, password: "secret12".into(), confirm: "secret12".into(), ..NewIdDraft::default() }; + draft.new_id.name = "Grace Hopper".into(); + draft.new_id.key = key; + draft.new_id.password = "secret12".into(); + draft.new_id.confirm = "secret12".into(); app.sign_draft = Some(draft); let i = app.create_digital_id()?; Ok(PathBuf::from(&app.digital_ids[i].path)) @@ -1036,4 +1060,74 @@ mod tests { assert_eq!(bar(&[signature(None, Valid)]), Some(("circle-check", "Signed and all signatures are valid."))); assert_eq!(bar(&[SignatureInfo { signed: false, ..signature(None, Invalid) }]), None); } + + #[test] + fn dismissing_or_stepping_back_in_the_sign_dialog_clears_passwords() { + use egui_kittest::{Harness, kittest::Queryable}; + + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.digital_ids = vec![listed("/ids/grace.p12", "Grace Hopper")]; + let mut draft = SignDraft::new(0, None, None, None, &app.digital_ids); + draft.step = SignStep::SignAs; + draft.password = "secret12".into(); + app.sign_draft = Some(draft); + app.dialog = Some(crate::Dialog::Sign); + app + }); + h.run_steps(2); + + // Stepping back from SignAs to Choose clears the typed digital ID password. + h.get_by_label("Back").click(); + h.run_steps(2); + assert_eq!(h.state().sign_draft.as_ref().unwrap().step, SignStep::Choose); + assert!(h.state().sign_draft.as_ref().unwrap().password.is_empty(), "Back from SignAs must clear password"); + + // Stepping back from Configure to Choose resets the new-ID password fields. + { + let d = h.state_mut().sign_draft.as_mut().unwrap(); + d.step = SignStep::Configure; + d.new_id.password = "newsecret12".into(); + d.new_id.confirm = "newsecret12".into(); + d.new_id.file_password = "filesecret12".into(); + } + h.run_steps(2); + h.get_by_label("Back").click(); + h.run_steps(2); + let d = h.state().sign_draft.as_ref().unwrap(); + assert_eq!(d.step, SignStep::Choose); + assert!(d.new_id.password.is_empty() && d.new_id.confirm.is_empty() && d.new_id.file_password.is_empty()); + + // Dismissing the modal with Escape clears sign_draft completely. + h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().sign_draft.is_none(), "sign_draft must be cleared when Dialog::Sign is dismissed"); + + // Importing a digital ID file and signing work by borrowing the draft in place. + let dir = scratch(); + let saved = create_in(&dir).unwrap(); + let mut import_app = PdfCraftApp::new(); + let mut import_draft = SignDraft::new(0, None, None, None, &[]); + import_draft.new_id.create = false; + import_draft.new_id.file = saved.to_string_lossy().into_owned(); + import_draft.new_id.file_password = "secret12".into(); + import_app.sign_draft = Some(import_draft); + assert_eq!(import_app.import_digital_id(), Ok(0)); + assert_eq!(import_app.digital_ids[0].name, "Grace Hopper"); + + let bytes = pdfcraft_engine::Session::new().create_blank(200.0, 200.0, 1).unwrap(); + import_app.open_bytes("unsigned.pdf", None, bytes.as_ref().clone()).unwrap(); + let out_pdf = dir.join("signed.pdf"); + import_app.save_override = Some(out_pdf.to_string_lossy().into_owned()); + let mut sign_draft = SignDraft::new(0, None, None, None, &import_app.digital_ids); + sign_draft.step = SignStep::SignAs; + sign_draft.password = "secret12".into(); + import_app.sign_draft = Some(sign_draft); + assert_eq!(import_app.finish_signing(), Ok(())); + assert!(out_pdf.exists()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index 61e9aac042..2e5a319573 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -206,6 +206,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { error(ui, &d.error); ui.horizontal(|ui| { if !app.digital_ids.is_empty() && widgets::pill_button(ui, tl!("Back"), false).clicked() { + d.new_id = NewIdDraft::default(); d.step = SignStep::Choose; d.error = None; } @@ -471,6 +472,7 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { error(ui, &d.error); ui.horizontal(|ui| { if widgets::pill_button(ui, tl!("Back"), false).clicked() { + zeroize::Zeroize::zeroize(&mut d.password); d.step = SignStep::Choose; d.error = None; } diff --git a/crates/ui-egui/src/workflows/combine_ui/files.rs b/crates/ui-egui/src/workflows/combine_ui/files.rs index 85634c2aff..9c8b3d25ed 100644 --- a/crates/ui-egui/src/workflows/combine_ui/files.rs +++ b/crates/ui-egui/src/workflows/combine_ui/files.rs @@ -128,6 +128,12 @@ pub(crate) struct Preview { #[derive(Clone, Default)] pub struct Secret(pub(super) String); +impl Drop for Secret { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.0); + } +} + impl std::fmt::Debug for Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(if self.0.is_empty() { "\"\"" } else { "…" }) diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 5033fcd51c..2ac0d7b127 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -82,6 +82,7 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + Request::OpenAttachment { name, open } => self.open_script_attachment(id, &name, open), Request::Highlight(on) => self.script_highlight(id, on), Request::ShowPanel(p) => { let panel = match p { @@ -114,6 +115,23 @@ impl PdfCraftApp { } } + /// A form button's `exportDataObject`: the attached file `name` (its key in the document's + /// embedded files, else its file name), opened in a new tab when the script asks to open a + /// PDF, and otherwise offered to save. Other files aren't launched in other programs. + fn open_script_attachment(&mut self, id: DocId, name: &str, open: bool) { + let Some(doc) = self.session.get(id) else { return }; + let keyed = |a: &pdfcraft_render::Attachment| matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key } if key == name); + let index = doc.info.attachments.iter().position(keyed).or_else(|| doc.info.attachments.iter().position(|a| a.name == name)); + let Some(i) = index else { + // The name is the form's own text: shortened, as file names are elsewhere. + let shown = crate::dialogs::shorten_middle(name, 80); + self.notify_fmt("The form asks for the attached file {name}, which this document doesn't have.", &[("name", &shown)]); + return; + }; + // Whether it is a PDF is decided by its bytes, not its name (`attachment_action`). + self.attachment_action(id, i, open); + } + /// A form's script turned field highlighting on or off (`app.runtimeHighlight`): as the /// Highlight fields button does, for document `id` and the next ones. pub(crate) fn script_highlight(&mut self, id: DocId, on: bool) { diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index ffed67f50d..2f43f74ddd 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -13,9 +13,12 @@ use crate::{PdfCraftApp, widgets}; mod body; mod draft; +mod setups; pub(crate) use body::body; -pub use draft::{Handling, PrintDraft, Which}; +pub use draft::{CUSTOM_SCALE, Handling, IMAGE_DPI, PrintDraft, Which}; +pub use setups::PrinterSetup; +pub(crate) use setups::{from_json as setups_from_json, to_json as setups_to_json}; /// The preview pane, and the paper inside it (12 pt inset on each side). const PREVIEW_W: f32 = 320.0; @@ -25,10 +28,15 @@ pub(crate) const PREVIEW_BOX: (f32, f32) = (PREVIEW_W - 2.0 * PREVIEW_INSET, PRE /// Label printing reads the printed area of at most this many pages (each is a quick render on /// the UI thread); further pages print whole. pub(crate) const LABEL_CROP_PAGES: usize = 50; +/// Two-sided reads running at once. A driver Windows can't reach holds its read for about 48 s, +/// so picking printer after printer could otherwise start a thread for each; past this, the +/// printer picked is asked once a read finishes. +#[cfg(not(target_arch = "wasm32"))] +const TWO_SIDED_READS: usize = 4; /// Lists the printers off the UI thread (the desktop app sets [`PdfCraftApp::printer_source`]): -/// on Windows each driver's capabilities are read for its two-sided support, and CUPS' driverless discovery can wait on -/// the network. Without a source the dialog reads them as it opens, so tests stay deterministic. +/// CUPS' driverless discovery can wait on the network. Without a source the dialog reads them as +/// it opens, so tests stay deterministic. pub type PrinterSource = std::sync::Arc Vec + Send + Sync>; /// Where a printer's driver options come from: the spooler's (`spool::printer_options_in`, in the @@ -39,9 +47,21 @@ pub type OptionsSource = std::sync::Arc Vec Result + Send + Sync>; -/// The system spooler's printers, as the desktop app's [`PrinterSource`]. +/// The system spooler's printers, as the desktop app's [`PrinterSource`]: its queues only, with +/// no driver asked anything, so the list never waits on a printer (#2192). pub fn system_printers() -> PrinterSource { - std::sync::Arc::new(spool::printers) + std::sync::Arc::new(spool::printer_queues) +} + +/// Reads whether one printer's driver takes two-sided settings, off the UI thread, for the +/// printer picked (the desktop app sets [`PdfCraftApp::two_sided_source`]). The printer list +/// leaves it unknown: reading a driver Windows can't reach takes it about 48 s, so the list +/// doesn't wait for every printer's (#2192). +pub type TwoSidedSource = std::sync::Arc Option + Send + Sync>; + +/// The system spooler's answer, as the desktop app's [`TwoSidedSource`]. +pub fn system_two_sided() -> TwoSidedSource { + std::sync::Arc::new(spool::two_sided) } /// The Print dialog's work in the background: the printer list being read, and the jobs being @@ -50,6 +70,9 @@ pub fn system_printers() -> PrinterSource { pub(crate) struct PrintTasks { #[cfg(not(target_arch = "wasm32"))] printers: Option>>, + /// Two-sided support being read, by printer. + #[cfg(not(target_arch = "wasm32"))] + two_sided: Vec<(String, std::sync::mpsc::Receiver>)>, #[cfg(not(target_arch = "wasm32"))] jobs: Vec, /// The progress card is showing a print job (it is shared with other long jobs). @@ -79,7 +102,7 @@ fn listed_printers() -> Vec { } #[cfg(not(target_arch = "wasm32"))] { - spool::printers() + spool::printer_queues() } } @@ -111,9 +134,10 @@ enum JobNews { Done(Result), } -/// The printer the dialog preselects: the system default, else the first. -fn default_printer(printers: &[spool::Printer]) -> Option { - printers.iter().find(|p| p.default).or(printers.first()).map(|p| p.name.clone()) +/// The printer the dialog preselects: the one printed to last while it is listed (#2001), else +/// the system default, else the first. +fn default_printer(d: &PrintDraft, printers: &[spool::Printer]) -> Option { + d.last_printer(printers).or_else(|| printers.iter().find(|p| p.default)).or(printers.first()).map(|p| p.name.clone()) } /// Whether `printer` can still be chosen from `printers`: Save as PDF (`None`) always can. @@ -149,21 +173,49 @@ impl PdfCraftApp { } _ => (listed_printers(), false), }; - let default = default_printer(&printers); let current = self.views[i].current; let selected: Vec = self.views[i].selected.iter().copied().collect(); - let keep = std::mem::take(&mut self.print_draft); + let mut keep = std::mem::take(&mut self.print_draft); + // Leaving Label gives back the paper it replaced (#3564), before the layout resets below. + if keep.handling == Handling::Label { + keep.set_handling(Handling::Size); + } + let default = default_printer(&keep, &printers); // The printer picked last time, Save as PDF included, while it is still listed (#3537). let picked = keep.printer_picked && offered(keep.printer.as_deref(), &printers); let printer = if picked { keep.printer.clone() } else { default }; - // Picked pages are what Print is for (Acrobat's "Selected pages"); without any, a choice - // left over from another document falls back to the whole document. - let which = match (selected.is_empty(), keep.which) { - (false, _) => Which::Selected, - (true, Which::Selected) => Which::All, - (true, other) => other, - }; + // A printer other than last time's brings back what it was last printed with (#2001); + // the same one keeps this session's choices. + let changed = printer != keep.printer; + // Picked pages are what Print is for (Acrobat's "Selected pages"); without any, the whole + // document. + let which = if selected.is_empty() { Which::All } else { Which::Selected }; + // Copies, pages, the layout with its own options and orientation are this job's, and start + // from the defaults each time the dialog opens: ISO 32000-2 Table 147 initialises them per + // document when the print dialog opens (NumCopies, "typically 1"; PrintPageRange). The + // choices that describe how this computer prints (two-sided, grayscale, collate, sizing, + // Comments & forms, a booklet's binding) carry over, and are remembered across restarts + // (#2001). + let fresh = PrintDraft::default(); self.print_draft = PrintDraft { + copies: fresh.copies, + range: fresh.range, + subset: fresh.subset, + reverse: fresh.reverse, + handling: fresh.handling, + per_sheet: fresh.per_sheet, + custom_grid: fresh.custom_grid, + cols: fresh.cols, + rows: fresh.rows, + order: fresh.order, + border: fresh.border, + auto_rotate: fresh.auto_rotate, + booklet_subset: fresh.booklet_subset, + poster_scale: fresh.poster_scale, + overlap: fresh.overlap, + cut_marks: fresh.cut_marks, + label_flip: fresh.label_flip, + orientation: fresh.orientation, crops: Vec::new(), crop_pages: None, printers, @@ -177,6 +229,9 @@ impl PdfCraftApp { which, ..keep }; + if changed { + self.print_draft.apply_setup(); + } self.dialog = Some(crate::Dialog::Print); } @@ -318,12 +373,17 @@ impl PdfCraftApp { // A picked printer that is no longer listed falls back to the default too. if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; - d.printer = default_printer(&printers); + let printer = default_printer(d, &printers); + if printer != d.printer { + d.printer = printer; + d.apply_setup(); + } } d.printers = printers; d.printers_loading = false; } } + self.poll_two_sided(); let mut finished = Vec::new(); self.print_tasks.jobs.retain_mut(|job| { loop { @@ -382,6 +442,48 @@ impl PdfCraftApp { } } + /// Pick up two-sided answers that arrived, and ask for the picked printer's when the list + /// doesn't say, nobody has asked yet and fewer than [`TWO_SIDED_READS`] reads are running. + #[cfg(not(target_arch = "wasm32"))] + fn poll_two_sided(&mut self) { + let d = &mut self.print_draft; + self.print_tasks.two_sided.retain(|(printer, rx)| match rx.try_recv() { + Ok(two_sided) => { + d.two_sided.insert(printer.clone(), two_sided); + false + } + Err(std::sync::mpsc::TryRecvError::Empty) => true, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + d.two_sided.insert(printer.clone(), None); + false + } + }); + if self.dialog != Some(crate::Dialog::Print) { + return; + } + let Some(source) = self.two_sided_source.clone() else { return }; + let Some(printer) = d.printers.iter().find(|p| d.printer.as_ref() == Some(&p.name)) else { return }; + if printer.two_sided.is_some() + || d.two_sided.contains_key(&printer.name) + || self.print_tasks.two_sided.iter().any(|(n, _)| *n == printer.name) + || self.print_tasks.two_sided.len() >= TWO_SIDED_READS + { + return; + } + let name = printer.name.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let ctx = self.ctx.clone(); + let asked = name.clone(); + std::thread::spawn(move || { + // The receiver may be gone (the app quit): nothing to report to then. + let _ = tx.send(source(&asked)); + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + }); + self.print_tasks.two_sided.push((name, rx)); + } + fn report_print_job(&mut self, printer: &str, result: Result) { match result { Ok(msg) if msg.is_empty() => self.notify(crate::i18n::fmt(tl!("Sent to {printer}"), &[("printer", printer)])), @@ -416,7 +518,8 @@ impl PdfCraftApp { return false; } }; - match self.print_draft.printer.clone() { + let to_printer = self.print_draft.printer.is_some(); + let sent = match self.print_draft.printer.clone() { #[cfg(target_arch = "wasm32")] Some(printer) if printer == crate::web_print::BROWSER_PRINTER => { let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (f64::from(p.width), f64::from(p.height))).collect(); @@ -439,7 +542,12 @@ impl PdfCraftApp { self.send_to_printer(printer, bytes, job) } None => self.save_print_pdf(&name, bytes), + }; + // What this printer printed with is what it starts with next time (#2001). + if sent && to_printer { + self.print_draft.remember_setup(); } + sent } /// Spool a job off the UI thread: on Windows every sheet is rendered at the printer's @@ -684,6 +792,18 @@ mod tests { } } + /// #5806: with Print as image, grayscale makes the pictures grey, so the job doesn't ask the + /// driver for it as well; a driver that keeps no grayscale setting then still prints. + #[test] + fn grey_pictures_dont_ask_the_driver_for_grayscale() { + let vector = PrintDraft { grayscale: true, ..PrintDraft::default() }; + assert!(vector.job("a.pdf").grayscale, "vector pages: the driver makes them grey"); + let pictures = PrintDraft { grayscale: true, as_image: true, ..PrintDraft::default() }; + assert!(!pictures.job("a.pdf").grayscale, "grey pictures: nothing for the driver to do"); + assert!(pictures.settings(1, &[]).unwrap().raster.is_some_and(|r| r.grayscale), "grey pictures: the pages are rendered grey"); + assert!(!PrintDraft::default().job("a.pdf").grayscale); + } + #[test] fn composed_sheets_keep_exact_scaling_and_the_dialogs_duplex_choice() { let draft = PrintDraft { duplex: Some(spool::Duplex::LongEdge), ..PrintDraft::default() }; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 690dc3142e..f2e288661c 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -1,4 +1,4 @@ -use super::draft::{Handling, PrintDraft, Which}; +use super::draft::{CUSTOM_SCALE, Handling, PrintDraft, Which}; use super::{ Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, @@ -93,8 +93,25 @@ pub(crate) fn body( ui.label(egui::RichText::new(tl!("Print")).font(theme::semibold(18.0))); ui.add_space(8.0); let settings = d.settings(sizes.len(), labels); - let sheets = settings.as_ref().ok().and_then(|s| print::layout(sizes, s).ok()).unwrap_or_default(); + let laid = settings.as_ref().ok().map(|s| print::layout(sizes, s)); + // Creep within its field's range can still be too much for a thick booklet. + let too_much_creep = d.handling == Handling::Booklet && d.creep > 0.0 && matches!(laid, Some(Err(_))); + let sheets = laid.and_then(Result::ok).unwrap_or_default(); d.sheet = d.sheet.min(sheets.len().saturating_sub(1)); + // Cap the settings+preview area to the screen's available height (#4252, #5317): + // on a short screen, or with the driver's own Properties… panel expanded below, this row can + // otherwise grow taller than the display and push the Print/Cancel buttons off both edges. + // The title and the button row stay outside, so they are always reachable. + // `viewport_rect()` is egui's own idea of the current viewport, kept from the latest + // `RawInput::screen_rect` it was handed — unlike reading `raw.screen_rect` directly, it + // does not go blank on a frame where the backend didn't resend it. + let screen_h = ui.ctx().input(|i| i.viewport_rect().height()); + let max_h = (screen_h - 220.0).max(300.0); + // The viewport is `max_h` itself, not what the modal offers this frame: when a different + // dialog was shown before, the shared modal remeasures from a height of zero (#5118) and + // offers less, which would size this area by the dialog shown before. `auto_shrink` still + // shrinks it to contents that fit. + egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).auto_shrink([false, true]).show(ui, |ui| { ui.horizontal_top(|ui| { // Settings. ui.vertical(|ui| { @@ -158,6 +175,8 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; + // What this printer was last printed with (#2001). + d.apply_setup(); } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -222,7 +241,7 @@ pub(crate) fn body( // Windows says which drivers can't print two-sided as they're asked; they keep their // own setting, even when Two-sided is Off. if let Some(p) = d.printers.iter().find(|p| d.printer.as_ref() == Some(&p.name)) - && p.two_sided == Some(false) + && p.two_sided.or_else(|| d.two_sided.get(&p.name).copied().flatten()) == Some(false) { ui.label(egui::RichText::new(tl!("This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties…")).small().color(t.text_muted)); } @@ -315,7 +334,7 @@ pub(crate) fn body( if ui.add(widgets::radio(custom, tl!("Custom scale:"))).clicked() { d.size = SizeMode::Custom(d.custom_scale); } - ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")) + ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(CUSTOM_SCALE).suffix(" %")) .accessible_name(tl!("Custom scale")); }); } @@ -408,6 +427,10 @@ pub(crate) fn body( ); ui.label(tl!("Binding:")); combo(ui, "binding", tl!("Binding:"), &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); + let creep_label = ui.label(tl!("Creep:")); + ui.add(egui::DragValue::new(&mut d.creep).range(0.0..=print::MAX_CREEP).speed(0.05).suffix(" pt")) + .labelled_by(creep_label.id) + .on_hover_text(tl!("Moves each sheet's pages toward the fold by this much more than the sheet outside it, so the margins match once the booklet is folded and trimmed.")); }); } } @@ -467,6 +490,10 @@ pub(crate) fn body( (Err(e), _) => { ui.put(area.shrink(16.0), egui::Label::new(egui::RichText::new(tl!(e)).color(t.text_muted)).wrap()); } + (Ok(_), None) if too_much_creep => { + let msg = tl!("The creep moves the innermost sheet's pages too far for this many pages. Reduce Creep."); + ui.put(area.shrink(16.0), egui::Label::new(egui::RichText::new(msg).color(t.text_muted)).wrap()); + } (Ok(_), Some(sheet)) => { let k = (PREVIEW_BOX.0 / sheet.size.0 as f32).min(PREVIEW_BOX.1 / sheet.size.1 as f32); let paper = Rect::from_center_size(area.center(), vec2(sheet.size.0 as f32 * k, sheet.size.1 as f32 * k)); @@ -557,6 +584,7 @@ pub(crate) fn body( } }); }); + }); // end ScrollArea (#4252, #5317) ui.add_space(12.0); let (mut go, mut cancel) = (false, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -571,3 +599,31 @@ pub(crate) fn body( }); (go, cancel) } + +#[cfg(test)] +mod tests { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + use crate::{Dialog, PdfCraftApp}; + + /// #4252, #5317: on a short screen the settings+preview area used to grow past + /// both the top and bottom of the screen (centered modals overflow both edges equally), + /// taking the Cancel button with it. It must now stay reachable, scrolling instead. + #[test] + fn the_print_dialog_keeps_its_buttons_within_a_short_screen() { + let screen = egui::vec2(1000.0, 480.0); // shorter than the settings+preview column alone + let mut h = Harness::builder().with_size(screen).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = app.session.create_blank(612.0, 792.0, 1).unwrap(); + app.open_bytes("one-page.pdf", None, bytes.as_ref().clone()).unwrap(); + app.dialog = Some(Dialog::Print); + app + }); + h.run_steps(4); + + let cancel = h.get_by_label("Cancel").rect(); + assert!(cancel.min.y >= 0.0 && cancel.max.y <= screen.y, "Cancel must stay within the {}×{} screen, got {cancel:?}", screen.x, screen.y); + } +} diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 27080a7329..4c3325401c 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,3 +1,4 @@ +use super::setups::{self, PrinterSetup}; use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -19,6 +20,12 @@ pub enum Handling { Label, } +/// The percent a custom scale can be (the dialog's field and a saved setting). +pub const CUSTOM_SCALE: std::ops::RangeInclusive = 1.0..=1000.0; + +/// The resolutions the Print dialog offers for Print as image, in dots per inch. +pub const IMAGE_DPI: [f64; 3] = [150.0, 300.0, 600.0]; + #[derive(Clone, Debug, PartialEq)] pub struct PrintDraft { pub printers: Vec, @@ -54,6 +61,8 @@ pub struct PrintDraft { pub auto_rotate: bool, pub booklet_subset: BookletSubset, pub binding: Binding, + /// Booklet creep compensation, points per sheet. + pub creep: f64, pub poster_scale: f64, pub overlap: f64, pub cut_marks: bool, @@ -87,6 +96,12 @@ pub struct PrintDraft { pub driver_options_loading: Option, /// Properties… asked for this printer's options; the app starts the read on the next frame. pub read_options_for: Option, + /// Whether each printer's driver takes two-sided settings, as read in the background for + /// the printer picked (see [`TwoSidedSource`](super::TwoSidedSource)); `None` when it couldn't be read. + pub two_sided: std::collections::BTreeMap>, + /// The paper, sizing and two-sided choice each printer was last printed with, the printer + /// printed to last first; kept across restarts (#2001). + pub setups: Vec<(String, PrinterSetup)>, } impl Default for PrintDraft { @@ -118,6 +133,7 @@ impl Default for PrintDraft { auto_rotate: true, booklet_subset: BookletSubset::BothSides, binding: Binding::Left, + creep: 0.0, poster_scale: 200.0, overlap: 18.0, cut_marks: true, @@ -138,6 +154,8 @@ impl Default for PrintDraft { crop_pages: None, driver_options_loading: None, read_options_for: None, + two_sided: Default::default(), + setups: Vec::new(), } } } @@ -163,6 +181,40 @@ impl PrintDraft { } } + /// Remember this draft's paper, sizing and two-sided choice for its printer, after printing + /// to it (#2001). Save as PDF has none to remember. + pub fn remember_setup(&mut self) { + let Some(printer) = self.printer.clone() else { return }; + // Label's stock is that job's; the printer keeps the paper Label replaced. + let paper = if self.handling == Handling::Label { self.paper_before_label.unwrap_or(self.paper) } else { self.paper }; + let setup = PrinterSetup { paper, size: self.size, custom_scale: self.custom_scale, duplex: self.duplex }; + setups::record(&mut self.setups, &printer, setup); + } + + /// Bring back what the chosen printer was last printed with, when it has been printed to + /// (#2001); a printer never printed to keeps the dialog's choices. While Label holds the + /// label stock, the printer's paper is what leaving Label gives back. + pub fn apply_setup(&mut self) { + let Some(printer) = self.printer.as_deref() else { return }; + let Some(setup) = self.setups.iter().find(|(name, _)| name == printer).map(|(_, s)| *s) else { return }; + self.size = setup.size; + self.custom_scale = setup.custom_scale; + self.duplex = setup.duplex; + let paper = setup.paper.min(PAPERS.len() - 1); + if self.handling == Handling::Label { + self.paper_before_label = Some(paper); + } else { + self.paper = paper; + } + self.sheet = 0; + } + + /// The printer printed to last, while `printers` still lists it (#2001). + pub fn last_printer<'a>(&self, printers: &'a [spool::Printer]) -> Option<&'a spool::Printer> { + let (last, _) = self.setups.first()?; + printers.iter().find(|p| p.name == *last) + } + /// The engine settings for this draft (page count and labels from the document). pub fn settings(&self, count: usize, labels: &[String]) -> Result { if self.cut_and_stack() && matches!(self.duplex, Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge)) { @@ -200,7 +252,7 @@ impl PrintDraft { other => other, } } - Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding }, + Handling::Booklet => Layout::Booklet { subset: self.booklet_subset, binding: self.binding, creep: self.creep }, Handling::Poster => Layout::Poster { scale: self.poster_scale, overlap: self.overlap, cut_marks: self.cut_marks }, Handling::Label => Layout::Label { flip: self.label_flip }, }; @@ -210,11 +262,16 @@ impl PrintDraft { orientation: self.orientation, layout, content: self.content, - raster: self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }), + raster: self.raster(), crops: self.crops.clone(), }) } + /// Print as image: pictures at the chosen resolution, grey when grayscale is asked for. + fn raster(&self) -> Option { + self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }) + } + fn cut_and_stack(&self) -> bool { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } @@ -236,7 +293,7 @@ impl PrintDraft { self.duplex }, scaling: spool::Scaling::Exact, - grayscale: self.grayscale, + grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()), title: title.to_string(), // Only for the printer the options were read from. options: match (&self.printer, &self.driver_options) { diff --git a/crates/ui-egui/src/workflows/print_ui/setups.rs b/crates/ui-egui/src/workflows/print_ui/setups.rs new file mode 100644 index 0000000000..e0dba6031b --- /dev/null +++ b/crates/ui-egui/src/workflows/print_ui/setups.rs @@ -0,0 +1,163 @@ +//! What each printer was last printed with (#2001): its paper, page sizing and two-sided choice, +//! kept across restarts, so a label printer keeps 4 × 6 in at Actual size while an office +//! printer keeps A4 at Fit. The printer printed to last is preselected the next time Print opens. +//! +//! Copies, the pages to print, orientation and the Multiple / Booklet / Poster layouts stay one +//! job's choices, as before. + +use super::{CUSTOM_SCALE, PAPERS, SizeMode, print::names, spool::Duplex}; + +/// At most this many printers are remembered, the most recently printed to first (settings are +/// untrusted, and a long-gone queue shouldn't stay forever). +pub const MAX_PRINTERS: usize = 32; +/// A longer printer name in the settings file is refused (CUPS and Windows queue names are far +/// shorter). +const MAX_NAME_BYTES: usize = 1024; + +/// The paper, sizing and two-sided choice a printer was last printed with. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrinterSetup { + /// `PAPERS` index. + pub paper: usize, + pub size: SizeMode, + /// The percent `SizeMode::Custom` prints at. + pub custom_scale: f64, + /// `None` = the printer's own default. + pub duplex: Option, +} + +/// Put `setup` first for `printer`, replacing what it had, and drop the oldest past +/// [`MAX_PRINTERS`]. +pub fn record(setups: &mut Vec<(String, PrinterSetup)>, printer: &str, setup: PrinterSetup) { + setups.retain(|(name, _)| name != printer); + setups.insert(0, (printer.to_string(), setup)); + setups.truncate(MAX_PRINTERS); +} + +/// The setups for the settings file: the paper by its canonical name (as `print_paper`), sizing +/// and two-sided by the names `doc_print` and the `print` setting use ([`names`]). +pub fn to_json(setups: &[(String, PrinterSetup)]) -> serde_json::Value { + setups + .iter() + .filter_map(|(printer, s)| { + let (paper, _) = PAPERS.get(s.paper)?; + Some(serde_json::json!({ + "printer": printer, + "paper": paper, + "size": names::size_mode(s.size), + "custom_scale": s.custom_scale, + "duplex": names::duplex(s.duplex), + })) + }) + .collect() +} + +/// The setups read back from the settings file; `None` when it holds no list, so the caller +/// keeps what it has. An entry with an unknown or malformed value is skipped whole (a half-read +/// setup would print the label at the wrong size), as is a repeated printer. +pub fn from_json(v: &serde_json::Value) -> Option> { + let mut out: Vec<(String, PrinterSetup)> = Vec::new(); + for entry in v.as_array()?.iter() { + if out.len() >= MAX_PRINTERS { + break; + } + let Some(read) = read_entry(entry) else { continue }; + if out.iter().all(|(name, _)| *name != read.0) { + out.push(read); + } + } + Some(out) +} + +fn read_entry(entry: &serde_json::Value) -> Option<(String, PrinterSetup)> { + let printer = entry.get("printer")?.as_str().filter(|p| !p.is_empty() && p.len() <= MAX_NAME_BYTES)?; + let paper_name = entry.get("paper")?.as_str()?; + let paper = PAPERS.iter().position(|(name, _)| *name == paper_name)?; + let custom_scale = entry.get("custom_scale")?.as_f64().filter(|s| CUSTOM_SCALE.contains(s))?; + let size = names::parse_size_mode(entry.get("size")?.as_str()?, custom_scale)?; + let duplex = names::parse_duplex(entry.get("duplex")?.as_str()?)?; + Some((printer.to_string(), PrinterSetup { paper, size, custom_scale, duplex })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn setup(paper: usize, size: SizeMode, duplex: Option) -> PrinterSetup { + PrinterSetup { paper, size, custom_scale: 62.5, duplex } + } + + #[test] + fn setups_round_trip_through_the_settings_file() { + let mut setups = Vec::new(); + record(&mut setups, "Office", setup(4, SizeMode::Fit, Some(Duplex::LongEdge))); + record(&mut setups, "Label", setup(super::super::print::LABEL_PAPER, SizeMode::Actual, Some(Duplex::Off))); + record(&mut setups, "Plotter", setup(0, SizeMode::Custom(62.5), None)); + record(&mut setups, "Shrinker", setup(1, SizeMode::Shrink, Some(Duplex::ShortEdge))); + assert_eq!(from_json(&to_json(&setups)), Some(setups)); + } + + #[test] + fn printing_again_moves_a_printer_first_and_the_oldest_drops_off() { + let mut setups = Vec::new(); + for i in 0..MAX_PRINTERS + 3 { + record(&mut setups, &format!("Queue {i}"), setup(0, SizeMode::Fit, None)); + } + assert_eq!(setups.len(), MAX_PRINTERS); + assert_eq!(setups[0].0, format!("Queue {}", MAX_PRINTERS + 2)); + assert!(setups.iter().all(|(name, _)| name != "Queue 0")); + record(&mut setups, "Queue 10", setup(4, SizeMode::Actual, None)); + assert_eq!(setups.len(), MAX_PRINTERS); + assert_eq!(setups[0], ("Queue 10".to_string(), setup(4, SizeMode::Actual, None))); + assert_eq!(setups.iter().filter(|(name, _)| name == "Queue 10").count(), 1); + } + + #[test] + fn malformed_entries_are_skipped_whole() { + let good = serde_json::json!({"printer": "Label", "paper": "A4", "size": "actual", "custom_scale": 100.0, "duplex": "off"}); + let mut bad = Vec::new(); + for (key, value) in [ + ("printer", serde_json::json!("")), + ("printer", serde_json::json!(5)), + ("printer", serde_json::json!("x".repeat(MAX_NAME_BYTES + 1))), + ("paper", serde_json::json!("a4")), + ("paper", serde_json::json!(4)), + ("size", serde_json::json!("fit-to-page")), + ("size", serde_json::Value::Null), + ("custom_scale", serde_json::json!(0.5)), + ("custom_scale", serde_json::json!(1e9)), + ("custom_scale", serde_json::json!("100")), + ("duplex", serde_json::json!("Long-Edge")), + ("duplex", serde_json::json!(true)), + ] { + let mut entry = good.clone(); + entry[key] = value; + bad.push(entry); + } + let mut missing = good.clone(); + missing.as_object_mut().unwrap().remove("duplex"); + bad.push(missing); + bad.push(serde_json::json!("Label")); + bad.push(serde_json::Value::Null); + for entry in &bad { + assert_eq!(from_json(&serde_json::json!([entry])), Some(Vec::new()), "{entry}"); + } + // The good entry still reads among bad ones, once. + let mut list = bad.clone(); + list.push(good.clone()); + list.push(serde_json::json!({"printer": "Label", "paper": "A3", "size": "fit", "custom_scale": 100.0, "duplex": "printer"})); + let read = from_json(&serde_json::Value::Array(list)).unwrap(); + assert_eq!(read.len(), 1); + assert_eq!(read[0].0, "Label"); + assert_eq!(read[0].1.size, SizeMode::Actual); + // Not a list at all: the caller keeps what it has. + for v in [serde_json::Value::Null, serde_json::json!({}), serde_json::json!("Label"), serde_json::json!(3)] { + assert_eq!(from_json(&v), None, "{v}"); + } + // A file listing more printers than are kept reads the first ones. + let many: Vec<_> = (0..MAX_PRINTERS * 2) + .map(|i| serde_json::json!({"printer": format!("Q{i}"), "paper": "A4", "size": "fit", "custom_scale": 100.0, "duplex": "printer"})) + .collect(); + assert_eq!(from_json(&serde_json::Value::Array(many)).unwrap().len(), MAX_PRINTERS); + } +} diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 572bdf10f9..d894e30c33 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1039,6 +1039,177 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); } +/// #4477, #3938: in Edit a PDF, an added image can be resized repeatedly by its corner handles +/// (including the outer part of the handle circle and when a corner reaches past the page edge) +/// without losing selection to the object marquee, and dragging an out-of-bounds image back onto +/// the page does not smear edge pixels across the out-of-bounds half. +#[test] +fn added_picture_resizes_repeatedly_and_drags_back_from_out_of_bounds_without_smear() { + let _gpu = gpu(); + let mut h = harness(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 60, 60); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap(); + } + h.state_mut().add_image("red.png".into(), png); + h.run_steps(3); + crate::support::settle(&mut h); + + // 1. Grab the outer part of the lower-right corner handle (where the resize cursor is shown, + // outside r.expand(2.0)) and drag inward along one axis to shrink the image. + let rect0 = added(&h)[0].content.rect(); + let r0 = item_on_screen(&h, rect0); + let grip1 = r0.expand(1.0).right_bottom() + egui::vec2(2.0, 2.0); + h.hover_at(grip1); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + let to1 = grip1 + egui::vec2(-30.0, 0.0); + h.drag_at(grip1); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip1 + (to1 - grip1) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to1); + h.run_steps(3); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added image stays selected after releasing a corner resize"); + let rect1 = added(&h)[0].content.rect(); + assert!(rect1[2] - rect1[0] < (rect0[2] - rect0[0]) - 10.0, "first corner resize shrinks the image: {rect0:?} -> {rect1:?}"); + + // 2. Immediately resize again right after letting go, stretching past the right page edge, + // then shrink back from the out-of-bounds corner handle. + let r1 = item_on_screen(&h, rect1); + let page_right = h.state().views[0].page_screen_rect(0).unwrap().right(); + let grip2 = r1.expand(1.0).right_bottom(); + let to2 = pos2(page_right + 12.0, grip2.y); + h.hover_at(grip2); + h.run_steps(1); + h.drag_at(grip2); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip2 + (to2 - grip2) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to2); + h.run_steps(3); + let rect2 = added(&h)[0].content.rect(); + assert!(rect2[2] > 300.0, "second resize reaches past the page edge: {rect2:?}"); + + // Grab the corner handle that now sits outside the page rect and shrink it back inside. + let r2 = item_on_screen(&h, rect2); + let grip3 = r2.expand(1.0).right_bottom(); + assert!(!h.state().views[0].page_screen_rect(0).unwrap().contains(grip3), "corner handle lies outside the page rect"); + let to3 = pos2(page_right - 40.0, grip3.y); + h.hover_at(grip3); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + h.drag_at(grip3); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip3 + (to3 - grip3) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to3); + h.run_steps(3); + let rect3 = added(&h)[0].content.rect(); + assert!(rect3[2] < 300.0, "out-of-bounds corner handle still resizes the image back inside: {rect2:?} -> {rect3:?}"); + + // 3. Place the image half below the bottom edge of the page (y = -30..30), wait for the page + // raster, then drag it up into the page: the out-of-bounds bottom half must not smear the + // page edge's red texels across the lower half during the drag preview. + let img = match &added(&h)[0].content { + pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [120.0, -30.0, 180.0, 30.0], ..i.clone() }, + _ => panic!(), + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) })); + crate::support::settle(&mut h); + let from = item_on_screen(&h, [120.0, -30.0, 180.0, 30.0]); + let grab = pos2(from.center().x, from.top() + 10.0); + let shift = egui::vec2(0.0, -120.0); + h.hover_at(grab); + h.run_steps(1); + h.drag_at(grab); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grab + shift * (k as f32 / 4.0)); + h.run_steps(1); + } + crate::support::settle(&mut h); + let moved = from.translate(shift); + let upper_half = egui::Rect::from_min_max(moved.min + egui::vec2(4.0, 4.0), pos2(moved.max.x - 4.0, moved.center().y - 4.0)); + let lower_half = egui::Rect::from_min_max(pos2(moved.min.x + 4.0, moved.center().y + 4.0), moved.max - egui::vec2(4.0, 4.0)); + let pixels = h.render().unwrap(); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + pixels.save(format!("{dir}/added-image-outside-page-drag.png")).unwrap(); + } + assert!(red(&pixels, upper_half) > 100, "the in-bounds half of the picture follows the drag"); + assert_eq!(red(&pixels, lower_half), 0, "the out-of-bounds half must not smear edge texels across the lower half"); + h.drop_at(grab + shift); + crate::support::settle(&mut h); +} + +/// A short page leaves empty window under the scroll content. A handle dragged into that +/// margin has to start another resize after the pointer is released. +#[test] +fn a_handle_in_the_viewport_below_a_short_page_starts_another_resize() { + let _gpu = gpu(); + let mut h = harness(); + h.state_mut().set_option("zoom", "50").unwrap(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 60, 60); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[200u8, 30, 30].repeat(3600)).unwrap(); + } + h.state_mut().add_image("red.png".into(), png); + h.run_steps(3); + let img = match &added(&h)[0].content { + pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [40.0, 0.0, 100.0, 40.0], ..i.clone() }, + _ => panic!("added an image"), + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Image(img) })); + h.run_steps(3); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let grip = item_on_screen(&h, [40.0, 0.0, 100.0, 40.0]).expand(1.0).right_bottom(); + let below = pos2(grip.x, page.bottom() + 80.0); + assert!(below.y < 880.0, "the margin point stays in the window: {below:?}"); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip + (below - grip) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(below); + h.run_steps(3); + let pulled = added(&h)[0].content.rect(); + assert!(pulled[1] < -5.0, "the first drag leaves the page: {pulled:?}"); + let grip2 = item_on_screen(&h, pulled).expand(1.0).right_bottom(); + assert!(grip2.y > page.bottom() + 40.0, "the handle is below the page margin: grip {grip2:?} page {page:?}"); + let to = pos2(grip2.x, grip2.y - 36.0); + h.hover_at(grip2); + h.run_steps(1); + h.drag_at(grip2); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip2 + (to - grip2) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let again = added(&h)[0].content.rect(); + assert!(again[1] > pulled[1] + 5.0, "the second press never started a resize: {pulled:?} -> {again:?}"); +} + /// Bug A for added text: with one line of the box selected, an alignment applies to that line /// only, as does a second one, the box stays open, and Undo puts it back and closes the box. #[test] @@ -1491,6 +1662,43 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() { assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out"); } +/// Out-of-page editing grips do not turn the invisible image body into a context-menu target. +#[test] +fn image_context_menu_requires_visible_body_even_when_the_frame_crosses_the_page() { + for visible in [false, true] { + let mut h = harness(); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let mut png = Vec::new(); + image::RgbImage::from_pixel(20, 20, image::Rgb([200, 30, 30])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + h.state_mut().add_image("edge.png".into(), png); + h.run_steps(3); + let image = match &added(&h)[0].content { + pdfcraft_engine::AddedContent::Image(i) => pdfcraft_engine::AddedImage { rect: [260.0, 150.0, 340.0, 230.0], ..i.clone() }, + _ => panic!("added an image"), + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { + page: 0, + index: 0, + content: pdfcraft_engine::AddedContent::Image(image), + })); + h.run_steps(3); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + let x = if visible { 280.0 } else { 320.0 }; + let point = item_on_screen(&h, [x, 190.0, x, 190.0]).center(); + assert_eq!(h.state().views[0].page_screen_rect(0).unwrap().contains(point), visible); + h.hover_at(point); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: point, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + // Flip horizontal also appears in the persistent sidebar; Arrange image is menu-only. + assert_eq!(h.query_all_by_label("Arrange image").next().is_some(), visible, "image menu requires visible body"); + } +} + /// The rail's page and zoom fields are the same size, and each sits wholly inside the rail, so /// neither box is cut off at the edge (the page box lost its right side once the rail's contents /// changed width). diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index bb11d83e0c..481f7e09a7 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -5487,6 +5487,30 @@ fn paragraph_edges_and_frames_are_caught_without_aiming_for_the_hairline() { assert!(h.query_all_by_label_contains("object selected").next().is_none(), "and doesn't take the box"); } +/// Leaving All tools ▸ Edit a PDF for another tool applies the paragraph still open, as leaving +/// the Edit tab does, rather than dropping what was typed (review on #4605). +#[test] +fn another_tool_from_edit_a_pdf_applies_the_open_paragraph() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(1, |_| {}); + h.state_mut().set_option("tool", "edit").unwrap(); + h.run_steps(2); + assert_ne!(h.state().mode, Mode::Edit, "Edit a PDF from All tools, not the Edit tab"); + assert_eq!(h.state().quick_tool, QuickTool::EditText); + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + click_at(&mut h, egui::pos2(r.left() + ((b.rect[0] + b.rect[2]) / 2.0) as f32 * k, r.top() + (300.0 - (b.rect[1] + b.rect[3]) / 2.0) as f32 * k)); + assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("Page 1"), "the paragraph is open for typing"); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Page one".into(); + h.state_mut().set_option("tool", "organize").unwrap(); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_none()); + assert_eq!(h.state().quick_tool, QuickTool::Select); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].text, "Page one", "the typed text was applied"); +} + #[test] fn an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys() { let mut h = harness(2, |_| {}); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index ff302aa3c4..31595def46 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1766,6 +1766,120 @@ fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); } +/// The one-page PDF `guide_fixture` attaches as `Guide.pdf`. +const GUIDE_INNER: &str = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + +/// A page with a push button `go`, and three attached files: a one-page PDF filed under the key +/// `Guide.pdf` (its own file name `inner.pdf`), `notes.txt`, and `fake.pdf`, which holds text. +fn guide_fixture() -> Vec { + let inner = GUIDE_INNER; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /Names << /EmbeddedFiles << /Names [(Guide.pdf) 5 0 R (fake.pdf) 9 0 R (notes.txt) 7 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 20 120 50] /P 3 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()), + "<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 8 0 R >> >>".into(), + "<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(), + "<< /Type /Filespec /F (fake.pdf) /UF (fake.pdf) /EF << /F 10 0 R >> >>".into(), + "<< /Type /EmbeddedFile /Length 12 >>\nstream\nnot a PDF at\nendstream".into(), + ]; + 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 +} + +/// An attachment opens in a tab only when its bytes are a PDF, whatever its name says: a +/// `fake.pdf` holding text is offered to save instead. A request goes back to the tab opened from +/// that attachment, and not to another open PDF of the same name whose bytes start with it. +#[test] +fn a_form_button_opens_only_a_real_pdf_and_returns_to_the_tab_it_opened() { + 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("form.pdf", None, guide_fixture()).unwrap(); + // Another document named like the attachment, its bytes the attachment's and an update. + app.open_bytes("inner.pdf", None, format!("{GUIDE_INNER}\n% updated later\n").into_bytes()).unwrap(); + app.active = Some(0); + app + }); + h.run_steps(4); + let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().views.len(); + let id = h.state().views[0].id; + // A text file named .pdf is offered to save, not opened. + let saved = std::env::temp_dir().join(format!("pdfcraft-fake-{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&saved); + h.state_mut().pick_override = Some(vec![saved.to_string_lossy().into_owned()]); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'fake.pdf', nLaunch: 2});"); + h.run_steps(6); + assert_eq!(tabs(&h), 2, "no tab for a file that isn't a PDF: {:?}", h.state().toast); + assert_eq!(std::fs::read(&saved).ok().as_deref(), Some(&b"not a PDF at"[..]), "offered to save instead"); + let _ = std::fs::remove_file(&saved); + // The real attached PDF opens in a tab of its own, though another inner.pdf starts with it. + h.state_mut().active = Some(0); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!(tabs(&h), 3, "a tab of its own"); + assert_eq!(h.state().active, Some(2)); + // Asked again, it goes back to the tab it opened. + h.state_mut().active = Some(0); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!((tabs(&h), h.state().active), (3, Some(2)), "the tab it opened, not the other inner.pdf"); +} + +/// A button script's `exportDataObject` opens the attached PDF in a new tab, goes back to that +/// tab when asked again, and names a missing file in a toast, a long name shortened. +#[test] +fn a_form_button_opens_its_attached_instructions_in_a_new_tab() { + 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("form.pdf", None, guide_fixture()).unwrap(); + app + }); + h.run_steps(4); + let tabs = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect::>() + }; + let id = h.state().views[0].id; + // By its key in the document's attached files, as forms name it. + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "the attached PDF opens in its own tab"); + // Asked again from the form, it goes back to that tab rather than opening another. + h.state_mut().active = Some(0); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "no second copy"); + assert_eq!(h.state().active, Some(1), "its tab is shown"); + // A file the document doesn't have is said so. + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'nope.pdf', nLaunch: 2});"); + h.run_steps(2); + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains("nope.pdf")), "{:?}", h.state().toast); + assert_eq!(tabs(&h).len(), 2); + // A name of the form's own is shortened in the message, keeping its end. (Past a line's + // length, the script engine cuts it first, as it cuts every name a script passes.) + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: new Array(3001).join('x') + '.pdf', nLaunch: 2});"); + h.run_steps(2); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains('…') && toast.contains("x.pdf"), "{toast}"); + assert!(toast.chars().count() < 200, "{} characters", toast.chars().count()); +} + /// Two combo boxes of Apple and Banana: `fruit` editable (Combo and Edit, ISO 32000-2 Table 233), /// `veg` a drop-down list only. fn combo_form() -> Vec { diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 3d9c8e968f..81e96c0311 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -336,6 +336,229 @@ fn format_validate_and_calculate_tabs() { assert_eq!(f.actions.calculate, Calculate::Notation("name".into())); } +/// The radio of the Calculate tab's custom calculation script (#6630). +const CUSTOM: &str = "Custom calculation script:"; + +/// What the Calculate tab says when an edit would take the custom script past 256 KiB. +const REFUSAL: &str = "A script edited here can be at most 256 KiB long; that change wasn't made."; + +/// The longest custom script the Calculate tab edits, in bytes. +const MAX_EDITED_SCRIPT: usize = 256 * 1024; + +/// The harness in Prepare Form, with `city`'s calculation set to `js`, a script of its own. +fn with_calculate_script(js: &str) -> Harness<'static, PdfCraftApp> { + let mut h = harness(); + set_calculate_script(&mut h, js); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h +} + +/// Sets `city`'s calculate script, as form_set_script does. +fn set_calculate_script(h: &mut Harness<'static, PdfCraftApp>, js: &str) { + let edit = pdfcraft_engine::Edit::SetFieldScript { name: "city".into(), event: "calculate".into(), script: Some(js.into()) }; + assert!(h.state_mut().apply_edit(edit)); + h.run_steps(2); +} + +/// Opens Field Properties of `name` on the Calculate tab. +fn open_calculate(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + h.state_mut().open_field_props(name, 0); + h.run_steps(2); + h.get_by_label("Calculate").click(); + h.run_steps(2); +} + +/// Clicks the dialog button `label` (OK or Cancel). +fn press(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +/// Clicks the Calculate tab's radio `label`. +fn choose(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + h.get_by_role_and_label(Role::RadioButton, label).click(); + h.run_steps(2); +} + +/// Whether the Calculate tab's radio `label` is selected. +fn chosen(h: &Harness<'static, PdfCraftApp>, label: &str) -> bool { + use egui_kittest::kittest::NodeT; + h.get_by_role_and_label(Role::RadioButton, label).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +/// `city`'s calculation as the document has it: the recognised one and the script of its own. +fn city_calculation(h: &Harness<'static, PdfCraftApp>) -> (pdfcraft_engine::form_scripts::Calculate, Option) { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().clone(); + (f.actions.calculate, f.actions.scripts.calculate) +} + +/// What each multi-line box on the tab holds. +fn script_boxes(h: &Harness<'static, PdfCraftApp>) -> Vec { + h.query_all_by_role(Role::MultilineTextInput).filter_map(|n| n.value()).collect() +} + +/// The script box's description: what a screen reader says about it besides its text. +fn script_box_description(h: &Harness<'static, PdfCraftApp>) -> Option { + use egui_kittest::kittest::NodeT; + h.get_by_role_and_label(Role::MultilineTextInput, CUSTOM).accesskit_node().description() +} + +/// Selects everything in the script box and types `text` over it. +fn retype_script(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + h.get_by_role(Role::MultilineTextInput).click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role(Role::MultilineTextInput).type_text(text); + h.run_steps(2); +} + +/// Pastes `text` into the script box where its cursor is. +fn paste_script(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + h.get_by_role(Role::MultilineTextInput).click(); + h.run_steps(1); + h.event(egui::Event::Paste(text.into())); + h.run_steps(2); +} + +/// A field whose calculation is a script of its own shows it on the Calculate tab as a custom +/// calculation script, not as "Value is not calculated" (#6630); another choice and back brings +/// the script back, and OK without a change keeps it. +#[test] +fn the_calculate_tab_shows_a_custom_calculation_script() { + let js = "event.value = \"*\" + this.getField(\"name\").value + \"*\";"; + let mut h = with_calculate_script(js); + open_calculate(&mut h, "city"); + assert!(chosen(&h, CUSTOM), "the script is the field's calculation"); + assert!(!chosen(&h, "Value is not calculated")); + assert_eq!(script_boxes(&h), [js]); + assert_eq!(script_box_description(&h), None, "nothing to say about it"); + choose(&mut h, "Simplified field notation:"); + assert!(!script_boxes(&h).contains(&js.to_owned())); + choose(&mut h, CUSTOM); + assert_eq!(script_boxes(&h), [js]); + assert_eq!(h.state().field_props.as_ref().and_then(|d| d.props()), None, "an untouched tab changes nothing"); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (pdfcraft_engine::form_scripts::Calculate::None, Some(js.to_owned()))); +} + +/// Only a script that is no sum or notation opens as a custom script: a sum stays a sum, and a +/// field with no calculation opens on "Value is not calculated", with no script box. +#[test] +fn a_sum_or_no_calculation_does_not_open_as_a_custom_script() { + let mut h = with_calculate_script("AFSimple_Calculate(\"SUM\", new Array(\"name\"));"); + open_calculate(&mut h, "city"); + assert!(chosen(&h, "Value is the"), "the sum is recognised"); + assert!(!chosen(&h, CUSTOM)); + assert!(script_boxes(&h).is_empty()); + press(&mut h, "Cancel"); + open_calculate(&mut h, "name"); + assert!(chosen(&h, "Value is not calculated")); + assert!(!chosen(&h, CUSTOM)); + assert!(script_boxes(&h).is_empty()); +} + +/// An edited custom script is written as typed, and "Value is not calculated" removes it. +#[test] +fn an_edited_custom_script_is_written_and_no_calculation_removes_it() { + use pdfcraft_engine::form_scripts::Calculate; + let mut h = with_calculate_script("event.value = 1;"); + open_calculate(&mut h, "city"); + let edited = "event.value = this.getField(\"name\").value; // città"; + retype_script(&mut h, edited); + assert_eq!(script_boxes(&h), [edited]); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (Calculate::None, Some(edited.to_owned()))); + open_calculate(&mut h, "city"); + choose(&mut h, "Value is not calculated"); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (Calculate::None, None)); +} + +/// An edit is taken up to exactly 256 KiB, counted in bytes as the JavaScript engine counts them; +/// one past it, by a byte or by a multi-byte paste of fewer characters, is refused, the box keeps +/// what it had, and the refusal is said beside the box and in its description until the next edit +/// is taken. +#[test] +fn an_edit_past_256_kib_is_refused_and_the_next_edit_is_taken() { + use pdfcraft_engine::form_scripts::Calculate; + let js = "event.value = 1;"; + let mut h = with_calculate_script(js); + open_calculate(&mut h, "city"); + let refused = |h: &Harness<'static, PdfCraftApp>| (h.query_by_label(REFUSAL).is_some(), script_box_description(h)); + assert_eq!(refused(&h), (false, None)); + // 100 Ki euro signs are 300 KiB: fewer characters than the limit, more bytes. + for paste in ["x".repeat(300 * 1024), "€".repeat(100 * 1024)] { + paste_script(&mut h, &paste); + assert_eq!(refused(&h), (true, Some(REFUSAL.to_owned())), "{} bytes", paste.len()); + assert_eq!(script_boxes(&h), [js], "the box keeps what it had"); + } + // Exactly at the limit is taken, and the refusal is no longer said. + let full = format!("/*{}*/", "x".repeat(MAX_EDITED_SCRIPT - 4)); + retype_script(&mut h, &full); + assert_eq!(refused(&h), (false, None)); + assert!(script_boxes(&h) == [full.as_str()], "the box holds the full 256 KiB"); + // One byte more is not. + h.key_press(egui::Key::End); + h.get_by_role(Role::MultilineTextInput).type_text(" "); + h.run_steps(2); + assert!(refused(&h).0, "256 KiB and a byte"); + assert!(script_boxes(&h) == [full.as_str()]); + press(&mut h, "OK"); + assert!(city_calculation(&h) == (Calculate::None, Some(full))); +} + +/// The refusal is forgotten when another calculation is chosen or the dialog is opened again, and +/// choosing Custom again brings back the field's own script, not an edit that was left. +#[test] +fn a_refusal_is_forgotten_on_another_choice_or_reopening() { + use pdfcraft_engine::form_scripts::Calculate; + let js = "event.value = 1;"; + let mut h = with_calculate_script(js); + open_calculate(&mut h, "city"); + retype_script(&mut h, "event.value = 2;"); + paste_script(&mut h, &"x".repeat(300 * 1024)); + assert!(h.query_by_label(REFUSAL).is_some()); + choose(&mut h, "Simplified field notation:"); + assert!(h.query_by_label(REFUSAL).is_none(), "another choice forgets it"); + choose(&mut h, CUSTOM); + assert!(h.query_by_label(REFUSAL).is_none()); + assert_eq!(script_box_description(&h), None); + assert_eq!(script_boxes(&h), [js], "the field's own script, not the edit left behind"); + paste_script(&mut h, &"x".repeat(300 * 1024)); + assert!(h.query_by_label(REFUSAL).is_some()); + press(&mut h, "Cancel"); + open_calculate(&mut h, "city"); + assert!(h.query_by_label(REFUSAL).is_none(), "opening the dialog again forgets it"); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (Calculate::None, Some(js.to_owned()))); +} + +/// A script past 256 KiB isn't edited: the box shows its start, read-only (cut at a character, not +/// inside one), says why, and OK keeps the whole script. +#[test] +fn a_script_past_256_kib_shows_its_start_and_is_kept() { + use pdfcraft_engine::form_scripts::Calculate; + // 18 + 3 × 100 Ki + 2 bytes: 256 KiB falls inside a euro sign. + let long = format!("event.value = 1;/*{}*/", "€".repeat(100 * 1024)); + let mut h = with_calculate_script(&long); + open_calculate(&mut h, "city"); + assert!(chosen(&h, CUSTOM)); + let note = "This script is too long to edit here (300 KiB); its start is shown. It is kept as it is."; + assert!(h.query_by_label(note).is_some()); + assert_eq!(script_box_description(&h).as_deref(), Some(note)); + let start = &long[..MAX_EDITED_SCRIPT - 1]; + assert!(script_boxes(&h) == [start], "the box shows the start, up to the last whole character"); + h.get_by_role(Role::MultilineTextInput).click(); + h.run_steps(1); + h.get_by_role(Role::MultilineTextInput).type_text("x"); + h.run_steps(2); + assert!(script_boxes(&h) == [start], "and can't be typed in"); + press(&mut h, "OK"); + assert!(city_calculation(&h) == (Calculate::None, Some(long))); +} + /// Renaming a field in Field Properties renames it in the formulas that read it, in the same undo /// step, and says how many changed. #[test] diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 0eae20289a..13f74198cf 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -519,6 +519,46 @@ fn custom_page_cannot_silently_match_a_standard_paper() { assert_eq!(h.state().print_draft.size, before.size); } +/// Windows' printer list doesn't say which drivers take two-sided settings: reading a printer +/// Windows can't reach takes it about 48 s (#2192). The dialog lists every printer at once and +/// asks only about the one picked, in the background, so a printer nobody picks is never waited on. +#[test] +fn two_sided_support_is_read_only_for_the_printer_picked() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::{Arc, Mutex}; + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(|| { + vec![ + Printer { name: "Office PCL".into(), default: true, two_sided: None, description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: None, description: String::new() }, + Printer { name: "Away WSD".into(), default: false, two_sided: None, description: String::new() }, + ] + })); + let asked = Arc::new(Mutex::new(Vec::::new())); + let log = asked.clone(); + h.state_mut().two_sided_source = Some(Arc::new(move |name: &str| { + log.lock().unwrap().push(name.to_string()); + Some(name != "Office PS") + })); + assert!(h.state_mut().execute("print.dialog")); + let settle = |h: &mut Harness<'static, PdfCraftApp>, done: &dyn Fn(&PdfCraftApp) -> bool| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !done(h.state()) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + }; + settle(&mut h, &|a| a.print_draft.two_sided.contains_key("Office PCL")); + assert_eq!(h.state().print_draft.printers.len(), 3); + let note = "doesn't take two-sided settings"; + assert!(h.query_by_label_contains(note).is_none(), "a driver that prints two-sided as asked"); + h.state_mut().print_draft.printer = Some("Office PS".into()); + settle(&mut h, &|a| a.print_draft.two_sided.contains_key("Office PS")); + h.get_by_label_contains(note); + assert_eq!(*asked.lock().unwrap(), ["Office PCL", "Office PS"], "each picked printer once, never the others"); +} + /// A driver Windows reports without two-sided printing keeps its own setting whatever Two-sided /// says (some print both sides with it Off), so the dialog says where to change it. #[test] @@ -545,6 +585,92 @@ fn a_printer_without_two_sided_printing_points_to_its_properties() { h.get_by_label_contains(note); } +/// A list that already says which drivers take two-sided settings is used as it is: no driver is +/// asked again, whichever printer is picked. +#[test] +fn a_listed_two_sided_answer_is_not_asked_again() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::{Arc, Mutex}; + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(|| { + vec![ + Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }, + ] + })); + let asked = Arc::new(Mutex::new(Vec::::new())); + let log = asked.clone(); + h.state_mut().two_sided_source = Some(Arc::new(move |name: &str| { + log.lock().unwrap().push(name.to_string()); + None + })); + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers.is_empty() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + h.state_mut().print_draft.printer = Some("Office PS".into()); + h.run_steps(4); + h.get_by_label_contains("doesn't take two-sided settings"); + assert!(asked.lock().unwrap().is_empty(), "the list's answers are used: {:?}", asked.lock().unwrap()); +} + +/// Reads that hang (printers Windows can't reach) don't pile up as the user picks printer after +/// printer: at most four run at once, and the printer picked is asked once one finishes. +#[test] +fn hanging_two_sided_reads_are_capped() { + use pdfcraft_engine::print::spool::Printer; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + let names: Vec = (1..=6).map(|i| format!("Away WSD {i}")).collect(); + let listed = names.clone(); + let mut h = harness(); + h.state_mut().printer_source = Some(Arc::new(move || { + listed.iter().enumerate().map(|(i, n)| Printer { name: n.clone(), default: i == 0, two_sided: None, description: String::new() }).collect() + })); + let asked = Arc::new(Mutex::new(Vec::::new())); + let released = Arc::new(AtomicBool::new(false)); + let (log, gate) = (asked.clone(), released.clone()); + h.state_mut().two_sided_source = Some(Arc::new(move |name: &str| { + log.lock().unwrap().push(name.to_string()); + // Hold the read like an unreachable printer, until the test lets it go (10 s at most). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !gate.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Some(true) + })); + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers.is_empty() && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + for name in &names { + h.state_mut().print_draft.printer = Some(name.clone()); + h.run_steps(2); + } + let started = |a: &Arc>>| a.lock().unwrap().len(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while started(&asked) < 4 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); + let mut first = asked.lock().unwrap().clone(); + first.sort(); + assert_eq!(first, names[..4], "four reads at once, the rest wait"); + released.store(true, Ordering::SeqCst); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !h.state().print_draft.two_sided.contains_key(&names[5]) && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().print_draft.two_sided.get(&names[5]), Some(&Some(true)), "the printer picked is asked once a read finishes"); + assert!(!h.state().print_draft.two_sided.contains_key(&names[4]), "a printer no longer picked isn't asked"); +} + /// Print as image (Acrobat's Advanced Print Setup) is a choice in the Print dialog's Advanced /// section: the saved print-ready PDF carries the page as a picture, no text. #[test] @@ -869,7 +995,7 @@ fn paper_choice_survives_cancel_and_settings_restore() { h.get_by_label("A4").click(); h.run_steps(2); assert_eq!(h.state().print_draft.paper, a4); - // Only paper should be added to persistence; these choices still belong to this session. + // Paper persists; copies, pages and orientation belong to this job (grayscale is remembered, #2001). let draft = &mut h.state_mut().print_draft; draft.copies = 5; draft.which = pdfcraft_ui_egui::PrintWhich::Range; @@ -886,8 +1012,10 @@ fn paper_choice_survives_cancel_and_settings_restore() { assert_eq!(json["print_paper"], "A4", "store the canonical name, not its position"); let mut app = PdfCraftApp::new(); app.restore(&saved); - let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, ..Default::default() }; - assert_eq!(app.print_draft, expected, "no other print preference becomes persistent"); + // Grayscale is one of the preferences remembered across restarts (#2001); copies, pages and + // orientation are this job's and are not saved. + let expected = pdfcraft_ui_egui::PrintDraft { paper: a4, grayscale: true, ..Default::default() }; + assert_eq!(app.print_draft, expected, "only the paper and the remembered preferences persist"); let mut restarted = harness_with_settings(Some(saved)); assert!(restarted.state_mut().execute("print.dialog")); restarted.run_steps(3); @@ -997,6 +1125,10 @@ fn a_booklet_decides_two_sided_itself() { let d = &h.state().print_draft; assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); + assert!(h.query_all_by_label("Creep:").next().is_some(), "a Creep field beside Binding"); + h.state_mut().print_draft.creep = 1.5; + let layout = h.state().print_draft.settings(4, &[]).map(|s| s.layout); + assert!(matches!(layout, Ok(pdfcraft_engine::print::Layout::Booklet { creep, .. }) if creep == 1.5), "the creep reaches the booklet (#6777)"); for subset in [BookletSubset::FrontOnly, BookletSubset::BackOnly] { h.state_mut().print_draft.booklet_subset = subset; assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "{subset:?} is one pass of a manual duplex"); @@ -1006,3 +1138,246 @@ fn a_booklet_decides_two_sided_itself() { assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); } + +/// Creep the field allows can still move a thick booklet's innermost pages too far; the preview +/// says to reduce it rather than showing no sheets without a reason (#6777). +#[test] +fn too_much_creep_for_a_thick_booklet_is_explained_in_the_preview() { + 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("thick.pdf", None, crate::support::fixtures::pdf_pages(400)).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.get_by_label("Booklet").click(); + h.run_steps(2); + h.state_mut().print_draft.creep = 5.0; + h.run_steps(2); + h.get_by_label_contains("Reduce Creep"); + h.state_mut().print_draft.creep = 0.5; + h.run_steps(2); + assert!(h.query_by_label_contains("Reduce Creep").is_none(), "a creep that fits lays the booklet out"); +} + +/// An app restored from `saved` settings (if any), with a form open and its Print dialog shown. +fn print_opened_with(saved: Option<&str>) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + if let Some(saved) = saved { + app.restore(saved); + } + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.open_print(); + app +} + +/// The choices that describe how an office prints (two-sided, grayscale, collate, page sizing, +/// comments & forms, a booklet's binding side, Print as image and its resolution) survive a +/// restart; the ones that describe one job (copies, pages, layout, orientation) start from the +/// defaults each time Print opens, in the same session too (ISO 32000-2 Table 147 initialises them +/// per document) (#2001, #6392). +#[test] +fn print_preferences_survive_a_restart_but_the_jobs_own_choices_reset() { + use pdfcraft_engine::print::{Binding, BookletSubset, Content, LABEL_PAPER, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool::Duplex}; + use pdfcraft_ui_egui::{PrintHandling, PrintWhich}; + let open = print_opened_with; + let mut app = open(None); + let d = &mut app.print_draft; + d.duplex = Some(Duplex::LongEdge); + d.grayscale = true; + d.collate = false; + d.size = SizeMode::Custom(62.5); + d.custom_scale = 62.5; + d.content = Content::Document; + d.binding = Binding::Right; + d.as_image = true; + d.dpi = 600.0; + d.copies = 5; + d.which = PrintWhich::Range; + d.range = "2-3".into(); + d.subset = Subset::Even; + d.reverse = true; + d.handling = PrintHandling::Booklet; + d.booklet_subset = BookletSubset::FrontOnly; + d.per_sheet = 16; + (d.custom_grid, d.cols, d.rows) = (true, 3, 5); + d.order = PageOrder::CutStack; + d.orientation = Orientation::Landscape; + let saved = app.persist(); + + // Opened again in the same session: this job's choices, the layout's own options included, + // start over; the rest carry on. + app.open_print(); + let d = &app.print_draft; + assert_eq!((d.copies, d.which, d.range.as_str(), d.subset, d.reverse), (1, PrintWhich::All, "", Subset::All, false)); + assert_eq!((d.handling, d.orientation), (PrintHandling::Size, Orientation::Auto)); + assert_eq!((d.booklet_subset, d.per_sheet, d.order), (BookletSubset::BothSides, 2, PageOrder::Horizontal)); + let fresh = pdfcraft_ui_egui::PrintDraft::default(); + assert_eq!((d.custom_grid, d.cols, d.rows), (false, fresh.cols, fresh.rows)); + assert_eq!((d.duplex, d.grayscale, d.collate, d.size, d.binding), (Some(Duplex::LongEdge), true, false, SizeMode::Custom(62.5), Binding::Right)); + + // A job printed on label stock: the next one is back on the paper Label replaced (#3564). + let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap(); + app.print_draft.paper = a4; + app.print_draft.set_handling(PrintHandling::Label); + assert_eq!(app.print_draft.paper, LABEL_PAPER); + app.open_print(); + assert_eq!((app.print_draft.handling, app.print_draft.paper), (PrintHandling::Size, a4)); + + let again = open(Some(&saved)); + let d = &again.print_draft; + assert_eq!(d.duplex, Some(Duplex::LongEdge)); + assert!(d.grayscale); + assert!(!d.collate); + assert_eq!(d.size, SizeMode::Custom(62.5)); + assert_eq!(d.custom_scale, 62.5); + assert_eq!(d.content, Content::Document); + assert_eq!(d.binding, Binding::Right, "a booklet's binding side is kept (#2707)"); + assert!(d.as_image, "Print as image is kept, for a printer that needs pictures (#6392)"); + assert_eq!(d.dpi, 600.0, "and its resolution"); + assert_eq!(d.copies, 1, "copies are this job's"); + assert_eq!(d.which, PrintWhich::All, "the page choice is this job's"); + assert_eq!(d.range, ""); +} + +/// Settings are untrusted: an unknown name, a scale outside the dialog's range or a resolution it +/// doesn't offer keeps the default (#2001, #6392). +#[test] +fn print_preferences_that_dont_parse_keep_the_defaults() { + use pdfcraft_engine::print::{Binding, Content, SizeMode, spool::Duplex}; + let open = print_opened_with; + let garbage = r#"{"print": {"duplex": "sideways", "grayscale": "yes", "collate": null, "size": "custom", "custom_scale": 1e9, "comments_forms": 7, "binding": "up", "as_image": 1, "dpi": 72}}"#; + let d = open(Some(garbage)).print_draft; + assert!(!d.as_image); + assert_eq!(d.dpi, 300.0, "a resolution the dialog doesn't offer keeps the default"); + assert_eq!(d.duplex, None, "an unknown name keeps the printer's own default"); + assert!(!d.grayscale); + assert!(d.collate); + assert_eq!(d.custom_scale, 100.0, "a scale outside 1..=1000 keeps the default"); + assert_eq!(d.size, SizeMode::Custom(100.0), "custom is honoured with the default scale"); + assert_eq!(d.content, Content::DocumentAndMarkups); + assert_eq!(d.binding, Binding::Left); + let d = open(Some(r#"{"print": 5}"#)).print_draft; + assert_eq!(d.size, SizeMode::Fit); + // Leaving two-sided to the printer is remembered as such, over a different earlier choice. + let d = open(Some(r#"{"print": {"duplex": "printer"}}"#)).print_draft; + assert_eq!(d.duplex, None); + let d = open(Some(r#"{"print": {"duplex": "off"}}"#)).print_draft; + assert_eq!(d.duplex, Some(Duplex::Off)); +} + +/// #2001: each printer keeps the paper, sizing and two-sided choice it last printed with, across +/// a restart, and the printer printed to last is chosen again. A label printer keeps 4 × 6 in +/// at Actual size, single-sided, while the office printer keeps A4 at Fit, two-sided. +#[test] +fn each_printer_keeps_what_it_last_printed_with_across_a_restart() { + use pdfcraft_engine::print::spool::{Duplex, Job, Printer}; + use pdfcraft_engine::print::{LABEL_PAPER, PAPERS, SizeMode}; + use std::sync::{Arc, Mutex}; + let a4 = PAPERS.iter().position(|(name, _)| *name == "A4").unwrap(); + let listed = Arc::new(Mutex::new(vec!["Office", "D520BT_Z"])); + let jobs: Arc>> = Arc::default(); + let attach = |h: &mut Harness<'static, PdfCraftApp>| { + let source = listed.clone(); + h.state_mut().printer_source = Some(Arc::new(move || { + let names = source.lock().map(|n| n.clone()).unwrap_or_default(); + names + .into_iter() + .map(|name| Printer { name: name.into(), default: name == "Office", two_sided: None, description: String::new() }) + .collect() + })); + let sent = jobs.clone(); + h.state_mut().spooler = Some(Arc::new(move |_pdf: &[u8], job: &Job| { + sent.lock().unwrap().push(job.clone()); + Ok(String::new()) + })); + }; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().dialog = None; + assert!(h.state_mut().execute("print.dialog")); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().print_draft.printers_loading); + h.run_steps(2); + }; + let print = |h: &mut Harness<'static, PdfCraftApp>| -> Job { + let before = jobs.lock().unwrap().len(); + h.get_by(|n| n.label().as_deref() == Some("Print") && n.role() == Role::Button).click(); + for _ in 0..400 { + h.run_steps(1); + if jobs.lock().unwrap().len() > before { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + jobs.lock().unwrap().last().cloned().expect("the job reached the spooler") + }; + let setup = |h: &Harness<'static, PdfCraftApp>| { + let d = &h.state().print_draft; + (d.printer.clone(), d.paper, d.size, d.duplex) + }; + let label = (Some("D520BT_Z".to_string()), LABEL_PAPER, SizeMode::Actual, Some(Duplex::Off)); + let office = (Some("Office".to_string()), a4, SizeMode::Fit, Some(Duplex::LongEdge)); + + let mut h = harness(); + attach(&mut h); + open(&mut h); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "the system default first"); + { + let d = &mut h.state_mut().print_draft; + d.printer = Some("D520BT_Z".into()); + d.printer_picked = true; + d.paper = LABEL_PAPER; + d.size = SizeMode::Actual; + d.duplex = Some(Duplex::Off); + } + h.run_steps(2); + assert_eq!(print(&mut h).printer.as_deref(), Some("D520BT_Z")); + open(&mut h); + { + let d = &mut h.state_mut().print_draft; + d.printer = Some("Office".into()); + d.paper = a4; + d.size = SizeMode::Fit; + d.duplex = Some(Duplex::LongEdge); + } + h.run_steps(2); + assert_eq!(print(&mut h).printer.as_deref(), Some("Office")); + + // Choosing the label printer in the dialog brings back what it printed with. + open(&mut h); + assert_eq!(setup(&h), office, "the same printer keeps this session's choices"); + h.get_by_value("Office").click(); + h.run_steps(2); + h.get_by_label("D520BT_Z").click(); + h.run_steps(2); + assert_eq!(setup(&h), label); + let job = print(&mut h); + assert_eq!(job.duplex, Some(Duplex::Off)); + + // After a restart the label printer, printed to last, is chosen again with its setup, not + // the system default; choosing the office printer brings back A4, Fit and two-sided. + let saved = h.state().persist(); + let mut r = harness_with_settings(Some(saved.clone())); + attach(&mut r); + open(&mut r); + assert_eq!(setup(&r), label, "the label printer and its 4 × 6 in at Actual size, single-sided"); + r.get_by_value("D520BT_Z").click(); + r.run_steps(2); + r.get_by_label("Office (default)").click(); + r.run_steps(2); + assert_eq!(setup(&r), office); + + // A remembered printer that is gone falls back to the system default, with its own setup. + *listed.lock().unwrap() = vec!["Office"]; + let mut gone = harness_with_settings(Some(saved)); + attach(&mut gone); + open(&mut gone); + assert_eq!(setup(&gone), office); +} diff --git a/crates/ui-egui/tests/it/reading_colors.rs b/crates/ui-egui/tests/it/reading_colors.rs index ac48d89d05..557696dbf4 100644 --- a/crates/ui-egui/tests/it/reading_colors.rs +++ b/crates/ui-egui/tests/it/reading_colors.rs @@ -9,41 +9,7 @@ use pdfcraft_ui_egui::PdfCraftApp; /// One valid page (proper xref, so no repair banner covers the dialog): black text on white. fn pdf() -> Vec { - pdf_pages(1) -} - -fn pdf_pages(count: usize) -> Vec { - let mut objs: 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 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), - { - let body = b"BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET"; - format!("<< /Length {} >>\nstream\n{}\nendstream", body.len(), String::from_utf8_lossy(body)).into_bytes() - }, - b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), - ]; - let mut kids = vec!["3 0 R".to_owned()]; - for _ in 1..count { - kids.push(format!("{} 0 R", objs.len() + 1)); - objs.push(objs[2].clone()); - } - objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {count} >>", kids.join(" ")).into_bytes(); - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - out.extend_from_slice(o); - out.extend_from_slice(b"\nendobj\n"); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + crate::support::fixtures::pdf_pages(1) } #[test] @@ -61,7 +27,7 @@ fn pending_pages_use_document_reading_background() { ] { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("pending.pdf", None, pdf_pages(3)).unwrap(); + app.open_bytes("pending.pdf", None, crate::support::fixtures::pdf_pages(3)).unwrap(); let id = app.views[0].id; // Deliberately differ from the app preference: the document is the source of truth. app.session.set_reading_colors(id, colors); diff --git a/crates/ui-egui/tests/it/support/fixtures.rs b/crates/ui-egui/tests/it/support/fixtures.rs new file mode 100644 index 0000000000..d20cc1a3b1 --- /dev/null +++ b/crates/ui-egui/tests/it/support/fixtures.rs @@ -0,0 +1,37 @@ +//! Synthetic documents shared by the shell tests. + +/// `count` valid pages (proper xref, so no repair banner covers a dialog), each 300 x 200 pt with +/// black text on white. +pub fn pdf_pages(count: usize) -> Vec { + let mut objs: 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 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + { + let body = b"BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj ET"; + format!("<< /Length {} >>\nstream\n{}\nendstream", body.len(), String::from_utf8_lossy(body)).into_bytes() + }, + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + let mut kids = vec!["3 0 R".to_owned()]; + for _ in 1..count { + kids.push(format!("{} 0 R", objs.len() + 1)); + objs.push(objs[2].clone()); + } + objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {count} >>", kids.join(" ")).into_bytes(); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 0d5f15b68a..e6993666dd 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -8,6 +8,7 @@ use egui::epaint::text::{Fonts, TextOptions}; use egui_kittest::Harness; use pdfcraft_ui_egui::{PdfCraftApp, i18n, theme}; +pub mod fixtures; pub mod input; /// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 8b58a08aa5..3d70d6698f 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -489,6 +489,39 @@ fn read_mode_hides_the_quick_toolbar() { h.get_by_label("Select (V)"); } +/// All tools ▸ Edit a PDF turns on Edit text & images at once, as the Edit tab does, so the page +/// is editable without clicking Edit again. Another tool from there puts editing down. +#[test] +fn edit_a_pdf_from_all_tools_turns_on_editing() { + use pdfcraft_ui_egui::{Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().mode, Mode::AllTools); + assert_eq!(h.state().quick_tool, QuickTool::Select); + h.get_by_label("Edit a PDF").click(); + h.run_steps(3); + assert_eq!(h.state().mode, Mode::AllTools, "the workspace stays All tools, with its way back"); + assert_eq!(h.state().quick_tool, QuickTool::EditText, "Edit text & images is on at once"); + h.state_mut().set_option("tool", "organize").unwrap(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Select, "another tool puts editing down"); + // In the Edit workspace, another tool from its panel leaves editing on, as before. + h.state_mut().set_option("mode", "edit").unwrap(); + h.state_mut().set_option("tool", "edit").unwrap(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::EditText); + h.state_mut().set_option("tool", "organize").unwrap(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::EditText); + // Read mode hides the quick toolbar and keeps the tools down, so the control channel's + // `tool edit` there leaves editing off (review on #4605). + h.state_mut().run_command("view.read_mode"); + h.run_steps(2); + assert_eq!(h.state().mode, Mode::Read); + h.state_mut().set_option("tool", "edit").unwrap(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Select, "no editing tool in Read mode"); +} + /// Opening a document with Read as the default mode puts down the tool in use, as entering Read /// mode from the mode bar does: the hidden toolbar would leave no way to see or change it. #[test] diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index e6cc09d7c5..61a0e03ee5 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -169,6 +169,8 @@ fn turning_the_check_at_start_on_waits_for_the_next_start() { #[test] fn preferences_turn_the_check_at_start_off() { let (mut h, _calls) = started_with(r#"{"update_check_at_start": true}"#); + // The check at start answers on its own thread; its prompt would take the click below. + later_on_the_startup_prompt(&mut h); h.state_mut().execute("app.preferences"); h.run_steps(3); h.get_by_label("Check for updates when PdfCraft starts").click(); @@ -196,6 +198,12 @@ fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { app.set_startup_update_check(true); app }); + later_on_the_startup_prompt(&mut h); + assert!(h.query_by_label_contains("is available").is_none(), "Later closes the prompt"); +} + +/// Waits for the check at start to offer its prompt, then answers Later. +fn later_on_the_startup_prompt(h: &mut Harness<'static, PdfCraftApp>) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while h.query_by_label_contains("is available").is_none() { h.run_steps(2); @@ -204,5 +212,4 @@ fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { } h.get_by_label("LATER").click(); h.run_steps(3); - assert!(h.query_by_label_contains("is available").is_none(), "Later closes the prompt"); } diff --git a/docs/roadmap.md b/docs/roadmap.md index f870a91a2c..686928b06c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged) +> > **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged) > **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged) @@ -251,7 +253,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. DocumentDuplex-only bindings use the supported scope through craft-print 9fe50c94. create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | @@ -318,6 +320,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. | | 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. | | 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. | | 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 642268ef87..ae3261876d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -819,7 +819,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/render/src/raster/tests.rs::widget_appearance_states", "crates/render/src/raster/tests.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster/tests.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance", "crates/annot/src/tests.rs::a_four_entry_colour_with_a_null_is_cmyk_with_zero", "crates/forms/src/tests.rs::a_four_entry_mk_colour_with_a_null_is_cmyk_with_zero", "crates/annot/src/tests.rs::text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them", "crates/annot/src/tests.rs::text_markup_reach_stops_at_the_page_and_the_quad_cap", "crates/annot/src/tests.rs::text_markup_reach_reads_quads_like_the_drawing", "crates/annot/src/tests.rs::ink_whose_strokes_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::line_whose_points_reach_past_its_rect_is_drawn_through_them", "crates/annot/src/tests.rs::path_reach_stops_at_the_point_cap_and_is_saved_on_restyle", "crates/annot/src/tests.rs::indirect_border_style_and_colour_are_drawn", "crates/annot/src/tests.rs::callout_without_rd_is_drawn", "crates/forms/src/tests.rs::normal_appearances_that_give_no_stream_are_drawn", "crates/engine/src/tests.rs::text_boxes_and_redaction_marks_without_appearances_show_their_outlines", "crates/annot/src/tests.rs::free_text_da_fonts_draw_in_their_standard_face", "crates/annot/src/tests.rs::free_text_ds_style_is_drawn", "crates/annot/src/tests.rs::redaction_marks_with_quadpoints_take_their_outline_from_oc_then_c", "crates/annot/src/tests.rs::unapplied_redaction_marks_follow_their_oc_outline_colour"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved, with style values given by reference (a /BS, colour, /RD or /CL as an indirect object, ISO 32000-2 §7.3.10) followed and a callout without /RD (optional, Table 177) boxed at its /Rect; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text boxes without /BS or /Border get the /Border default 1-pt border (ISO 32000-2 Table 166), their /DA font when /DR or its resource name makes it one of the twelve standard text faces (others stay Helvetica), and /DS font, size and colour over /DA (no /RC rich text yet); unapplied redaction marks without /AP are outlined in an array /OC colour (three finite numbers) when they have one, with or without /QuadPoints; without one, marks with /QuadPoints keep /C (else red) and marks without /QuadPoints (Table 195: /Rect is the region) draw nothing; an /OC dictionary stays optional content (Table 166). Basis: ISO 32000-2 §12.5.6.23 says only that before redaction is applied the user can see, move and redefine these annotations, and Table 195 has no outline-colour entry, so the colour comes from black-box observation of two synthetic fixtures. noap3.pdf (generator attached to #4364), rows J/K/L, unapplied, without /QuadPoints: Adobe Acrobat Pro J nothing, K red outline, L nothing; Chrome (PDFium) and Edge nothing on all three; PDF-XChange Editor and Right PDF Editor a red outline on all three. oc.pdf, six marks without /AP (1 /OC blue; 2 /C green; 3 /OC blue + /C green; 4 /OC blue + /IC black; 5 no colour; 6 /OC blue + /QuadPoints; rebuilt inline in unapplied_redaction_marks_follow_their_oc_outline_colour): Acrobat blue on 1, 3, 4 and 6 and nothing on 2 and 5; Chrome and Edge nothing on all six; PDF-XChange Editor green on 2 and 3, nothing on the others; Right PDF Editor red on all six. The three readers tested that support redaction marks disagree, so PdfCraft follows the /OC colour, the one Acrobat uses; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget's /N gives no stream to draw (none, null, a dictionary without its stream data, or a state subdictionary without a stream for /AS; #3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup; text markup, lines, polylines, polygons and ink through their /QuadPoints, /L, /Vertices or /InkList where these reach past /Rect, as far as the crop box) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved, with style values given by reference (a /BS, colour, /RD or /CL as an indirect object, ISO 32000-2 §7.3.10) followed and a callout without /RD (optional, Table 177) boxed at its /Rect; a four-entry colour with a non-number in it (/C, /IC, /MK /BC and /BG written [1 0 0 null]) is read as CMYK with that entry 0; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text boxes without /BS or /Border get the /Border default 1-pt border (ISO 32000-2 Table 166), their /DA font when /DR or its resource name makes it one of the twelve standard text faces (others stay Helvetica), and /DS font, size and colour over /DA (no /RC rich text yet); unapplied redaction marks without /AP are outlined in an array /OC colour (three finite numbers) when they have one, with or without /QuadPoints; without one, marks with /QuadPoints keep /C (else red) and marks without /QuadPoints (Table 195: /Rect is the region) draw nothing; an /OC dictionary stays optional content (Table 166). Basis: ISO 32000-2 §12.5.6.23 says only that before redaction is applied the user can see, move and redefine these annotations, and Table 195 has no outline-colour entry, so the colour comes from black-box observation of two synthetic fixtures. noap3.pdf (generator attached to #4364), rows J/K/L, unapplied, without /QuadPoints: Adobe Acrobat Pro J nothing, K red outline, L nothing; Chrome (PDFium) and Edge nothing on all three; PDF-XChange Editor and Right PDF Editor a red outline on all three. oc.pdf, six marks without /AP (1 /OC blue; 2 /C green; 3 /OC blue + /C green; 4 /OC blue + /IC black; 5 no colour; 6 /OC blue + /QuadPoints; rebuilt inline in unapplied_redaction_marks_follow_their_oc_outline_colour): Acrobat blue on 1, 3, 4 and 6 and nothing on 2 and 5; Chrome and Edge nothing on all six; PDF-XChange Editor green on 2 and 3, nothing on the others; Right PDF Editor red on all six. The three readers tested that support redaction marks disagree, so PdfCraft follows the /OC colour, the one Acrobat uses; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget's /N gives no stream to draw (none, null, a dictionary without its stream data, or a state subdictionary without a stream for /AS; #3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP are not synthesized for display or printing; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" @@ -1907,8 +1907,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_prepare", "text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph"] -notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." +evidence = ["crates/edit/src/tests.rs::editing_legacy_thai_keeps_the_embedded_face_weight_size_spacing_and_paint_after_reopen", "crates/edit/src/tests.rs::editing_legacy_thai_shapes_a_run_across_subsets_of_the_same_face", "crates/edit/src/tests.rs::grouped_text_nested_past_the_level_limit_is_reported", "crates/edit/src/tests.rs::grouped_image_artwork_is_not_prepared", "crates/edit/src/tests.rs::grouped_text_in_an_rgb_group_needs_a_page_that_blends_in_rgb", "crates/fonts/src/pdf/thai.rs::recovered_thai_map_checks_every_kept_glyph_against_the_font_program", "crates/automation/tests/it/automation.rs::text_prepare_refuses_pages_without_grouped_text", "crates/fonts/src/pdf/tests.rs::composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program", "crates/automation/tests/it/automation.rs::grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs", "crates/edit/src/tests.rs::grouped_text_preparation_keeps_unsafe_compositing_paths_and_malformed_forms_intact", "crates/automation/tests/it/automation.rs::explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen", "crates/edit/src/tests.rs::existing_thai_subset_uses_shaped_matching_face_without_embedding_variable_font", "crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them", "crates/edit/src/tests.rs::ungroup_refuses_a_form_with_its_own_default_colour_space", "crates/edit/src/tests.rs::ungroup_renames_the_name_operand_the_renderer_reads", "crates/ui-egui/tests/it/editing.rs::one_click_on_grouped_text_opens_it_for_editing", "crates/edit/src/tests.rs::grouped_text_at_says_which_artwork_preparation_would_open", "crates/edit/src/tests.rs::legacy_thai_map_repair_needs_no_grouped_text", "crates/edit/src/tests.rs::ungroup_refuses_a_form_drawn_inside_text_or_a_path", "crates/edit/src/tests.rs::ungroup_reads_what_a_group_draws_within_one_allowance", "crates/edit/src/tests.rs::unicode_row_breaks_break_the_row_instead_of_needing_a_glyph", "crates/ui-egui/tests/it/ui.rs::edit_a_pdf_from_all_tools_turns_on_editing", "crates/ui-egui/tests/it/editing.rs::another_tool_from_edit_a_pdf_applies_the_open_paragraph"] +notes = "In Edit text, one click on text in a supported grouped Form prepares its page (one undoable step) and opens the paragraph clicked, so its existing source glyphs are edited directly; nothing is prepared while pages are only shown, a drag still moves the selected group, and a click on grouped artwork that preparation wouldn't open (a logo, a refused group) selects it as before. text_prepare does the same headlessly, with the same code as Ungroup. Unsafe groups remain intact and are reported by text_prepare and in the repair log when the page is prepared, as is text grouped more than eight levels deep; image-only forms are left grouped. Narrow legacy THSarabunPSK character-map recovery, checked glyph by glyph against the subset's own advances, is recorded, also for text that was never grouped (text_prepare). Legacy Thai source editing reuses original font programs, positional glyphs and same-face subsets (a Thai run split across subsets is shaped cluster by cluster; what no subset can show in its codes is shaped whole by a complete program of the face, such as one from Load font), preserving Regular/Bold, point size, spacing and paint. A different Thai family requires explicit selection; the Noto replacement is Regular only; mixed contextual style runs remain limited. The Edit tab turns Edit text & images on at once, as in Acrobat, and so does All tools ▸ Edit a PDF (also from Home, the command palette and the control channel); leaving the tab, or Edit a PDF for another tool from All tools, puts the tool down and applies an edit in progress; Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. The Unicode row breaks (U+2028, U+2029, U+0085, vertical tab, form feed and the information separators) break the row instead of being looked up as glyphs (#6272). Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside, are drawn translucent or blend in a colour space other than the page's are refused, as are forms with resources of their own whose /DefaultGray, /DefaultRGB or /DefaultCMYK differs from the page's, either side lacking it included (ISO 32000-2 §8.6.5.6), and forms drawn inside a text object or an unfinished path, each resource name is read from the operand the renderer uses (a surplus operand before it is ignored; an operator whose operands can't be read is refused), and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels." [[feature]] id = "edit.text-reflow" @@ -4806,8 +4806,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_script"] -evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text"] -notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I." +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text", "crates/ui-egui/tests/it/prepare.rs::the_calculate_tab_shows_a_custom_calculation_script", "crates/ui-egui/tests/it/prepare.rs::an_edited_custom_script_is_written_and_no_calculation_removes_it", "crates/ui-egui/tests/it/prepare.rs::an_edit_past_256_kib_is_refused_and_the_next_edit_is_taken", "crates/ui-egui/tests/it/prepare.rs::a_script_past_256_kib_shows_its_start_and_is_kept", "crates/forms/src/tests.rs::a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it"] +notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. Field Properties ▸ Calculate ▸ Custom calculation script shows a field's calculate script and edits it (of one past 256 KiB the start is shown, read-only, and the script is kept); the Format and Validate tabs don't offer their custom scripts yet, so those are set with form_set_script. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I." [[feature]] id = "form.calculation-order" @@ -5363,8 +5363,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console" @@ -8189,8 +8189,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] -notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::each_printer_keeps_what_it_last_printed_with_across_a_restart", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/workflows/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/workflows/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper", "crates/ui-egui/tests/it/print.rs::print_preferences_survive_a_restart_but_the_jobs_own_choices_reset"] +notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name. Two-sided (including the printer's own default), grayscale, collate, page sizing and Comments & forms are remembered across restarts (#2001); copies, pages, layout and orientation start from the defaults each time the dialog opens (ISO 32000-2 Table 147 initialises them per document). CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752). Not yet: the document's own NumCopies, PrintPageRange, Duplex and PrintScaling viewer preferences as the dialog's initial values. Each printer printed to also keeps its own paper, page sizing (Fit, Actual size, Shrink, Custom %) and two-sided choice across restarts, brought back when it is chosen, and the printer printed to last is preselected while it is listed, else the system default (at most 32 printers)." [[feature]] id = "print.printer-selection" @@ -8202,7 +8202,7 @@ status = "shipped" commands = ["print.dialog"] tools = ["printers", "printer_options", "doc_print"] evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"] -notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer." +notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer. The copy sent to the Windows spooler renames every embedded TrueType/OpenType program (name table, /BaseFont, /FontName), because Windows drew a subset's renumbered glyphs with the installed font of the same name and printed shifted letters (#3132); not yet checked on a physical Windows printer." [[feature]] id = "print.copies-collate" @@ -8273,8 +8273,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/workflows/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer"] -notes = "long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting. Two-sided starts at Printer default, as in Acrobat: nothing is sent, so the printer's own default (its Properties) applies until Off or an edge is chosen (doc_print: duplex \"printer\"). Missing: the document's /ViewerPreferences /Duplex preset isn't read yet" +evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/ui-egui/src/workflows/print_ui.rs::two_sided_follows_the_printer_until_chosen", "crates/automation/src/printing.rs::duplex_printer_leaves_two_sided_to_the_printer", "crates/print/src/spool/windows_duplex.rs::printer_default_reaches_the_windows_job_for_each_binding", "crates/print/src/spool/windows_duplex.rs::failed_or_invalid_settings_fall_back_and_keep_the_resolved_queue", "crates/print/src/spool/windows_duplex.rs::manual_duplex_defaults_remain_unspecified_without_automatic_support"] +notes = "Manual two-sided defaults stay unspecified unless driver capabilities confirm automatic duplex (#5772); long/short edge via the spooler: lp -o sides=…, and the Windows driver's Duplex setting. Two-sided starts at Printer default: Windows reads the driver's current DEVMODE duplex and sends it explicitly (#5130), logging lookup failures and leaving duplex unspecified as a fallback; CUPS leaves duplex unspecified so the queue default applies (doc_print: duplex \"printer\"). Off or an edge overrides either. Physical output still needs hardware validation. Missing: the document's /ViewerPreferences /Duplex preset isn't read yet" [[feature]] id = "print.n-up" @@ -8297,8 +8297,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"] -notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707)" +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge", "crates/print/src/tests.rs::booklet_creep_moves_inner_sheets_toward_the_fold"] +notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707); creep compensation (points per sheet, default 0) moves each sheet's pages toward the fold by their sheet's place from the outside, clipped at the fold (#6777); the amount is entered, not worked out from the paper's thickness" [[feature]] id = "print.poster" @@ -8333,8 +8333,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects"] -notes = "Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources" +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print"] +notes = "Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources" [[feature]] id = "print.print-with-comment-summary" @@ -8369,8 +8369,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/it/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/it/automation.rs::doc_print_as_image"] -notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them." +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/it/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/it/automation.rs::doc_print_as_image", "crates/automation/src/printing.rs::grey_pictures_dont_ask_the_driver_for_grayscale", "crates/ui-egui/src/workflows/print_ui.rs::grey_pictures_dont_ask_the_driver_for_grayscale"] +notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too and the printer isn't asked for it: a driver that keeps no grayscale setting (Microsoft Print to PDF) prints them (#5806). Still missing against Acrobat: a grey conversion of the vector pages without rendering them, so vector pages to such a driver are refused rather than printed in colour." [[feature]] id = "print.print-as-image" diff --git a/vendor/README.md b/vendor/README.md index 232955947e..4e6707f772 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,11 +39,11 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so); (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | +| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most as many symbol pixels per symbol dictionary as the largest page bitmap holds, 2^28 (a fuzzed dictionary decoded for minutes; each symbol now pays for its pixels out of the stream's decoding work and is held in the retained bitmaps, and at the earlier 2^26 the soft mask of a scanned drawing, one dictionary of 7,559 symbols, was refused: #6722), and a refusal by one of the decoder's limits is told apart from damaged data (`DecodeError::is_limit`, `OverflowError::BitmapLimit`); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding (about a second at the earlier 2^26 bound; not run at 2^28), `raster::tests::a_symbol_dictionary_holds_as_many_pixels_as_a_page`, and for `is_limit` and `BitmapLimit` `error::pdfcraft_limit_tests`, `bitmap::pdfcraft_limit_tests` and `gray_scale::pdfcraft_limit_tests` (hayro-jbig2); (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (36) a stream with no `endstream` left after it is the bytes its `/Length` gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 7.3.8.2), when the file holds that many (`parse_declared`: first, before the search for `endstream`, when the `/Length` ends before a whole `endstream` token, or a whole `endstrea` or `endobj` token after which the object ends too (the end of the data, another object's `N G obj` header, `xref`, `trailer` or `startxref`: not payload such as `endobject` or `endobj more`, ISO 32000-2 7.2.3), or at the end of the data, so the search can't run on into a later object's stream; then after the search finds none; `parse_proper` and the search for `endstream` take only a whole token, not `endstreamer`): a producer that writes `endstrea` after every stream lost every font and page content, and its pages drew blank while other readers draw them; (37) a document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25: every encrypting crypt filter is EFOpen, `/EFF` names one, `/StmF` and `/StrF` don't), opens without a password with the pass-through decryptor: its strings and streams are in the clear and nothing drawn reads an embedded file; (38) a page box that encloses no area is read as a missing one: a `/MediaBox` without area (`[0 0 0 0]`) as missing (an inherited one with area, else the default page, US Letter since patch 44), and a `/CropBox` without area, or with none in common with the media box, as missing (an inherited one, else the media box; ISO 32000-2 §14.11.2, Table 33); kept, the visible region had no area, so nothing was drawn and no text extracted (pdf.js `boundingBox_invalid`); (40) the last Flate filter of an image whose PNG predictor (`/Predictor` 10–15) its writer declared but never applied gives the image's samples as they are: when the inflated data is exactly `/Height` rows of the predictor's row length with no filter-type byte before each (where predicted rows would need one more byte each, ISO 32000-2 §7.4.4.4), `untagged_png_rows` takes it for unpredicted samples and reports it (`warn!`), as PDFium, MuPDF and Poppler read it; read with the predictor, each row's first sample was taken for a filter type and the image came out a row short and shifted, drawn in fragments (PDFBOX-3932). `Filter::apply` is given the image's rows for its last filter only; any other length is predicted as before; (41) CCITT `/Rows` above the image's `/Height` decodes to the height too, so the decoder is never asked for more rows than the image has (ISO 32000-2 Table 87): a scan with `/Rows 1084975104` on 2,592 columns had the size check refuse every page, and all 17 drew blank (PDFBOX-771); `/Rows` still counts for a stream decoded without an image's height; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels; (43) a last update cut off before its trailer (the last `startxref` more than 4 KiB from the end of the file, as `pdfcraft-cos` judges the footer, with `N G obj` headers after it: `cut_off_update_follows`) is read by rebuilding the table from object headers (`Pdf::new_with_password` calls `fallback` first, after the table the last `startxref` names has refused a wrong password, and takes the rebuilt table only when it is encrypted exactly when that one is), the latest definition of each object winning, as `pdfcraft-cos` reads such a file; the older table the last `startxref` names is read only when the rebuilt table has no page (a file cut short inside the update, whose new page tree names pages that are not there). The renderer had shown the older revision while the document model read the newer one (PDFBOX-3934: 12 pages against 13; ISO 32000-2 §7.5.6). A `startxref` near the end, or one followed only by data that holds no object header, is read as before; (44) a page with no `/MediaBox`, its own or inherited, or one without area (`[0 0 0 0]`, which now counts as missing, so the page is placed and clipped by the region it is sized by and its content shows), is US Letter (`LETTER`, 612 × 792 pt), not A4, and so is the `base_dimensions` fallback for a visible region without area: ISO 32000-2 Table 31 requires the box and gives no default, other readers use Letter, and PdfCraft's page model and inspector already did (PDFBOX-4001); (45) `root_xref`: when the newest cross-reference section names no earlier one (no `/Prev`, ISO 32000-2 §7.5.6, §7.5.8.2; a hybrid section's `/XRefStm` stream belongs to the same update and doesn't count, §7.5.8.4), the file holds another `startxref`, and the catalog's `/Pages` can't be read, the table is rebuilt by scanning the file (`XRef::repair`) before the page tree is read, so the cut-off page tree is found instead of the page dictionaries `Pages::new_brute_force` turns up (the signed files of PDFBOX-2720 and PDFBOX-2942 showed 2 blank pages for 1, 10, 13 and 1754); `Pdf::rebuilt_unlinked_update` says so, and `pdfcraft-cos` rebuilds under the same conditions | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (36) `raster::tests::a_content_stream_without_endstream_is_drawn_to_its_length`, `raster::tests::a_content_stream_without_endstream_is_not_read_into_a_later_stream`, `raster::tests::hayro_reads_a_stream_without_endstream_to_its_length_before_a_later_one`, `raster::tests::hayro_does_not_end_a_stream_inside_a_longer_word` (pdfcraft-render); (37) `inspect::tests::a_document_encrypting_only_its_embedded_files_opens_without_a_password`; (38) `text::tests::text_on_a_page_whose_box_has_no_area_is_extracted` (pdfcraft-render); (40) `filter::lzw_flate::pdfcraft_png_predictor_tests::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples`, `an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone`, `data_of_neither_length_decodes_as_it_did`, `rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic` (hayro-syntax), `raster::tests::png_predicted_images::an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples`, `an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor`; (41) `raster::tests::group4_fax_with_rows_past_its_height_decodes_to_the_image_height`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images`; (43) `raster::tests::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them`, `raster::tests::a_complete_update_reads_as_before`, `raster::tests::trailing_data_without_objects_reads_as_before`, `raster::tests::a_short_cut_off_update_reads_as_before`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt`, `raster::tests::trailing_data_with_an_object_header_reads_as_the_model_reads_it`, `raster::tests::an_update_cut_off_before_its_new_pages_keeps_the_older_table`, `raster::tests::an_encrypted_file_with_a_cut_off_update_still_needs_its_password`, `raster::tests::a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken` (pdfcraft-render); (44) `raster::tests::a_page_without_a_media_box_is_letter`, `raster::tests::raw_crop_unit_floor_preserves_nearly_zero_fallback` (pdfcraft-render), `pdfcraft-model::tests::{a_page_without_a_media_box_is_letter, a_page_whose_media_box_has_no_area_is_letter}`; (45) `inspect::core::unlinked_update_tests::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt`, `inspect::core::unlinked_update_tests::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it` (pdfcraft-render) | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (30) a stream's own crypt filter: `/Crypt` in its chain read as the filter it names, `/Identity` without one, instead of the document's filter and then a filter that can't be decoded, which left such a stream unreadable in any spelling (ISO 32000-2 §7.4.10); (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not, and `jbig2_limited` says whether the image decoded last on the thread was refused by one of the decoder's limits rather than its data, and `jbig2_limited_by_stream` whether that limit is one the stream reaches by itself rather than its decoding work, which may be the page's; `clear_jbig2_limited` and each new page forget it; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space; (34) a table rebuilt by scanning the file (`fallback_xref_map`) finds an object by its number when its header names another generation, including one no generation may be (`3 18446744073709551616 obj` in `poppler-85140-0.pdf`, #5580): the header is read leniently only in that scan (`read_object_header`), the object is also filed under generation 0 unless a real `N 0 obj` holds that key (a later header for the number moves the stand-in), `XRef::get_with` matches by object number only in a repaired table, and `XRef::objects` yields each location once; (35) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture; (36) a stream with no `endstream` left after it is the bytes its `/Length` gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 7.3.8.2), when the file holds that many (`parse_declared`: first, before the search for `endstream`, when the `/Length` ends before a whole `endstream` token, or a whole `endstrea` or `endobj` token after which the object ends too (the end of the data, another object's `N G obj` header, `xref`, `trailer` or `startxref`: not payload such as `endobject` or `endobj more`, ISO 32000-2 7.2.3), or at the end of the data, so the search can't run on into a later object's stream; then after the search finds none; `parse_proper` and the search for `endstream` take only a whole token, not `endstreamer`): a producer that writes `endstrea` after every stream lost every font and page content, and its pages drew blank while other readers draw them; (37) a document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent /EFOpen`, ISO 32000-2 §7.6.6, Table 25: every encrypting crypt filter is EFOpen, `/EFF` names one, `/StmF` and `/StrF` don't), opens without a password with the pass-through decryptor: its strings and streams are in the clear and nothing drawn reads an embedded file; (38) a page box that encloses no area is read as a missing one: a `/MediaBox` without area (`[0 0 0 0]`) as missing (an inherited one with area, else the default page, US Letter since patch 44), and a `/CropBox` without area, or with none in common with the media box, as missing (an inherited one, else the media box; ISO 32000-2 §14.11.2, Table 33); kept, the visible region had no area, so nothing was drawn and no text extracted (pdf.js `boundingBox_invalid`); (40) the last Flate filter of an image whose PNG predictor (`/Predictor` 10–15) its writer declared but never applied gives the image's samples as they are: when the inflated data is exactly `/Height` rows of the predictor's row length with no filter-type byte before each (where predicted rows would need one more byte each, ISO 32000-2 §7.4.4.4), `untagged_png_rows` takes it for unpredicted samples and reports it (`warn!`), as PDFium, MuPDF and Poppler read it; read with the predictor, each row's first sample was taken for a filter type and the image came out a row short and shifted, drawn in fragments (PDFBOX-3932). `Filter::apply` is given the image's rows for its last filter only; any other length is predicted as before; (41) CCITT `/Rows` above the image's `/Height` decodes to the height too, so the decoder is never asked for more rows than the image has (ISO 32000-2 Table 87): a scan with `/Rows 1084975104` on 2,592 columns had the size check refuse every page, and all 17 drew blank (PDFBOX-771); `/Rows` still counts for a stream decoded without an image's height; (42) TIFF predictor 2 on 8- and 16-bit samples (`lzw_flate::undo_tiff_bytes`) is undone a byte row at a time on slices, each sample added to the same component of the pixel to its left, wrapping at the sample's width (16-bit samples big-endian), instead of through the bit-level readers 1-, 2- and 4-bit samples still need; the bytes are those the bit-level path wrote, and a partial last row is still dropped. A page of 45 full-page 2445 × 4724 8-bit grey images so predicted (PDFBOX-1350) took about 23 s to render at 96 dpi and now takes about 4 s, with the same pixels; (43) a last update cut off before its trailer (the last `startxref` more than 4 KiB from the end of the file, as `pdfcraft-cos` judges the footer, with `N G obj` headers after it: `cut_off_update_follows`) is read by rebuilding the table from object headers (`Pdf::new_with_password` calls `fallback` first, after the table the last `startxref` names has refused a wrong password, and takes the rebuilt table only when it is encrypted exactly when that one is), the latest definition of each object winning, as `pdfcraft-cos` reads such a file; the older table the last `startxref` names is read only when the rebuilt table has no page (a file cut short inside the update, whose new page tree names pages that are not there). The renderer had shown the older revision while the document model read the newer one (PDFBOX-3934: 12 pages against 13; ISO 32000-2 §7.5.6). A `startxref` near the end, or one followed only by data that holds no object header, is read as before; (44) a page with no `/MediaBox`, its own or inherited, or one without area (`[0 0 0 0]`, which now counts as missing, so the page is placed and clipped by the region it is sized by and its content shows), is US Letter (`LETTER`, 612 × 792 pt), not A4, and so is the `base_dimensions` fallback for a visible region without area: ISO 32000-2 Table 31 requires the box and gives no default, other readers use Letter, and PdfCraft's page model and inspector already did (PDFBOX-4001); (45) `root_xref`: when the newest cross-reference section names no earlier one (no `/Prev`, ISO 32000-2 §7.5.6, §7.5.8.2; a hybrid section's `/XRefStm` stream belongs to the same update and doesn't count, §7.5.8.4), the file holds another `startxref`, and the catalog's `/Pages` can't be read, the table is rebuilt by scanning the file (`XRef::repair`) before the page tree is read, so the cut-off page tree is found instead of the page dictionaries `Pages::new_brute_force` turns up (the signed files of PDFBOX-2720 and PDFBOX-2942 showed 2 blank pages for 1, 10, 13 and 1754); `Pdf::rebuilt_unlinked_update` says so, and `pdfcraft-cos` rebuilds under the same conditions | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (31) `jbig2_limited`: `raster::tests::a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `raster::tests::a_limit_refusal_says_nothing_of_the_next_image`, `raster::tests::failed_image_decodes::a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw`; (30) `raster::tests::a_stream_with_a_crypt_filter_of_its_own_is_drawn`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax); (34) `raster::tests::a_rebuilt_table_finds_an_object_whose_header_names_another_generation`, `raster::tests::a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object` (pdfcraft-render), `xref::tests::an_object_stream_and_its_first_member_are_both_enumerated` (hayro-syntax); (35) `filter::lzw_flate::pdfcraft_png_predictor_tests` (hayro-syntax: every filter type at 1–16 bits and 1–4 colours against the PNG specification's byte-by-byte definition, a row ending part-way through a pixel, Average and Paeth over 6-byte pixels), `raster::tests::png_predicted_images::a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared`; (36) `raster::tests::a_content_stream_without_endstream_is_drawn_to_its_length`, `raster::tests::a_content_stream_without_endstream_is_not_read_into_a_later_stream`, `raster::tests::hayro_reads_a_stream_without_endstream_to_its_length_before_a_later_one`, `raster::tests::hayro_does_not_end_a_stream_inside_a_longer_word` (pdfcraft-render); (37) `inspect::tests::a_document_encrypting_only_its_embedded_files_opens_without_a_password`; (38) `text::tests::text_on_a_page_whose_box_has_no_area_is_extracted` (pdfcraft-render); (40) `filter::lzw_flate::pdfcraft_png_predictor_tests::an_image_whose_rows_carry_no_png_tags_is_read_as_its_samples`, `an_image_whose_rows_carry_png_tags_still_has_its_predictor_undone`, `data_of_neither_length_decodes_as_it_did`, `rows_of_no_bytes_or_of_more_than_memory_holds_do_not_panic` (hayro-syntax), `raster::tests::png_predicted_images::an_image_and_soft_mask_whose_rows_carry_no_png_tags_draw_their_samples`, `an_image_one_byte_short_of_untagged_rows_still_goes_through_the_predictor`; (41) `raster::tests::group4_fax_with_rows_past_its_height_decodes_to_the_image_height`; (42) `filter::lzw_flate::pdfcraft_tiff_predictor_tests` (hayro-syntax: random 8- and 16-bit rows at 1–7 colours and 1–50 columns against the bit-level path, partial and empty rows, and a row counter showing whole-byte samples skip the bit-level path), `raster::tests::tiff_predicted_images`; (43) `raster::tests::an_update_cut_off_before_its_trailer_shows_its_pages_as_the_model_reads_them`, `raster::tests::a_complete_update_reads_as_before`, `raster::tests::trailing_data_without_objects_reads_as_before`, `raster::tests::a_short_cut_off_update_reads_as_before`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_delimiter_is_rebuilt`, `raster::tests::a_cut_off_update_with_obj_followed_by_a_comment_is_rebuilt`, `raster::tests::trailing_data_with_an_object_header_reads_as_the_model_reads_it`, `raster::tests::an_update_cut_off_before_its_new_pages_keeps_the_older_table`, `raster::tests::an_encrypted_file_with_a_cut_off_update_still_needs_its_password`, `raster::tests::a_rebuild_whose_trailer_lacks_the_encryption_dictionary_is_not_taken` (pdfcraft-render); (44) `raster::tests::a_page_without_a_media_box_is_letter`, `raster::tests::raw_crop_unit_floor_preserves_nearly_zero_fallback` (pdfcraft-render), `pdfcraft-model::tests::{a_page_without_a_media_box_is_letter, a_page_whose_media_box_has_no_area_is_letter}`; (45) `inspect::core::unlinked_update_tests::an_update_without_prev_shows_the_page_tree_and_says_it_was_rebuilt`, `inspect::core::unlinked_update_tests::a_hybrid_update_without_prev_is_rebuilt_as_the_document_model_rebuilds_it` (pdfcraft-render) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical; (2) `StripStorage::room_for_path{,_within}`: whether one more path's coverage (at most the whole surface, counted twice) still fits below 2^31 values, the most a strip's packed alpha index reaches (`Strip::new` asserts it), or below a smaller limit; (4) `flatten::stroke`: when kurbo 0.13.1's stroker returns an outline containing NaN, which `fill` would drop whole, the path is stroked again from a copy (`robust_for_stroking`, one element out per element in) in which each curve whose control points lie within the stroke tolerance of its chord is a line (the #4907 fix, a nearly straight cubic given a NaN join, kurbo issues #617 and #519; its row here was lost in a merge and is restored with this entry) and each cubic whose second control point is its end point, as `y` builds (ISO 32000-2 §8.5.2.1 Table 58), has that control point moved the tolerance towards its first (`open_zero_end_handle`; the curve moves by at most 4/9 of the tolerance): kurbo's cusp search on such a curve's offset can stop on the end point, where the derivative is zero, and normalising it gives NaN, so at 16, 32 and 63 pt a two-curve `y` path drew nothing and a file in the PDFBox tracker lost its 32 pt "XX" outline. Only an exact zero triggers it (none of 192,000 random strokes with a handle 1e-9 long, nor of 256,000 `v` strokes, came back with NaN; 1,073 of 192,000 random `y`/`v` strokes at 1–64 pt did before and none after). A finite outline takes the old path unchanged | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate; (2) `strip_generator::pdfcraft_tests::strips_have_room_until_a_surface_short_of_the_limit`; (4) `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render crate's `stroke_degenerate_cubic` fail without the near-line replacement, `flatten::tests::a_wide_stroke_of_a_curve_with_a_zero_end_tangent_is_drawn` (widths 1–64, every join and cap, `y` and the mirrored `v`) and the render crate's `stroke_zero_end_tangent` fail without the moved control point; `a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` pass either way | diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index 9c633c8738..cce3fa57dc 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -93,7 +93,10 @@ impl Cache { /// and gets nothing, as the decode did. A failure counts [`FAILED_DECODE_BYTES`] towards the /// budget, so failures can't grow the map without bound either. A failure that may come from /// what the page had left (its JBIG2 allowance, `ContentTruncated`) isn't kept: another page, -/// or the same page drawn again, starts with a fresh allowance. +/// or the same page drawn again, starts with a fresh allowance. One that left out a JBIG2 mask +/// for a limit its stream reaches by itself is kept, with the image it takes along (patch 73, +/// [`note_stream_limited`]): decoding them again would only refuse them again, and an image of +/// any size under such a mask was decoded again at every draw. #[derive(Clone)] pub(crate) struct DecodedImageCache { inner: Arc>, @@ -142,6 +145,26 @@ pub(crate) struct DecodedImageKey { /// entry take in the map. const FAILED_DECODE_BYTES: usize = 128; +/// PdfCraft patch (91): with the warnings of a failure kept, that it left out a JBIG2 mask for a +/// limit its stream reaches by itself ([`note_stream_limited`]); no warning's flag. +const STREAM_LIMITED: u8 = 128; + +std::thread_local! { + static LEFT_OUT_BY_STREAM_LIMIT: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// PdfCraft patch (73): note, wherever a decode reports `ContentTruncated` for an image or a kept +/// failure replays it, whether that left out a JBIG2 mask for a limit its stream reaches by itself +/// (`hayro_syntax::jbig2_limited_by_stream`): its failure, and the failure of the image it takes +/// along, is then the stream's to keep, since decoding it again refuses it again. +pub(crate) fn note_stream_limited(limited: bool) { + LEFT_OUT_BY_STREAM_LIMIT.with(|left_out| left_out.set(limited)); +} + +fn stream_limited() -> bool { + LEFT_OUT_BY_STREAM_LIMIT.with(std::cell::Cell::get) +} + /// PdfCraft patch (91): what a [`DecodedImageCache`] keeps for a key. enum DecodedEntry { /// The decoded image. @@ -244,6 +267,7 @@ impl DecodedImageCache { match hit { Some(Ok(hit)) => return Some(hit), Some(Err(bits)) => { + note_stream_limited(bits & STREAM_LIMITED != 0); warnings_in(bits).for_each(|warning| sink(warning)); return None; } @@ -278,12 +302,12 @@ impl DecodedImageCache { Some(DecodedEntry::Failed(_)) if val.is_none() => return None, _ => {} } + let truncated = reported & warning_bit(InterpreterWarning::ContentTruncated) != 0; let size = match &val { Some(v) => size_of(v), - // A failure the page's JBIG2 allowance may have caused isn't the image's to keep. - None if reported & warning_bit(InterpreterWarning::ContentTruncated) != 0 - || hayro_syntax::page_jbig2_work_spent() => - { + // A failure the page's JBIG2 allowance may have caused isn't the image's to keep, nor + // one a JBIG2 decode's work did, which may be the page's too. + None if hayro_syntax::page_jbig2_work_spent() || (truncated && !stream_limited()) => { return None; } None => FAILED_DECODE_BYTES, @@ -297,6 +321,8 @@ impl DecodedImageCache { } let entry = match &val { Some(val) => DecodedEntry::Decoded(Box::new(val.clone())), + // Here only when a limit of its stream refused the mask that left it out (above). + None if truncated => DecodedEntry::Failed(reported | STREAM_LIMITED), None => DecodedEntry::Failed(reported), }; // An entry replaced (a failure another thread kept) isn't given back: the budget diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index cd70f480c7..8beee094c5 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -168,6 +168,8 @@ pub enum InterpreterWarning { /// An image failed to decode. ImageDecodeFailure, /// PdfCraft patch (18): a page content stream was skipped after exhausting its safety budget. + /// Patch (73): or an image was left out for the page's JBIG2 allowance, or because its JBIG2 + /// mask is past the allowance or one of the decoder's limits. ContentTruncated, /// PdfCraft patch (55): 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 diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 27393efd3e..71004657e0 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -24,6 +24,8 @@ use smallvec::{SmallVec, smallvec}; use std::borrow::Cow; use std::iter; use std::ops::Deref; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; pub(crate) enum XObject<'a> { FormXObject(FormXObject<'a>), @@ -495,6 +497,12 @@ pub(crate) struct ImageXObject<'a> { interpolate: bool, is_mask: bool, is_stencil_mask: bool, + /// PdfCraft patch (73): whether this is a picture's soft mask or `/Mask`, not an image drawn + /// on its own, by `Do` or inline, a stencil mask included. Only a picture's mask refused by a + /// limit takes something out with it, and is reported so. A picture's mask is cached only + /// averaged down (`shrunk_to`), which an image drawn on its own never is, so the two never + /// share an entry and its warnings. + masks_picture: bool, stream: Stream<'a>, transfer_function: Option, warning_sink: WarningSinkFn, @@ -523,6 +531,7 @@ impl<'a> ImageXObject<'a> { .get::(IM) .or_else(|| dict.get::(IMAGE_MASK)) .unwrap_or(false); + let masks_picture = is_mask; is_mask |= is_stencil_mask; let mut default_space = None; @@ -607,6 +616,7 @@ impl<'a> ImageXObject<'a> { stream: stream.clone(), is_mask, is_stencil_mask, + masks_picture, default_space, }) } @@ -939,7 +949,16 @@ fn decode_context<'a>( // PdfCraft patch: images and masks over `MAX_IMAGE_PIXELS` are not decoded at all (the // renderer skips them anyway): a fuzzed `/W 4294967295` stencil mask allocated gigabytes here. if !image_size_ok(obj.width, obj.height) { - (obj.warning_sink)(InterpreterWarning::ImageDecodeFailure); + // PdfCraft patch (73): a picture's JBIG2 mask of a size this bound refuses is one a limit + // refused, as when the decoder refuses it (below), and its size is the stream's own. + // `ImageXObject::new` refuses a size of 0, so this is past the bound. + let limited = obj.masks_picture && uses_jbig2(&obj.stream); + crate::cache::note_stream_limited(limited); + (obj.warning_sink)(if limited { + InterpreterWarning::ContentTruncated + } else { + InterpreterWarning::ImageDecodeFailure + }); return None; } let dict = obj.stream.dict(); @@ -958,13 +977,26 @@ fn decode_context<'a>( height: obj.height, }; + // PdfCraft patch (73): what refused an image decoded before this one says nothing about it, + // whether or not its filters reach JBIG2. + hayro_syntax::clear_jbig2_limited(); let decoded = obj .stream .decoded_image(&decode_params) .map_err(|_| { // PdfCraft patch (73): left out for the page's allowance, it is content the page's - // budget skipped, which is reported, and no failure of this image. - (obj.warning_sink)(if hayro_syntax::page_jbig2_work_spent() && uses_jbig2(&obj.stream) { + // budget skipped, which is reported, and no failure of this image. So is a picture's + // mask one of the decoder's limits refused, which takes the picture with it + // (`decode_raster`). + // Reported so, the decoded-image cache (patch 91) keeps the failure only when the + // limit is one the stream reaches by itself, whatever the page had left. + let limited = obj.masks_picture && hayro_syntax::jbig2_limited(); + let left_out = + (hayro_syntax::page_jbig2_work_spent() || limited) && uses_jbig2(&obj.stream); + crate::cache::note_stream_limited( + left_out && obj.masks_picture && hayro_syntax::jbig2_limited_by_stream(), + ); + (obj.warning_sink)(if left_out { InterpreterWarning::ContentTruncated } else { InterpreterWarning::ImageDecodeFailure @@ -1197,6 +1229,20 @@ fn decode_raster( let mut image = image_data?; + // PdfCraft patch (73): whether the mask, decoded below or kept failed by the decoded-image + // cache, was left out for the page's allowance or a decoder's limit: only those report + // `ContentTruncated` there (`decode_context`). + let left_out = Arc::new(AtomicBool::new(false)); + let sink: WarningSinkFn = { + let (left_out, sink) = (left_out.clone(), obj.warning_sink.clone()); + Arc::new(move |warning| { + if matches!(warning, InterpreterWarning::ContentTruncated) { + left_out.store(true, Ordering::Relaxed); + } + sink(warning); + }) + }; + let obj = &obj.reporting_to(&sink); let alpha = if let Some((alpha, matte_rgb)) = resolve_matte(obj, &ctx.color_space, target_dimension) && alpha.width == ctx.width @@ -1223,11 +1269,14 @@ fn decode_raster( }; // PdfCraft patch (73): a bad mask leaves the image as it is, but one left out for the page's - // allowance takes the image with it. Both are reported, and only here is the file not to blame - // for what the mask would have hidden being shown. + // allowance, or refused by one of the decoder's limits, takes the image with it. Both are + // reported, and only there is the file not to blame for what the mask would have hidden being + // shown: a scan's colour layer drawn without its JBIG2 mask covers the page in noise (#6722). + // A size or count the file damaged can read as past a limit, and is taken for one: it can't + // be told from a hostile one, and leaving the image out is the safe side. let dict = obj.stream.dict(); let mask = dict.get::>(SMASK).or_else(|| dict.get::>(MASK)); - if alpha.is_none() && hayro_syntax::page_jbig2_work_spent() && mask.is_some_and(|m| uses_jbig2(&m)) { + if alpha.is_none() && left_out.load(Ordering::Relaxed) && mask.is_some_and(|m| uses_jbig2(&m)) { return None; } diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index c4228595d4..a400831454 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -70,10 +70,10 @@ pub(crate) const BITMAP_WORDS: usize = size_of::() / size_of::(); /// output from the page header in between, and a 743-byte PDF's Indexed image asked for 137 GB. pub(crate) fn check_page_size(width: u32, height: u32) -> Result<()> { if width > MAX_DIMENSION || height > MAX_DIMENSION { - return Err(OverflowError::BitmapDimension.into()); + return Err(OverflowError::BitmapLimit.into()); } if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(OverflowError::BitmapDimension.into()); + return Err(OverflowError::BitmapLimit.into()); } Ok(()) } @@ -129,11 +129,11 @@ impl Bitmap { default_pixel: bool, ) -> Result { if width > MAX_DIMENSION || height > MAX_DIMENSION { - bail!(OverflowError::BitmapDimension); + bail!(OverflowError::BitmapLimit); } // PdfCraft patch: see `MAX_PIXELS`. if u64::from(width) * u64::from(height) > MAX_PIXELS { - bail!(OverflowError::BitmapDimension); + bail!(OverflowError::BitmapLimit); } let stride = width.div_ceil(WORD_BITS); @@ -357,3 +357,35 @@ impl Bitmap { Ok(area) } } + +#[cfg(test)] +mod pdfcraft_limit_tests { + use super::*; + use crate::error::DecodeError; + + /// PdfCraft patch: a bitmap past the bound on a side or on pixels is refused by a limit. + #[test] + fn a_bitmap_past_its_bounds_is_refused_by_a_limit() { + let limit = Err(DecodeError::Overflow(OverflowError::BitmapLimit)); + assert_eq!( + Bitmap::new(MAX_DIMENSION + 1, 1).map(|_| ()), + limit, + "a side" + ); + assert_eq!( + Bitmap::new(1, MAX_DIMENSION + 1).map(|_| ()), + limit, + "the other" + ); + // 2^28 + 2^14 pixels, each side within its bound. + assert_eq!( + Bitmap::new(1 << 14, (1 << 14) + 1).map(|_| ()), + limit, + "the pixels" + ); + assert!( + Bitmap::new(1 << 14, 1 << 14).is_ok(), + "2^28 pixels are within it" + ); + } +} diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index b682d4571c..6eefa05647 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -24,10 +24,14 @@ use crate::reader::Reader; const MAX_SYMBOLS: u32 = u16::MAX as u32; -/// PdfCraft patch: the most symbol pixels one dictionary may decode (2^26). Symbols are -/// decoded pixel by pixel, and up to 65535 of them of up to 65535 × 65535 each kept a fuzzed -/// file decoding for minutes; real dictionaries hold about a million pixels. -const MAX_DICTIONARY_PIXELS: u64 = 1 << 26; +/// PdfCraft patch: the most symbol pixels one dictionary may decode, as many as the largest page +/// bitmap holds (2^28, `bitmap::MAX_PIXELS`). Symbols are decoded pixel by pixel, and up to 65535 +/// of them of up to 65535 × 65535 each kept a fuzzed file decoding for minutes; each symbol now +/// pays for its pixels out of the stream's decoding work (`bitmap::MAX_WORK`), which bounds the +/// time, and holds them in the retained bitmaps (`room`), which bounds the memory. The bound was +/// 2^26, a quarter of this, which a real scan passes: the soft mask of a 14,642 × 8,879 drawing +/// with one dictionary of 7,559 symbols (#6722) needed more, and was refused. +const MAX_DICTIONARY_PIXELS: u64 = crate::bitmap::MAX_PIXELS; /// PdfCraft patch: decoding steps that decode nothing, allowed beyond what a valid dictionary can /// need. A height class without a symbol and an export run of length 0 don't move their loops @@ -135,7 +139,7 @@ pub(crate) fn decode( .pixels .saturating_add(u64::from(symbol_width) * u64::from(ctx.height_class_height)); if ctx.pixels > MAX_DICTIONARY_PIXELS { - bail!(OverflowError::BitmapDimension); + bail!(OverflowError::BitmapLimit); } // PdfCraft patch: see `bitmap::INTEGER_WORK`. Its width, and a stream can hold thousands // of dictionaries of symbols of no pixels. An aggregated one reads its count, a symbol diff --git a/vendor/hayro-jbig2/src/error.rs b/vendor/hayro-jbig2/src/error.rs index c8bcf8b811..a91074e577 100644 --- a/vendor/hayro-jbig2/src/error.rs +++ b/vendor/hayro-jbig2/src/error.rs @@ -141,6 +141,10 @@ pub enum OverflowError { SegmentCount, /// PdfCraft patch: a stream asks for more decoding work than it may. DecodingWork, + /// PdfCraft patch: a bitmap, gray-scale image or symbol dictionary is larger than the decoder + /// holds (see `bitmap::MAX_PIXELS`): one of its limits, where `BitmapDimension` is arithmetic + /// that overflows. + BitmapLimit, } impl fmt::Display for DecodeError { @@ -171,6 +175,7 @@ impl fmt::Display for OverflowError { Self::RetainedBitmaps => write!(f, "the segments retain too many bitmaps"), Self::SegmentCount => write!(f, "too many segments or references in a stream"), Self::DecodingWork => write!(f, "a stream asks for too much decoding work"), + Self::BitmapLimit => write!(f, "a bitmap is larger than the decoder holds"), } } } @@ -312,6 +317,59 @@ impl From for DecodeError { } } +impl DecodeError { + /// PdfCraft patch: whether one of the decoder's limits refused the stream (on a bitmap's size, + /// a dictionary's symbols, a region's instances, the bitmaps retained, the segments or the + /// decoding work) rather than its data failing to decode. A file within the format can reach + /// these, so a caller can tell a refusal of its own from a damaged file. A size or count the + /// file damaged can read as past one of them, and is a limit here too: it can't be told from a + /// hostile one. + pub fn is_limit(&self) -> bool { + matches!( + self, + Self::Overflow( + OverflowError::BitmapLimit + | OverflowError::GridDimension + | OverflowError::SymbolCount + | OverflowError::RetainedBitmaps + | OverflowError::SegmentCount + | OverflowError::DecodingWork + ) | Self::Symbol(SymbolError::TooManyInstances) + ) + } +} + +#[cfg(test)] +mod pdfcraft_limit_tests { + use super::*; + + /// PdfCraft patch: the decoder's own limits are limits, and arithmetic that overflows, + /// damage, is not. + #[test] + fn the_decoders_limits_are_limits_and_overflowing_arithmetic_is_not() { + let limits = [ + DecodeError::Overflow(OverflowError::BitmapLimit), + DecodeError::Overflow(OverflowError::GridDimension), + DecodeError::Overflow(OverflowError::SymbolCount), + DecodeError::Overflow(OverflowError::RetainedBitmaps), + DecodeError::Overflow(OverflowError::SegmentCount), + DecodeError::Overflow(OverflowError::DecodingWork), + DecodeError::Symbol(SymbolError::TooManyInstances), + ]; + for limit in limits { + assert!(limit.is_limit(), "{limit:?} is a limit"); + } + let damage = [ + DecodeError::Overflow(OverflowError::BitmapDimension), + DecodeError::Overflow(OverflowError::PlacementCoordinate), + DecodeError::Symbol(SymbolError::TooManySymbols), + ]; + for damage in damage { + assert!(!damage.is_limit(), "{damage:?} is damage"); + } + } +} + /// Result type for JBIG2 decoding operations. pub type Result = core::result::Result; diff --git a/vendor/hayro-jbig2/src/gray_scale.rs b/vendor/hayro-jbig2/src/gray_scale.rs index 4aa4229a5b..1cd5016f08 100644 --- a/vendor/hayro-jbig2/src/gray_scale.rs +++ b/vendor/hayro-jbig2/src/gray_scale.rs @@ -81,11 +81,11 @@ fn decode_arithmetic( let height = params.height; if width > MAX_DIMENSION || height > MAX_DIMENSION { - bail!(OverflowError::BitmapDimension); + bail!(OverflowError::BitmapLimit); } // PdfCraft patch: the product is bounded too (see `bitmap::MAX_GRID_CELLS`). if u64::from(width) * u64::from(height) > crate::bitmap::MAX_GRID_CELLS { - bail!(OverflowError::BitmapDimension); + bail!(OverflowError::BitmapLimit); } // `GSBPP` - The number of bits per gray-scale value. @@ -289,3 +289,34 @@ fn extract_bits( } } } + +#[cfg(test)] +mod pdfcraft_limit_tests { + use super::*; + use crate::error::DecodeError; + + /// PdfCraft patch: a gray-scale image past the bound on a side or on cells is refused by a + /// limit, before anything is decoded. A halftone region refuses such a grid first, so this is + /// reached only by calling it. + #[test] + fn a_gray_scale_image_past_its_bounds_is_refused_by_a_limit() { + let decode = |width, height| { + let params = GrayScaleParams { + use_mmr: false, + bits_per_pixel: 1, + width, + height, + template: Template::Template0, + skip_mask: None, + }; + decode_gray_scale_image(&[], ¶ms, &mut ScratchBuffers::default()).map(|_| ()) + }; + let limit = Err(DecodeError::Overflow(OverflowError::BitmapLimit)); + assert_eq!(decode(MAX_DIMENSION + 1, 1), limit, "a side"); + assert_eq!( + decode(1 << 13, (1 << 12) + 1), + limit, + "the cells, each side within its bound" + ); + } +} diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index 5c81b8b2b2..ea627a41ce 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -19,12 +19,79 @@ pub const MAX_PAGE_JBIG2_WORK: u64 = 1 << 30; std::thread_local! { // Unbounded until a page says otherwise: what isn't drawing a page has no page to run out on. static PAGE_WORK_LEFT: core::cell::Cell = const { core::cell::Cell::new(u64::MAX) }; + // PdfCraft patch (31): what refused the image decoded last on this thread, if one of the + // decoder's limits did: `STREAM_LIMIT` or `WORK_LIMIT`, 0 otherwise. + static LIMITED: core::cell::Cell = const { core::cell::Cell::new(0) }; } +/// PdfCraft patch (31): a limit the stream reaches by itself, whatever ran before it (a bitmap's +/// size, a dictionary's symbols, the bitmaps retained and the like), so decoding it again is +/// refused again. +#[cfg(feature = "std")] +const STREAM_LIMIT: u8 = 1; +/// PdfCraft patch (31): the decoding work, which may be what the page or run had left. +#[cfg(feature = "std")] +const WORK_LIMIT: u8 = 2; + +/// PdfCraft patch (31): whether the JBIG2 image decoded last on this thread was refused by one of +/// the decoder's limits rather than failing on its data. A soft mask a limit refused hides what +/// its image would show without it: hayro-interpret leaves such an image out, where a mask the +/// file damaged leaves its image whole (#6722). A size or count the file damaged can read as past +/// a limit, and is taken for one: it can't be told from a hostile one, and leaving the image out +/// is the safe side. +#[cfg(feature = "std")] +pub fn jbig2_limited() -> bool { + LIMITED.with(core::cell::Cell::get) != 0 +} + +#[cfg(not(feature = "std"))] +pub fn jbig2_limited() -> bool { + false +} + +/// PdfCraft patch (31): whether the refusal [`jbig2_limited`] reports came from a limit the stream +/// reaches by itself, not from the decoding work, so the same stream is refused again whenever it +/// is decoded. +#[cfg(feature = "std")] +pub fn jbig2_limited_by_stream() -> bool { + LIMITED.with(core::cell::Cell::get) == STREAM_LIMIT +} + +#[cfg(not(feature = "std"))] +pub fn jbig2_limited_by_stream() -> bool { + false +} + +/// PdfCraft patch (31): forget what refused the image decoded last, before decoding another +/// through filters that may fail before JBIG2 is reached. +#[cfg(feature = "std")] +pub fn clear_jbig2_limited() { + LIMITED.with(|limited| limited.set(0)); +} + +#[cfg(not(feature = "std"))] +pub fn clear_jbig2_limited() {} + +#[cfg(feature = "std")] +fn note_limited(error: Option<&hayro_jbig2::DecodeError>) { + let refused = match error { + Some(hayro_jbig2::DecodeError::Overflow(hayro_jbig2::OverflowError::DecodingWork)) => { + WORK_LIMIT + } + Some(e) if e.is_limit() => STREAM_LIMIT, + _ => 0, + }; + LIMITED.with(|limited| limited.set(refused)); +} + +#[cfg(not(feature = "std"))] +fn note_limited(_: Option<&hayro_jbig2::DecodeError>) {} + /// PdfCraft patch (31): what the JBIG2 images decoded next on this thread may take, for a new page. #[cfg(feature = "std")] pub fn set_page_jbig2_work(work: u64) { PAGE_WORK_LEFT.with(|left| left.set(work)); + clear_jbig2_limited(); } /// Pays for reading `bytes` of a stream and its globals out of what the page has left, or `None`. @@ -50,6 +117,7 @@ fn decode_for_page(image: &hayro_jbig2::Image<'_>, deco let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get); let decoded = image.decode_within(decoder, &mut left); PAGE_WORK_LEFT.with(|cell| cell.set(left)); + note_limited(decoded.as_ref().err()); decoded.ok() } @@ -93,7 +161,9 @@ pub(crate) fn decode( charge_page_reading(data.len().saturating_add(globals.as_deref().map_or(0, <[u8]>::len)))?; - let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?; + let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()) + .inspect_err(|e| note_limited(Some(e))) + .ok()?; // Whenever possible (if we don't have an indexed color space), we convert // the data as 8-bit instead of 1-bit, so that it can be easier converted diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index ed2b0e5c82..8b5e194770 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -9,7 +9,10 @@ mod dct; #[cfg(feature = "images")] mod jbig2; #[cfg(feature = "images")] -pub use jbig2::{MAX_PAGE_JBIG2_WORK, page_jbig2_work_spent}; +pub use jbig2::{ + MAX_PAGE_JBIG2_WORK, clear_jbig2_limited, jbig2_limited, jbig2_limited_by_stream, + page_jbig2_work_spent, +}; #[cfg(all(feature = "images", feature = "std"))] pub use jbig2::set_page_jbig2_work; #[cfg(feature = "images")]