From a0b60a3b92c44121763f73997e2c21111c72bc7e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:29:38 +0200 Subject: [PATCH 001/562] Optimize: optional target_bytes retries with stronger image settings (#1889) Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 54 +++++++++++++++++++++++++-- crates/automation/src/tools.rs | 3 +- crates/automation/tests/automation.rs | 43 +++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 88856305b2..17c1f7293c 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -92,6 +92,8 @@ const DEFAULT_DPI: f64 = 96.0; const MAX_DPI: f64 = 600.0; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; +/// `doc_optimize` with `target_bytes`: (max target ppi, max JPEG quality) per extra attempt, mildest first. +const TARGET_LADDER: [(f64, u8); 5] = [(110.0, 45), (96.0, 35), (72.0, 28), (54.0, 20), (36.0, 12)]; /// Bookmarks in one page of the bookmark list, by default and at most. const BOOKMARK_PAGE: usize = 100; @@ -1120,10 +1122,52 @@ impl Automation { }) .collect::>()?, }; - let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; + let target = match a.opt_int("target_bytes")? { + None => None, + Some(t) if t >= 1 => Some(t as usize), + Some(t) => return Err(ToolError::InvalidArgs(format!("target_bytes must be at least 1, not {t}"))), + }; + let (mut bytes, mut r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; + let mut attempts = 1; + let mut step_used = 0; + if let Some(t) = target { + // Stronger image settings, one rung per attempt, until the copy fits. Every attempt + // starts from the open document, so a failed one changes nothing; the number of + // attempts is fixed. The smallest copy is kept if none fits. + for (k, &(ppi, quality)) in TARGET_LADDER.iter().enumerate() { + if bytes.len() <= t { + break; + } + let mut s = settings.clone(); + for img in [&mut s.color, &mut s.gray] { + img.downsample = true; + img.target_ppi = img.target_ppi.min(ppi); + img.above_ppi = img.above_ppi.min(ppi * 1.5).max(img.target_ppi); + img.compression = match img.compression { + Compression::Jpeg(q) => Compression::Jpeg(q.min(quality)), + _ => Compression::Jpeg(quality), + }; + } + let (b2, r2) = self.session.optimized_bytes(id, &s, &discard).map_err(failed)?; + attempts += 1; + if b2.len() < bytes.len() { + (bytes, r, step_used) = (b2, r2, k + 1); + } + } + } write_atomic(&path, &bytes)?; let o = &r.optimize; - Ok(json!({ + let mut extra = json!({}); + if let Some(t) = target { + let (ppi, quality) = step_used.checked_sub(1).and_then(|k| TARGET_LADDER.get(k)).copied().unzip(); + extra = json!({ + "target_bytes": t, + "target_reached": bytes.len() <= t, + "attempts": attempts, + "target_settings": { "step": step_used, "max_ppi": ppi, "max_jpeg_quality": quality }, + }); + } + let mut out = json!({ "path": path.to_string_lossy(), "bytes_before": before, "bytes_after": bytes.len(), @@ -1144,7 +1188,11 @@ impl Automation { "unused_xobjects": o.unused_xobjects, "merged_objects": r.merged, "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), - })) + }); + if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) { + o.extend(e.iter().map(|(k, v)| (k.clone(), v.clone()))); + } + Ok(out) } /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..2365086a33 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1303,7 +1303,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi with JPEG quality at most 45, 35, 28, 20, 12; applied to colour and grey images) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1313,6 +1313,7 @@ pub fn tools() -> Vec { "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, + "target_bytes": { "type": "integer", "minimum": 1 }, "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..8018c83321 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4752,6 +4752,49 @@ fn a_page_printed_in_strips_gets_small_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "preset": "tiny" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn optimizing_to_a_target_size() { + let dir = workdir("optimize-target"); + let (w, h) = (2400u32, 1600u32); + let px: Vec = (0..w * h) + .flat_map(|i| { + let (x, y) = (i % w, i / w); + [(x * 255 / w) as u8 ^ ((x * y) & 15) as u8, (y * 255 / h) as u8, ((x + y) & 255) as u8] + }) + .collect(); + let mut jpeg = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 95); + enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(600)); + enc.encode(&px, w, h, image::ExtendedColorType::Rgb8).unwrap(); + std::fs::write(dir.join("photo.jpg"), &jpeg).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["photo.jpg"] }))["doc"].as_u64().unwrap(); + // Without a target nothing changes: no target fields. + let base = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "base.pdf" })); + assert!(base.get("target_reached").is_none(), "{base}"); + let base_len = base["bytes_after"].as_u64().unwrap(); + // A target below the default result is met by a stronger step. + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "fit.pdf", "target_bytes": base_len - 1 })); + assert_eq!(r["target_reached"], true, "{r}"); + assert!(r["bytes_after"].as_u64().unwrap() < base_len, "{r}"); + assert!(r["target_settings"]["step"].as_u64().unwrap() >= 1, "{r}"); + assert_eq!(std::fs::metadata(dir.join("fit.pdf")).unwrap().len(), r["bytes_after"].as_u64().unwrap()); + // A target already met by the first attempt tries nothing more. + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "easy.pdf", "target_bytes": base_len })); + assert_eq!((r["target_reached"].as_bool(), r["attempts"].as_u64()), (Some(true), Some(1)), "{r}"); + // An impossible target: bounded attempts, smallest copy written, said plainly. + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "tiny.pdf", "target_bytes": 1 })); + assert_eq!((r["target_reached"].as_bool(), r["attempts"].as_u64()), (Some(false), Some(6)), "{r}"); + assert!(r["bytes_after"].as_u64().unwrap() < base_len, "{r}"); + assert!(std::fs::metadata(dir.join("tiny.pdf")).is_ok()); + // The open document is untouched by the failed attempts, and bad targets are refused. + let again = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "again.pdf" })); + assert_eq!(again["bytes_after"].as_u64().unwrap(), base_len); + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "target_bytes": 0 })), Err(ToolError::InvalidArgs(_)))); + // Confinement still applies. + assert!(a.call("doc_optimize", &json!({ "doc": doc, "path": "../out.pdf", "target_bytes": 1000 })).is_err()); +} + #[test] fn initial_view_through_tools() { let dir = workdir("initial-view"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..aee6fda80f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7260,8 +7260,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] -notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/automation/tests/automation.rs::optimizing_to_a_target_size", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] +notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit; headless-only optional target_bytes retries up to 5 stronger image steps (an automation extension, not an Acrobat feature)" [[feature]] id = "optimize.downsample-images" From 68d7b4cf3b31516672f124e1a8bf65e8df150714 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:20:48 +0200 Subject: [PATCH 002/562] Redact: mark text by caller-supplied regex with optional checksum (#1886) Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 33 ++++ Cargo.toml | 2 + ROADMAP.md | 1 + crates/automation/src/redact.rs | 30 +++- crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 28 ++++ crates/engine/src/lib.rs | 1 + crates/redact/Cargo.toml | 1 + crates/redact/src/lib.rs | 1 + crates/redact/src/user_regex.rs | 211 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 12 ++ 11 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 crates/redact/src/user_regex.rs diff --git a/Cargo.lock b/Cargo.lock index 91ed3c4904..268f3c745d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,6 +148,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "aligned-vec" version = "0.6.4" @@ -4284,6 +4293,7 @@ dependencies = [ "pdfcraft-model", "pdfcraft-testkit", "pdfcraft-xfa", + "regex", "thiserror 2.0.21", ] @@ -5136,6 +5146,29 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.11" diff --git a/Cargo.toml b/Cargo.toml index 597dd316fa..cc0ceaa4d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,8 @@ weezl = "0.2" # foundation thiserror = "2" +# Linear-time matching (no backtracking) for caller-supplied redaction patterns. +regex = { version = "1", default-features = false, features = ["std", "unicode"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" diff --git a/ROADMAP.md b/ROADMAP.md index 66b9677569..d1e1c6febf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ 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-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-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 1b6283c0ad..1367cb819c 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -2,8 +2,8 @@ //! (removing what they cover for good) or clear them. use pdfcraft_engine::{ - Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad, - redact_word_list, + Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, + RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, }; use serde_json::{Value, json}; @@ -49,16 +49,20 @@ impl Automation { a.get("find").is_some(), a.get("words").is_some(), a.get("pattern").is_some(), + a.get("regex").is_some(), a.opt_bool("whole_pages")?.unwrap_or(false), ]; if chosen.iter().filter(|c| **c).count() != 1 { - return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into())); + return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern, regex or whole_pages: true".into())); } let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false); let whole_words = a.opt_bool("whole_words")?.unwrap_or(false); if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() { return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into())); } + if a.get("validate").is_some() && a.get("regex").is_none() { + return Err(ToolError::InvalidArgs("validate applies only to regex".into())); + } // Per searched word or phrase: how many marks it made. let mut matched_words: Vec<(String, usize)> = Vec::new(); if a.get("rect").is_some() { @@ -79,6 +83,19 @@ impl Automation { Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?), None => None, }; + let user_re = match a.opt_str("regex")? { + Some(src) => { + let validator = match a.opt_str("validate")? { + Some(v) => Some(RedactValidator::from_id(v).ok_or_else(|| { + let ids: Vec<_> = REDACT_VALIDATORS.iter().map(|v| v.id()).collect(); + ToolError::InvalidArgs(format!("unknown validate {v:?}; allowed: {}", ids.join(", "))) + })?), + None => None, + }; + Some(RedactRegex::new(src, validator).map_err(|e| ToolError::InvalidArgs(e.to_string()))?) + } + None => None, + }; let needles: Vec = match (a.opt_str("find")?, a.get("words")) { (Some(f), _) => vec![f.to_owned()], (None, Some(_)) => { @@ -98,9 +115,10 @@ impl Automation { let texts = self.page_texts(id, &pages)?; let info = self.doc(a)?.info.clone(); for (&p, text) in pages.iter().zip(&texts) { - let hits: Vec<(Option, _)> = match pattern { - Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(), - None => needles + let hits: Vec<(Option, _)> = match (&user_re, pattern) { + (Some(re), _) => text.find_with(|chars| re.find(chars)).into_iter().map(|h| (None, h)).collect(), + (None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(), + (None, None) => needles .iter() .enumerate() .flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h))) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 5e89aef78a..84831b79a4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -416,7 +416,7 @@ pub fn tools() -> Vec { t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ @@ -428,6 +428,8 @@ pub fn tools() -> Vec { "case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." }, "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, + "regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax); every match is marked." }, + "validate": { "type": "string", "enum": ["luhn", "mod97", "iban"], "description": "With regex: keep a match only if its checksum is valid (spaces and hyphens ignored)." }, "whole_pages": { "type": "boolean" }, "pages": pages("to search or mark (default: all)"), "overlay": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..b1cb1920cb 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3295,6 +3295,34 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn redacting_with_a_caller_regex_through_tools() { + let dir = workdir("redact-regex"); + std::fs::write( + dir.join("ids.txt"), + "Ref 1 85 05 78 006 084 36 end\nRef 2 91 12 34 567 890 12 end\nCard 4539 1488 0343 6467 and 4539 1488 0343 6468", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "ids.txt" }))["doc"].as_u64().unwrap(); + let bad = |a: &mut Automation, args: Value| matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_))); + assert!(bad(&mut a, json!({ "doc": doc, "regex": "(unclosed" }))); + assert!(bad(&mut a, json!({ "doc": doc, "regex": "a*" }))); + assert!(bad(&mut a, json!({ "doc": doc, "regex": "x".repeat(2000) }))); + assert!(bad(&mut a, json!({ "doc": doc, "regex": "x", "pattern": "ssn" }))); + assert!(bad(&mut a, json!({ "doc": doc, "regex": "x", "validate": "crc" }))); + assert!(bad(&mut a, json!({ "doc": doc, "find": "x", "validate": "luhn" }))); + assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "regex": "Z{3}" })), Err(ToolError::Failed(_)))); + let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "regex": r"\d \d\d \d\d \d\d \d{3} \d{3} \d\d" })); + assert_eq!(r["marked"].as_u64(), Some(2)); + let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "regex": r"\d{4}( \d{4}){3}", "validate": "luhn" })); + assert_eq!(r["marked"].as_u64(), Some(1), "only the number with a valid checksum"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = page_text(&mut a, doc)[0].clone(); + assert!(!text.contains("85 05") && !text.contains("91 12") && !text.contains("6467"), "{text}"); + assert!(text.contains("6468"), "{text}"); +} + #[test] fn redacting_with_codes_through_tools() { let dir = workdir("redact-codes"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 01f162ae7d..22743fdc65 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -152,6 +152,7 @@ pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, }; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator}; pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..f6f99995ac 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -17,6 +17,7 @@ pdfcraft-forms = { workspace = true } pdfcraft-edit = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-xfa = { workspace = true } +regex = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 61231bd982..fc44d941d1 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -27,6 +27,7 @@ pub mod sanitize; mod tags; #[cfg(test)] mod tests; +pub mod user_regex; use interp::{Mode, Scope, process}; diff --git a/crates/redact/src/user_regex.rs b/crates/redact/src/user_regex.rs new file mode 100644 index 0000000000..043b51d0b0 --- /dev/null +++ b/crates/redact/src/user_regex.rs @@ -0,0 +1,211 @@ +//! Caller-supplied regular expressions for Search & Redact (`redact_mark regex`). +//! +//! The expression is untrusted input. It runs on the `regex` crate, which matches in time +//! linear in the text (no backtracking, no look-around or back-references), and is compiled +//! under hard limits on source length, nesting and compiled size, so a hostile expression is +//! refused with an error instead of exhausting time or memory. An optional [`Validator`] +//! keeps a match only when its checksum holds. + +use std::ops::Range; + +use regex::RegexBuilder; +use thiserror::Error; + +/// Longest expression accepted, in bytes. +pub const MAX_REGEX_LEN: usize = 1024; +/// Most matches returned for one page; the rest are ignored. +pub const MAX_MATCHES_PER_TEXT: usize = 10_000; +/// Cap on the compiled program and on the lazy-DFA cache, in bytes. +const SIZE_LIMIT: usize = 1 << 20; +/// Cap on parenthesis nesting. +const NEST_LIMIT: u32 = 32; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RegexError { + #[error("regex is empty")] + Empty, + #[error("regex is {0} bytes long; at most {MAX_REGEX_LEN} are allowed")] + TooLong(usize), + #[error("invalid or too complex regex: {0}")] + Invalid(String), + #[error("regex matches the empty string, so it would mark nothing useful; require at least one character")] + MatchesEmpty, +} + +/// Checksum a match must pass to be kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Validator { + /// Luhn (ISO/IEC 7812): digits only, spaces and hyphens ignored. + Luhn, + /// ISO 7064 MOD 97-10: letters count as 10..35 and the number mod 97 must be 1. Letters and + /// digits only, spaces and hyphens ignored. + Mod97, + /// IBAN check (ISO 13616): the first four characters move to the end, then MOD 97-10. + Iban, +} + +/// Ids accepted for `validate`. +pub const VALIDATORS: [Validator; 3] = [Validator::Luhn, Validator::Mod97, Validator::Iban]; + +impl Validator { + pub fn id(self) -> &'static str { + match self { + Validator::Luhn => "luhn", + Validator::Mod97 => "mod97", + Validator::Iban => "iban", + } + } + + pub fn from_id(id: &str) -> Option { + VALIDATORS.into_iter().find(|v| v.id() == id) + } + + /// Whether `s` passes the checksum. + pub fn check(self, s: &str) -> bool { + match self { + Validator::Luhn => luhn(s), + Validator::Mod97 => mod97(s.chars()), + Validator::Iban => { + let kept: Vec = s.chars().filter(|c| *c != ' ' && *c != '-').collect(); + let (head, tail) = kept.split_at(kept.len().min(4)); + mod97(tail.iter().chain(head).copied()) + } + } + } +} + +fn luhn(s: &str) -> bool { + let (mut sum, mut digits, mut double) = (0u32, 0usize, false); + for c in s.chars().rev() { + if c == ' ' || c == '-' { + continue; + } + let Some(d) = c.to_digit(10).filter(|_| c.is_ascii_digit()) else { return false }; + let d = if double { if d * 2 > 9 { d * 2 - 9 } else { d * 2 } } else { d }; + sum = sum.saturating_add(d); + digits += 1; + double = !double; + } + digits >= 2 && sum % 10 == 0 +} + +fn mod97(s: impl Iterator) -> bool { + let (mut rem, mut n) = (0u32, 0usize); + for c in s { + if c == ' ' || c == '-' { + continue; + } + if !c.is_ascii_alphanumeric() { + return false; + } + // Digits stay one decimal digit, letters become two (A = 10 .. Z = 35). + let v = c.to_digit(36).unwrap_or(0); + rem = if v >= 10 { (rem * 100 + v) % 97 } else { (rem * 10 + v) % 97 }; + n += 1; + } + n >= 2 && rem == 1 +} + +/// A compiled caller expression with an optional checksum. +#[derive(Debug, Clone)] +pub struct UserRegex { + re: regex::Regex, + validator: Option, +} + +impl UserRegex { + pub fn new(source: &str, validator: Option) -> Result { + if source.is_empty() { + return Err(RegexError::Empty); + } + if source.len() > MAX_REGEX_LEN { + return Err(RegexError::TooLong(source.len())); + } + let re = RegexBuilder::new(source) + .size_limit(SIZE_LIMIT) + .dfa_size_limit(SIZE_LIMIT) + .nest_limit(NEST_LIMIT) + .build() + .map_err(|e| RegexError::Invalid(e.to_string()))?; + if re.is_match("") { + return Err(RegexError::MatchesEmpty); + } + Ok(Self { re, validator }) + } + + /// Character ranges of `text` the expression matches (non-overlapping, left to right). + pub fn find(&self, text: &[char]) -> Vec> { + let s: String = text.iter().collect(); + // Char index of every byte offset that starts a char (and of the end). + let mut byte_to_char = vec![usize::MAX; s.len().saturating_add(1)]; + for (ci, (bi, _)) in s.char_indices().enumerate() { + if let Some(slot) = byte_to_char.get_mut(bi) { + *slot = ci; + } + } + if let Some(slot) = byte_to_char.get_mut(s.len()) { + *slot = text.len(); + } + let mut out = Vec::new(); + for m in self.re.find_iter(&s) { + if out.len() >= MAX_MATCHES_PER_TEXT { + break; + } + if m.is_empty() || self.validator.is_some_and(|v| !v.check(m.as_str())) { + continue; + } + if let (Some(&a), Some(&b)) = (byte_to_char.get(m.start()), byte_to_char.get(m.end())) + && a < b + && b != usize::MAX + { + out.push(a..b); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chars(s: &str) -> Vec { + s.chars().collect() + } + + #[test] + fn finds_matches_as_char_ranges_with_multibyte_text() { + let r = UserRegex::new(r"\d{2}( \d{2}){2}", None).unwrap(); + let t = chars("Ré 85 05 78 et 91 12 34 fin"); + let found: Vec = r.find(&t).into_iter().map(|g| t[g].iter().collect()).collect(); + assert_eq!(found, ["85 05 78", "91 12 34"]); + } + + #[test] + fn validators_filter_matches() { + let t = chars("ok 4539 1488 0343 6467 bad 4539 1488 0343 6468"); + let r = UserRegex::new(r"\d{4}( \d{4}){3}", Some(Validator::Luhn)).unwrap(); + assert_eq!(r.find(&t).len(), 1); + let t = chars("GB82 WEST 1234 5698 7654 32 and GB83 WEST 1234 5698 7654 32"); + let r = UserRegex::new(r"GB\d\d WEST( \d{4}){3} \d\d", Some(Validator::Iban)).unwrap(); + assert_eq!(r.find(&t).len(), 1); + assert!(Validator::Mod97.check("98") && !Validator::Mod97.check("99")); + assert!(!Validator::Luhn.check("12a4") && !Validator::Mod97.check("é1") && !Validator::Luhn.check("0")); + } + + #[test] + fn hostile_expressions_are_refused_or_stay_linear() { + assert_eq!(UserRegex::new("", None).unwrap_err(), RegexError::Empty); + assert!(matches!(UserRegex::new(&"a".repeat(MAX_REGEX_LEN + 1), None), Err(RegexError::TooLong(_)))); + assert!(matches!(UserRegex::new("(unclosed", None), Err(RegexError::Invalid(_)))); + assert!(matches!(UserRegex::new(r"(a)\1", None), Err(RegexError::Invalid(_))), "no back-references"); + assert_eq!(UserRegex::new("a*", None).unwrap_err(), RegexError::MatchesEmpty); + assert!(matches!(UserRegex::new(r"\p{L}{1000}{1000}", None), Err(RegexError::Invalid(_))), "compiled size is capped"); + assert!(matches!(UserRegex::new(&format!("{}a{}", "(".repeat(100), ")".repeat(100)), None), Err(RegexError::Invalid(_)))); + // Classic catastrophic pattern finishes at once on a long non-matching input. + let r = UserRegex::new(r"(a+)+$", None).unwrap(); + let mut t = chars(&"a".repeat(50_000)); + t.push('!'); + assert!(r.find(&t).is_empty()); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 533013b547..159f580635 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5611,6 +5611,18 @@ tools = ["redact_mark"] evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"] notes = "phone (North American, E.164 with + country code, national with trunk 0), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded" +[[feature]] +id = "protect.redact-regex" +title = "Redact by caller-supplied regular expression" +area = "G" +tier = "P2" +milestone = "M8" +status = "shipped" +commands = [] +tools = ["redact_mark"] +evidence = ["crates/redact/src/user_regex.rs::hostile_expressions_are_refused_or_stay_linear", "crates/redact/src/user_regex.rs::validators_filter_matches", "crates/automation/tests/automation.rs::redacting_with_a_caller_regex_through_tools"] +notes = "Headless only (redact_mark regex, optional validate luhn, mod97 or iban; issue #1886): no UI entry yet. Rust regex syntax, matched in linear time with no look-around or back-references; expression capped at 1024 bytes, compiled size and nesting capped, empty-matching expressions refused. Matches run on the page text as the pattern matchers see it (words and lines joined by one space), so a match cannot span a page break. Missing: a UI field, a mask for checksums that need more than the whole match (for example the French social security key), and a check against Acrobat's own custom-pattern syntax (not observed)." + [[feature]] id = "protect.redact-pattern-locales" title = "Pattern sets per locale" From f6e335f3fe0c0ddc00f93b28a8c9f7499bcefa05 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:49:37 +0200 Subject: [PATCH 003/562] UI: allow a half-screen window, and fold the mode bar when narrow (#1913) The viewport minimum was 820x520 points, wider than half of a 1440 px screen. It is now MIN_WINDOW_SIZE = 480x520 points. At that width the five mode tabs were hidden under the search field, so the mode bar folds them into one menu named after the current mode, shrinks the search field and turns it into an icon button when it gets too narrow. Notice bars stack their buttons under the message in a narrow document area. Co-Authored-By: Claude Sonnet 5.5 --- apps/pdfcraft/src/main.rs | 2 +- crates/ui-egui/src/canvas.rs | 16 +++++++++++ crates/ui-egui/src/chrome.rs | 43 +++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 6 +++++ crates/ui-egui/src/widgets.rs | 7 +++++ crates/ui-egui/tests/min_window.rs | 38 ++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/min_window.rs diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..edc0652d0e 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -309,7 +309,7 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) let mut viewport = egui::ViewportBuilder::default() .with_title("PdfCraft") .with_inner_size([1440.0, 920.0]) - .with_min_inner_size([820.0, 520.0]) + .with_min_inner_size(pdfcraft_ui_egui::MIN_WINDOW_SIZE) .with_drag_and_drop(true) // Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop. .with_app_id(APP_ID); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..72a8316ab7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4248,8 +4248,24 @@ fn notices( fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, text: String, buttons: impl FnOnce(&mut egui::Ui)) { const ROW: f32 = 28.0; const ICON: f32 = 16.0; + /// Content width below which the buttons stack under the message. + const STACK_BELOW: f32 = 300.0; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { let width = ui.available_width(); + if width < STACK_BELOW { + // A narrow document area (a half-width window): the message takes the full width and + // the buttons sit on a row of their own below it, rather than squeezing it to nothing. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { + let (slot, _) = ui.allocate_exact_size(vec2(ICON, ICON), Sense::hover()); + icons::paint(ui, Rect::from_center_size(slot.center(), vec2(ICON, ICON)), icon, ICON, icon_color); + ui.add(egui::Label::new(egui::RichText::new(text).color(t.text)).wrap()); + }); + ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.set_min_height(ROW); + buttons(ui); + }); + return; + } ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.set_min_height(ROW); buttons(ui); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e8bf3b48dd..88165102ad 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -487,6 +487,11 @@ fn tab( }) } +/// Width the print, save and info buttons and their gaps take in the mode bar. +const MODE_BAR_RIGHT: f32 = 3.0 * 32.0 + 2.0 * 4.0 + 8.0; +/// Narrowest search field the mode bar shows; below it the field becomes an icon button. +const MODE_BAR_SEARCH_MIN: f32 = 140.0; + pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("mode_bar") @@ -499,13 +504,32 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); ui.add_space(10.0); - for (mode, label) in - [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] - { - if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() { - app.switch_mode(mode, true); + let modes = + [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]; + // What the right side needs (three 32 px buttons, gaps) and the least search box worth showing. + let avail = ui.available_width(); + let tabs_w: f32 = modes.iter().map(|(_, l)| widgets::mode_tab_width(ui, tl!(*l))).sum::() + 2.0 * (modes.len() as f32 - 1.0); + let inline_tabs = avail >= tabs_w + MODE_BAR_RIGHT + MODE_BAR_SEARCH_MIN; + if inline_tabs { + for (mode, label) in modes { + if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() { + app.switch_mode(mode, true); + } } + } else { + // Narrow window: the five modes fold into one menu named after the current one. + let current = modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l); + let resp = widgets::ghost_button(ui, "chevron-down", tl!(current)); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(160.0); + for (mode, label) in modes { + if ui.add(egui::Button::selectable(app.mode == mode, tl!(label))).clicked() { + app.switch_mode(mode, true); + } + } + }); } + let left = ui.available_width(); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 4.0; let has_doc = app.active.is_some(); @@ -524,7 +548,14 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } }); ui.add_space(8.0); - if widgets::search_box(ui, tl!("Find tools and commands"), 260.0).clicked() { + let search_w = (left - MODE_BAR_RIGHT).clamp(0.0, 260.0); + let opened = if search_w >= MODE_BAR_SEARCH_MIN { + widgets::search_box(ui, tl!("Find tools and commands"), search_w).clicked() + } else { + // Too narrow for the field: a button opens the same command search. + icons::button(ui, "search", 32.0, false, tl!("Find tools and commands")).clicked() + }; + if opened { app.palette_open = true; } }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e0..832ae30cb4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -149,6 +149,12 @@ mod open_worker_tests; use pdfcraft_engine::{DocId, Session}; pub use canvas::{DocView, RasterMemory}; + +/// Smallest window the shell is laid out for, in egui points (logical pixels, so the OS scale +/// factor is already applied: 480 points are 600 px at 125 % and 720 px at 150 %). 480 is half of a +/// 1440-wide screen at 150 % scaling, the tightest common half-screen split on Windows; the mode +/// bar, tab strip and notice bars adapt below the old 820 minimum (#1913). +pub const MIN_WINDOW_SIZE: [f32; 2] = [480.0, 520.0]; pub use editing::{CloseRequest, SaveTarget}; pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan}; pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore}; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..cae7405ac5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -108,6 +108,7 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) }; let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + // Keep in step with `mode_tab_width`. let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label)); let press = theme::Press::track(ui, &resp); @@ -128,6 +129,12 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { theme::hand(resp) } +/// Width [`mode_tab`] takes for `label` (the semibold, active, form is the widest). +pub fn mode_tab_width(ui: &egui::Ui, label: &str) -> f32 { + let t = Tokens::get(ui.ctx()); + ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), theme::semibold(13.5), t.text).size().x) + 22.0 +} + /// Rounded pill button; `primary` fills with the accent. pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs new file mode 100644 index 0000000000..b06e81d12d --- /dev/null +++ b/crates/ui-egui/tests/min_window.rs @@ -0,0 +1,38 @@ +//! The smallest window (#1913): at `MIN_WINDOW_SIZE` the mode bar folds the five modes into a menu +//! and keeps the command search reachable, instead of hiding them under the search field. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::{MIN_WINDOW_SIZE, PdfCraftApp}; + +fn harness(size: egui::Vec2) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(size).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(8); + h +} + +#[test] +fn at_the_minimum_width_the_modes_fold_into_a_menu_and_search_stays() { + let mut h = harness(egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1])); + assert!(h.query_by_role_and_label(Role::Button, "Read").is_none(), "no room for five tabs"); + assert!(h.query_by_role_and_label(Role::Button, "Find tools and commands").is_some(), "command search is reachable"); + h.get_by_role_and_label(Role::Button, "All tools").click(); + h.run_steps(4); + h.get_by_role_and_label(Role::Button, "E-Sign").click(); + h.run_steps(4); + assert!(h.query_by_role_and_label(Role::Button, "E-Sign").is_some(), "the menu button is named after the mode"); + assert!(h.query_by_role_and_label(Role::Button, "Menu").is_some(), "main menu reachable"); +} + +#[test] +fn a_wide_window_keeps_the_five_mode_tabs() { + let h = harness(egui::vec2(1400.0, 900.0)); + for mode in ["All tools", "Read", "Edit", "Convert", "E-Sign"] { + assert!(h.query_by_role_and_label(Role::Button, mode).is_some(), "{mode} tab"); + } +} From 03a4c21cfe7f73b890ee4d93a204fe38ad2c9e11 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:40:36 +0200 Subject: [PATCH 004/562] UI: stack notice buttons only below 200 pt so the 900-wide notice keeps its layout (#1913) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 72a8316ab7..ca1ff12337 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4249,7 +4249,7 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te const ROW: f32 = 28.0; const ICON: f32 = 16.0; /// Content width below which the buttons stack under the message. - const STACK_BELOW: f32 = 300.0; + const STACK_BELOW: f32 = 200.0; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { let width = ui.available_width(); if width < STACK_BELOW { From c3cf27558a6d0f6c8c245b5a4bad813e6ae5de30 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:53:33 +0200 Subject: [PATCH 005/562] UI: wrap stacked notice buttons and scroll the rail in short windows (#1913) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/chrome.rs | 241 +++++++++++++++-------------- crates/ui-egui/tests/min_window.rs | 82 ++++++++++ 3 files changed, 211 insertions(+), 115 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca1ff12337..349b4deded 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4260,7 +4260,8 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te icons::paint(ui, Rect::from_center_size(slot.center(), vec2(ICON, ICON)), icon, ICON, icon_color); ui.add(egui::Label::new(egui::RichText::new(text).color(t.text)).wrap()); }); - ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| { + // The buttons wrap onto further rows when even the full width is too narrow for them all. + ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center).with_main_wrap(true), |ui| { ui.set_min_height(ROW); buttons(ui); }); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 88165102ad..d4c6f09503 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -648,6 +648,9 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Height the rail's page-navigation cluster (zoom, fit, page box) needs; below it the rail scrolls. +const RAIL_BOTTOM_H: f32 = 316.0; + pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -668,122 +671,132 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .exact_size(48.0) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { - let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector)); - let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); - if has && !selected { - let c = r.rect.right_top() + vec2(-8.0, 8.0); - ui.painter().circle_filled(c, 3.0, t.accent); - } - if r.clicked() { - app.choose_right_panel(if selected { None } else { Some(panel) }); - } - }; - rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); - rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline); - rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false); - rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); - rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); - rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); - rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); - if has_check { - rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); - } - rail_button(ui, RightPanel::Tags, "tag", "Tags", false); + let panel_h = ui.available_height(); + // A window too short for both clusters scrolls the rail instead of overlapping them (#1913). + egui::ScrollArea::vertical().auto_shrink(false).scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show( + ui, + |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { + let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector)); + let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); + if has && !selected { + let c = r.rect.right_top() + vec2(-8.0, 8.0); + ui.painter().circle_filled(c, 3.0, t.accent); + } + if r.clicked() { + app.choose_right_panel(if selected { None } else { Some(panel) }); + } + }; + rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); + rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline); + rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false); + rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); + rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); + rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); + rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); + if has_check { + rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); + } + rail_button(ui, RightPanel::Tags, "tag", "Tags", false); - // Page navigation cluster at the bottom (as in Acrobat's rail). - let view = &mut app.views[index]; - let mut page_display = None; - ui.with_layout(Layout::bottom_up(Align::Center), |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); - } - // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. - let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); - let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); - page_display = egui::Popup::menu(&resp) - .show(|ui| { - ui.set_min_width(220.0); - rail_view_menu(ui, view) - }) - .and_then(|r| r.inner); - let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); - if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { - view.rotate_view(true); - } - // Not `columns-2` while fitting the page: that is the two-page view's icon. - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; - if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { - view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; - view.goto = Some((view.current, 0.0)); - } - let zoom = format!("{:.0}%", view.zoom * 100.0); - ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { - ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); - }); - ui.add_space(6.0); - if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { - view.step_page(true); - } - if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { - view.step_page(false); - } - // History is distinct from stepping to the adjacent page, and belongs to this tab. - for (forward, icon, label, shortcut, enabled) in [ - (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), - (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), - ] { - let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); - if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { - view.view_history(forward); + // Page navigation cluster at the bottom (as in Acrobat's rail). + let view = &mut app.views[index]; + let mut page_display = None; + let cluster_w = ui.available_width(); + // Inside the scroll area `available_height` is unbounded: the panel's height was taken outside. + let cluster_h = (panel_h - ui.min_rect().height() - ui.spacing().item_spacing.y).max(RAIL_BOTTOM_H); + ui.allocate_ui_with_layout(vec2(cluster_w, cluster_h), Layout::bottom_up(Align::Center), |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { + view.zoom_step(false); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { + view.zoom_step(true); + } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); + let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); + if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { + view.rotate_view(true); + } + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { + view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; + view.goto = Some((view.current, 0.0)); + } + let zoom = format!("{:.0}%", view.zoom * 100.0); + ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { + ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); + }); + ui.add_space(6.0); + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { + view.step_page(true); + } + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { + view.step_page(false); + } + // History is distinct from stepping to the adjacent page, and belongs to this tab. + for (forward, icon, label, shortcut, enabled) in [ + (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), + (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), + ] { + let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { + view.view_history(forward); + } + } + ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted)); + let page_input_id = view.page_input_id(); + // Consume replacement selection once, including when the editor has lost focus. + let reselect = std::mem::take(&mut view.page_input_reselect); + // Navigation updates the idle display, never the text being edited. + if !ui.memory(|m| m.has_focus(page_input_id)) { + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } else if reselect { + // The replacement changed the label while egui retained this editor's cursor. + let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); + let end = egui::text::CCursor::new(view.page_input.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ui.ctx(), page_input_id); + } + let edit = egui::TextEdit::singleline(&mut view.page_input) + .id(page_input_id) + .desired_width(34.0) + .horizontal_align(Align::Center) + .font(theme::medium(12.0)) + .margin(vec2(2.0, 4.0)); + let r = egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(5)) + .show(ui, |ui| ui.add(edit)) + .inner + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); + if r.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // A page label first (logical page numbers, as Acrobat), then a number. + let typed = view.page_input.clone(); + view.go_to_typed(&typed, &labels); + } + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } + }); + if let Some(id) = page_display { + app.execute(id); } - } - ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted)); - let page_input_id = view.page_input_id(); - // Consume replacement selection once, including when the editor has lost focus. - let reselect = std::mem::take(&mut view.page_input_reselect); - // Navigation updates the idle display, never the text being edited. - if !ui.memory(|m| m.has_focus(page_input_id)) { - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } else if reselect { - // The replacement changed the label while egui retained this editor's cursor. - let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); - let end = egui::text::CCursor::new(view.page_input.chars().count()); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); - state.store(ui.ctx(), page_input_id); - } - let edit = egui::TextEdit::singleline(&mut view.page_input) - .id(page_input_id) - .desired_width(34.0) - .horizontal_align(Align::Center) - .font(theme::medium(12.0)) - .margin(vec2(2.0, 4.0)); - let r = egui::Frame::NONE - .fill(t.field) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(5)) - .show(ui, |ui| ui.add(edit)) - .inner - .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); - if r.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Enter)) { - // A page label first (logical page numbers, as Acrobat), then a number. - let typed = view.page_input.clone(); - view.go_to_typed(&typed, &labels); - } - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } - }); - if let Some(id) = page_display { - app.execute(id); - } + }, + ); }); } diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs index b06e81d12d..889d952520 100644 --- a/crates/ui-egui/tests/min_window.rs +++ b/crates/ui-egui/tests/min_window.rs @@ -36,3 +36,85 @@ fn a_wide_window_keeps_the_five_mode_tabs() { assert!(h.query_by_role_and_label(Role::Button, mode).is_some(), "{mode} tab"); } } + +fn fixture(n: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn protected(user: &str, owner: &str, permissions: i32) -> Vec { + protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions) +} + +fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: user, + owner_password: owner, + permissions, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +/// A narrow window: the stacked notice bar keeps both buttons inside the window and apart. +#[test] +fn the_stacked_notice_keeps_its_buttons_in_view() { + let mut h = Harness::builder().with_size(egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1])).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); + app + }); + h.run_steps(8); + let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1])); + let msg = h.get_by_label_contains("This document is secured").rect(); + let (unlock, settings) = (h.get_by_label("Unlock…").rect(), h.get_by_label("Security settings").rect()); + for (name, r) in [("unlock", unlock), ("settings", settings)] { + assert!(window.contains_rect(r), "{name} stays inside the window: {r:?}"); + assert!(r.left() >= msg.left() - 30.0, "{name} is not cut off at the bar's left edge: {r:?} vs {msg:?}"); + assert!(r.top() >= msg.bottom() - 0.5 || r.bottom() <= msg.top() + 0.5, "{name} does not cover the message"); + } + assert!(!unlock.intersects(settings), "the buttons do not overlap: {unlock:?} {settings:?}"); +} + +/// #1913 (1182 x 556): the rail's top buttons and its page-navigation cluster must not overlap. +#[test] +fn a_short_window_does_not_overlap_the_rail_clusters() { + let h = { + let mut h = Harness::builder().with_size(egui::vec2(1182.0, 556.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, fixture(2)).unwrap(); + app + }); + h.run_steps(8); + h + }; + let tags = h.get_by_label("Tags").rect(); + let next = h.get_by_label("Previous page").rect(); + assert!(next.top() >= tags.bottom(), "page navigation sits below the rail buttons: {tags:?} vs {next:?}"); +} From fb7f7252dbc470ef5736295da94134f2c3e5146f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 23:33:25 +0200 Subject: [PATCH 006/562] UI: size the rail's bottom cluster for the history buttons, fix a pixel-fragile flick test (#1913) The rail's page-navigation cluster measures 360 pt since the two view-history buttons landed; RAIL_BOTTOM_H was 316, so in a short window the cluster overflowed its allocation and overlapped. The min-window test now uses the minimum height and checks the page box, history and zoom buttons. a_quick_flick_on_either_edge_rewraps_the_paragraph grabbed the widened edge 2 px outside the box, at the limit of the grab zone; the rail's scroll area moved the canvas width by 3 px and the press missed. It now grabs 1 px outside. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 4 ++-- crates/ui-egui/tests/editing.rs | 4 +++- crates/ui-egui/tests/min_window.rs | 12 ++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index d4c6f09503..51f269dc43 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -648,8 +648,8 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Height the rail's page-navigation cluster (zoom, fit, page box) needs; below it the rail scrolls. -const RAIL_BOTTOM_H: f32 = 316.0; +/// Height the rail's page-navigation cluster (zoom, fit, page, history buttons, page box) needs (360 pt measured); below it the rail scrolls. +const RAIL_BOTTOM_H: f32 = 364.0; pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db525751..cef7214485 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3487,7 +3487,9 @@ fn a_quick_flick_on_either_edge_rewraps_the_paragraph() { assert_eq!(text, ["Page", "1"], "narrowed from the right edge"); // Flick the right edge outward, past the box and its handle: back onto one line. let b = block(&h); - let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0); + // 1 px outside the box: the grab zone's outer limit sits near 2 px, where a pixel of page + // scale (the canvas width depends on the rail) decides whether the press lands on the handle. + let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(1.0, 0.0); flick(&mut h, edge, edge + egui::vec2(80.0 * k, 0.0)); let text: Vec = lines(&h).iter().map(|l| l.text.clone()).collect(); assert_eq!(text, ["Page 1"], "widened by a fast drag that left the handle at once"); diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs index 889d952520..8d28c6aff6 100644 --- a/crates/ui-egui/tests/min_window.rs +++ b/crates/ui-egui/tests/min_window.rs @@ -101,11 +101,11 @@ fn the_stacked_notice_keeps_its_buttons_in_view() { assert!(!unlock.intersects(settings), "the buttons do not overlap: {unlock:?} {settings:?}"); } -/// #1913 (1182 x 556): the rail's top buttons and its page-navigation cluster must not overlap. +/// #1913 (the minimum height): the rail's top buttons and its page-navigation cluster must not overlap. #[test] fn a_short_window_does_not_overlap_the_rail_clusters() { let h = { - let mut h = Harness::builder().with_size(egui::vec2(1182.0, 556.0)).build_eframe(|_cc| { + let mut h = Harness::builder().with_size(egui::vec2(1182.0, MIN_WINDOW_SIZE[1])).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("a.pdf", None, fixture(2)).unwrap(); @@ -117,4 +117,12 @@ fn a_short_window_does_not_overlap_the_rail_clusters() { let tags = h.get_by_label("Tags").rect(); let next = h.get_by_label("Previous page").rect(); assert!(next.top() >= tags.bottom(), "page navigation sits below the rail buttons: {tags:?} vs {next:?}"); + // The history buttons are the cluster's topmost buttons (above the page box). + let history = h.get_by_label_contains("Previous view").rect(); + assert!(history.top() >= tags.bottom(), "view history sits below the rail buttons: {tags:?} vs {history:?}"); + // The topmost thing in the cluster is the page box; the lowest is the zoom-out button. + let page_box = h.get_by_role(Role::TextInput).rect(); + assert!(page_box.top() >= tags.bottom(), "the page box sits below the rail buttons: {tags:?} vs {page_box:?}"); + let zoom_out = h.get_by_label_contains("Zoom out").rect(); + assert!(zoom_out.top() >= history.bottom(), "zoom sits below view history: {history:?} vs {zoom_out:?}"); } From d6d4999a355325e701506df85318ddf50f1689dd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:39:14 +0200 Subject: [PATCH 007/562] Test: the rail now has two text fields (zoom and page box) After merging main's editable zoom field, get_by_role(TextInput) in the min-window test found two nodes. Check every text field of the rail sits below the rail buttons. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/min_window.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs index 8d28c6aff6..005c97dc5f 100644 --- a/crates/ui-egui/tests/min_window.rs +++ b/crates/ui-egui/tests/min_window.rs @@ -120,9 +120,11 @@ fn a_short_window_does_not_overlap_the_rail_clusters() { // The history buttons are the cluster's topmost buttons (above the page box). let history = h.get_by_label_contains("Previous view").rect(); assert!(history.top() >= tags.bottom(), "view history sits below the rail buttons: {tags:?} vs {history:?}"); - // The topmost thing in the cluster is the page box; the lowest is the zoom-out button. - let page_box = h.get_by_role(Role::TextInput).rect(); - assert!(page_box.top() >= tags.bottom(), "the page box sits below the rail buttons: {tags:?} vs {page_box:?}"); + // The cluster holds two text fields, the zoom field and the page box; the page box is the topmost thing in it, the lowest is the zoom-out button. + for field in h.get_all_by_role(Role::TextInput) { + let r = field.rect(); + assert!(r.top() >= tags.bottom(), "the rail's text fields sit below the rail buttons: {tags:?} vs {r:?}"); + } let zoom_out = h.get_by_label_contains("Zoom out").rect(); assert!(zoom_out.top() >= history.bottom(), "zoom sits below view history: {history:?} vs {zoom_out:?}"); } From 8239f9c4eda76ed126ddf2f063544cc5a7819289 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 20 Oct 2026 00:55:51 +0200 Subject: [PATCH 008/562] Optimize target_bytes: keep zip/retain image encoding on retries, only tighten ppi Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 11 +++++++---- crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 8 ++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 17c1f7293c..a279856fb5 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -93,6 +93,7 @@ const MAX_DPI: f64 = 600.0; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; /// `doc_optimize` with `target_bytes`: (max target ppi, max JPEG quality) per extra attempt, mildest first. +/// The quality applies only to images the caller left on JPEG; zip and retain keep their encoding. const TARGET_LADDER: [(f64, u8); 5] = [(110.0, 45), (96.0, 35), (72.0, 28), (54.0, 20), (36.0, 12)]; /// Bookmarks in one page of the bookmark list, by default and at most. @@ -1143,10 +1144,10 @@ impl Automation { img.downsample = true; img.target_ppi = img.target_ppi.min(ppi); img.above_ppi = img.above_ppi.min(ppi * 1.5).max(img.target_ppi); - img.compression = match img.compression { - Compression::Jpeg(q) => Compression::Jpeg(q.min(quality)), - _ => Compression::Jpeg(quality), - }; + // Only a caller who chose JPEG gets a lower quality; zip and retain stay as asked. + if let Compression::Jpeg(q) = img.compression { + img.compression = Compression::Jpeg(q.min(quality)); + } } let (b2, r2) = self.session.optimized_bytes(id, &s, &discard).map_err(failed)?; attempts += 1; @@ -1160,6 +1161,8 @@ impl Automation { let mut extra = json!({}); if let Some(t) = target { let (ppi, quality) = step_used.checked_sub(1).and_then(|k| TARGET_LADDER.get(k)).copied().unzip(); + let lossy = matches!(settings.color.compression, Compression::Jpeg(_)) || matches!(settings.gray.compression, Compression::Jpeg(_)); + let quality = quality.filter(|_| lossy); extra = json!({ "target_bytes": t, "target_reached": bytes.len() <= t, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 2365086a33..550fa119d8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1303,7 +1303,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi with JPEG quality at most 45, 35, 28, 20, 12; applied to colour and grey images) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi with applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 8018c83321..b6e5305a69 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -4791,6 +4791,14 @@ fn optimizing_to_a_target_size() { let again = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "again.pdf" })); assert_eq!(again["bytes_after"].as_u64().unwrap(), base_len); assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "target_bytes": 0 })), Err(ToolError::InvalidArgs(_)))); + // A caller who asked for lossless images keeps them: retries only tighten the ppi, no JPEG quality. + let lossless = json!({ "compression": "zip" }); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "zip.pdf", "color": lossless, "gray": lossless, "target_bytes": 1 })); + assert_eq!(r["attempts"].as_u64(), Some(6), "{r}"); + assert!(r["target_settings"]["max_ppi"].is_number() && r["target_settings"]["max_jpeg_quality"].is_null(), "{r}"); + // A JPEG caller gets the quality step. + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jpg.pdf", "target_bytes": 1 })); + assert!(r["target_settings"]["max_jpeg_quality"].is_number(), "{r}"); // Confinement still applies. assert!(a.call("doc_optimize", &json!({ "doc": doc, "path": "../out.pdf", "target_bytes": 1000 })).is_err()); } From 86af84171f004d1e06564a01152406bf4d997ff4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 24 Oct 2026 21:11:43 +0200 Subject: [PATCH 009/562] doc_optimize: reject a target_bytes too large for usize Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 53e99e008d..ffde1dfab3 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1308,7 +1308,7 @@ impl Automation { }; let target = match a.opt_int("target_bytes")? { None => None, - Some(t) if t >= 1 => Some(t as usize), + Some(t) if t >= 1 => Some(usize::try_from(t).map_err(|_| ToolError::InvalidArgs(format!("target_bytes {t} is too large")))?), Some(t) => return Err(ToolError::InvalidArgs(format!("target_bytes must be at least 1, not {t}"))), }; let (mut bytes, mut r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; From 0f64d9589ed5469490bd5d176ce47101f2c1d45a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 24 Oct 2026 21:45:07 +0200 Subject: [PATCH 010/562] Run rustfmt on the mode bar after the upstream merge Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b08c48518d..e5b239ecbb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -507,8 +507,7 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add_space(10.0); // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. - let modes = - [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]; + let modes = [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]; // What the right side needs (three 32 px buttons, gaps) and the least search box worth showing. let avail = ui.available_width(); let tabs_w: f32 = modes.iter().map(|(_, l)| widgets::mode_tab_width(ui, tl!(*l))).sum::() + 2.0 * (modes.len() as f32 - 1.0); From 589c4af8e7138aa22eb51322353835d2ab73b772 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 25 Oct 2026 10:19:17 +0100 Subject: [PATCH 011/562] Redact regex: fail when a page exceeds the match cap UserRegex::find now returns an error instead of silently stopping at 10,000 matches, so redact_mark cannot report success while later matches stay readable. Empty-match detection probes several short texts so patterns like \b are refused with the clear message. Schema notes that the 1024 limit is in bytes. Co-Authored-By: Claude Sonnet 5.5 --- crates/automation/src/redact.rs | 19 ++++++++++++- crates/automation/src/tools.rs | 4 +-- crates/redact/src/user_regex.rs | 48 ++++++++++++++++++++++++--------- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 840020c570..808b6eabb7 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -126,7 +126,24 @@ impl Automation { let info = self.doc(a)?.info.clone(); for (&p, text) in pages.iter().zip(&texts) { let hits: Vec<(Option, _)> = match (&user_re, pattern) { - (Some(re), _) => text.find_with(|chars| re.find(chars)).into_iter().map(|h| (None, h)).collect(), + (Some(re), _) => { + // A page with too many matches fails the call: marking only the first ones + // would leave the rest readable after redact_apply. + let too_many = std::cell::Cell::new(false); + let found = text.find_with(|chars| { + re.find(chars).unwrap_or_else(|_| { + too_many.set(true); + Vec::new() + }) + }); + if too_many.get() { + return Err(ToolError::InvalidArgs(format!( + "regex has more than 10000 matches on page {}; nothing was marked. Narrow the expression so that every match can be marked", + p + 1 + ))); + } + found.into_iter().map(|h| (None, h)).collect() + } (None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(), (None, None) => needles .iter() diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4c651c00dd..0a41cacc0a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -486,7 +486,7 @@ pub fn tools() -> Vec { t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, more than 10000 matches on one page fail the call, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ @@ -498,7 +498,7 @@ pub fn tools() -> Vec { "case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." }, "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, - "regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax); every match is marked." }, + "regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax), at most 1024 bytes (maxLength counts characters, the limit is bytes); every match is marked." }, "validate": { "type": "string", "enum": ["luhn", "mod97", "iban"], "description": "With regex: keep a match only if its checksum is valid (spaces and hyphens ignored)." }, "whole_pages": { "type": "boolean" }, "pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"), diff --git a/crates/redact/src/user_regex.rs b/crates/redact/src/user_regex.rs index 043b51d0b0..9d1c1b0004 100644 --- a/crates/redact/src/user_regex.rs +++ b/crates/redact/src/user_regex.rs @@ -13,7 +13,8 @@ use thiserror::Error; /// Longest expression accepted, in bytes. pub const MAX_REGEX_LEN: usize = 1024; -/// Most matches returned for one page; the rest are ignored. +/// Most matches accepted for one page; one more makes [`UserRegex::find`] fail, so a page is never +/// marked only in part. pub const MAX_MATCHES_PER_TEXT: usize = 10_000; /// Cap on the compiled program and on the lazy-DFA cache, in bytes. const SIZE_LIMIT: usize = 1 << 20; @@ -30,6 +31,8 @@ pub enum RegexError { Invalid(String), #[error("regex matches the empty string, so it would mark nothing useful; require at least one character")] MatchesEmpty, + #[error("regex has more than {MAX_MATCHES_PER_TEXT} matches on one page; narrow it so that every match can be marked")] + TooManyMatches, } /// Checksum a match must pass to be kept. @@ -106,6 +109,12 @@ fn mod97(s: impl Iterator) -> bool { n >= 2 && rem == 1 } +/// Whether `re` can match the empty string, which `is_match("")` alone misses for assertions such +/// as `\b`. Tries a few short texts with letters, digits, space, newline and a non-ASCII letter. +fn can_match_empty(re: ®ex::Regex) -> bool { + ["", " ", "a", "1", "a a", "a\n", "\n", "é", "aa 11"].iter().any(|p| re.find_iter(p).any(|m| m.is_empty())) +} + /// A compiled caller expression with an optional checksum. #[derive(Debug, Clone)] pub struct UserRegex { @@ -127,14 +136,17 @@ impl UserRegex { .nest_limit(NEST_LIMIT) .build() .map_err(|e| RegexError::Invalid(e.to_string()))?; - if re.is_match("") { + if can_match_empty(&re) { return Err(RegexError::MatchesEmpty); } Ok(Self { re, validator }) } /// Character ranges of `text` the expression matches (non-overlapping, left to right). - pub fn find(&self, text: &[char]) -> Vec> { + /// + /// Fails with [`RegexError::TooManyMatches`] when more than [`MAX_MATCHES_PER_TEXT`] matches + /// are kept, instead of returning a partial list the caller would take for complete. + pub fn find(&self, text: &[char]) -> Result>, RegexError> { let s: String = text.iter().collect(); // Char index of every byte offset that starts a char (and of the end). let mut byte_to_char = vec![usize::MAX; s.len().saturating_add(1)]; @@ -148,9 +160,6 @@ impl UserRegex { } let mut out = Vec::new(); for m in self.re.find_iter(&s) { - if out.len() >= MAX_MATCHES_PER_TEXT { - break; - } if m.is_empty() || self.validator.is_some_and(|v| !v.check(m.as_str())) { continue; } @@ -158,10 +167,13 @@ impl UserRegex { && a < b && b != usize::MAX { + if out.len() >= MAX_MATCHES_PER_TEXT { + return Err(RegexError::TooManyMatches); + } out.push(a..b); } } - out + Ok(out) } } @@ -177,7 +189,7 @@ mod tests { fn finds_matches_as_char_ranges_with_multibyte_text() { let r = UserRegex::new(r"\d{2}( \d{2}){2}", None).unwrap(); let t = chars("Ré 85 05 78 et 91 12 34 fin"); - let found: Vec = r.find(&t).into_iter().map(|g| t[g].iter().collect()).collect(); + let found: Vec = r.find(&t).unwrap().into_iter().map(|g| t[g].iter().collect()).collect(); assert_eq!(found, ["85 05 78", "91 12 34"]); } @@ -185,10 +197,10 @@ mod tests { fn validators_filter_matches() { let t = chars("ok 4539 1488 0343 6467 bad 4539 1488 0343 6468"); let r = UserRegex::new(r"\d{4}( \d{4}){3}", Some(Validator::Luhn)).unwrap(); - assert_eq!(r.find(&t).len(), 1); + assert_eq!(r.find(&t).unwrap().len(), 1); let t = chars("GB82 WEST 1234 5698 7654 32 and GB83 WEST 1234 5698 7654 32"); let r = UserRegex::new(r"GB\d\d WEST( \d{4}){3} \d\d", Some(Validator::Iban)).unwrap(); - assert_eq!(r.find(&t).len(), 1); + assert_eq!(r.find(&t).unwrap().len(), 1); assert!(Validator::Mod97.check("98") && !Validator::Mod97.check("99")); assert!(!Validator::Luhn.check("12a4") && !Validator::Mod97.check("é1") && !Validator::Luhn.check("0")); } @@ -199,13 +211,25 @@ mod tests { assert!(matches!(UserRegex::new(&"a".repeat(MAX_REGEX_LEN + 1), None), Err(RegexError::TooLong(_)))); assert!(matches!(UserRegex::new("(unclosed", None), Err(RegexError::Invalid(_)))); assert!(matches!(UserRegex::new(r"(a)\1", None), Err(RegexError::Invalid(_))), "no back-references"); - assert_eq!(UserRegex::new("a*", None).unwrap_err(), RegexError::MatchesEmpty); + for empty in ["a*", r"\b", "(?m)^", r"\B", "(?m)$", r"\d*|x"] { + assert_eq!(UserRegex::new(empty, None).unwrap_err(), RegexError::MatchesEmpty, "{empty}"); + } + assert!(UserRegex::new(r"\bfoo\b", None).is_ok()); assert!(matches!(UserRegex::new(r"\p{L}{1000}{1000}", None), Err(RegexError::Invalid(_))), "compiled size is capped"); assert!(matches!(UserRegex::new(&format!("{}a{}", "(".repeat(100), ")".repeat(100)), None), Err(RegexError::Invalid(_)))); // Classic catastrophic pattern finishes at once on a long non-matching input. let r = UserRegex::new(r"(a+)+$", None).unwrap(); let mut t = chars(&"a".repeat(50_000)); t.push('!'); - assert!(r.find(&t).is_empty()); + assert!(r.find(&t).unwrap().is_empty()); + } + + #[test] + fn more_than_the_cap_of_matches_is_an_error_not_a_partial_list() { + let r = UserRegex::new("x", None).unwrap(); + let at_cap = chars(&"x ".repeat(MAX_MATCHES_PER_TEXT)); + assert_eq!(r.find(&at_cap).unwrap().len(), MAX_MATCHES_PER_TEXT); + let over = chars(&"x ".repeat(MAX_MATCHES_PER_TEXT + 1)); + assert_eq!(r.find(&over).unwrap_err(), RegexError::TooManyMatches); } } From 90fb9b8c9e37adb0663bacca887248672f68d8e4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 19:18:16 +0100 Subject: [PATCH 012/562] Test the menu bar's File item instead of the removed Menu button Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/it/min_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/min_window.rs b/crates/ui-egui/tests/it/min_window.rs index fc395e7cdf..f384eef0eb 100644 --- a/crates/ui-egui/tests/it/min_window.rs +++ b/crates/ui-egui/tests/it/min_window.rs @@ -26,7 +26,7 @@ fn at_the_minimum_width_the_modes_fold_into_a_menu_and_search_stays() { h.get_by_role_and_label(Role::Button, "E-Sign").click(); h.run_steps(4); assert!(h.query_by_role_and_label(Role::Button, "E-Sign").is_some(), "the menu button is named after the mode"); - assert!(h.query_by_role_and_label(Role::Button, "Menu").is_some(), "main menu reachable"); + assert!(h.query_by_role_and_label(Role::MenuItem, "File").is_some(), "main menu reachable"); } #[test] From 88649a8955fa674adcaa35af48340de74209e294 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 20:35:42 +0100 Subject: [PATCH 013/562] Name Read mode in the folded mode menu The narrow-window mode menu fell back to "All tools" while Read was active. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f831d26d9c..5e1fef8860 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -505,8 +505,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } else { // Narrow window: the modes fold into one menu named after the current one. - let current = modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l); - let resp = widgets::ghost_button(ui, "chevron-down", tl!(current)); + // Read is not one of the four choices but can be active (View command): name it too. + let current = if app.mode == Mode::Read { + tl!("Read") + } else { + tl!(modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l)) + }; + let resp = widgets::ghost_button(ui, "chevron-down", current); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(160.0); for (mode, label) in modes { From fe03703a3b71a92c96a1d5318b3ad4dd9d02ab88 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 21:55:16 +0100 Subject: [PATCH 014/562] perf(cos): keep a running total of the scan reader's cache size ObjectReader::try_get measured every parsed object in its cache (up to 128) after each read to check the byte budget. Keep a running total instead and measure the whole cache only when something other than one freshly parsed object changed it. Eviction timing is unchanged, so output is identical. Fixes #4867 Co-Authored-By: Claude Sonnet 5.5 --- crates/cos/src/document.rs | 43 +++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 3c7f5fbe18..74b773e026 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -445,6 +445,10 @@ pub struct Document { pub(crate) struct ObjectReader { pub(crate) document: Document, decoded_count: usize, + /// Objects in the parsed-object cache and the bytes of their own, as last measured. Kept + /// as a running total so a read does not measure the whole cache again. + counted_objects: usize, + counted_bytes: usize, } impl ObjectReader { @@ -488,8 +492,25 @@ impl ObjectReader { // have cached large objects. if let Ok(mut cache) = self.document.cache.lock() { let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { + // The common read parsed exactly this object and nothing else (a hit changes + // nothing): add its size to the running total. Anything else, such as a failed + // load, a /Length that cached further objects or a cache changed from outside, + // measures the whole cache again. + let grew_by_this_object = cache.len() == self.counted_objects.saturating_add(1) + && matches!((&object, cache.get(&reference.num)), (Ok(read), Some(cached)) if Arc::ptr_eq(read, cached)); + if object.is_ok() && cache.len() == self.counted_objects { + // Unchanged: a hit, or an object held by the overlay. + } else if let (true, Ok(read)) = (grew_by_this_object, &object) { + self.counted_bytes = self.counted_bytes.saturating_add(owned_bytes(read, data, 0)); + self.counted_objects = cache.len(); + } else { + self.counted_bytes = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); + self.counted_objects = cache.len(); + } + if cache.len() >= Self::OBJECTS || self.counted_bytes > Self::STREAM_BYTES { cache.clear(); + self.counted_objects = 0; + self.counted_bytes = 0; } } object @@ -1176,7 +1197,7 @@ impl Document { let mut document = self.clone(); document.cache = Arc::default(); document.objstms = Arc::default(); - ObjectReader { document, decoded_count: 0 } + ObjectReader { document, decoded_count: 0, counted_objects: 0, counted_bytes: 0 } } fn load(&self, num: u32, depth: u32) -> Result { @@ -3004,11 +3025,27 @@ endobj // Data of its own (decrypted streams, strings) counts: past the budget the cache is // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + reader.document.cache.lock().unwrap().insert(5, Arc::new(owned)); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } + #[test] + fn temporary_reader_running_total_matches_the_cache() { + let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), "<< /Type /Pages /Kids [] /Count 0 >>".to_owned()]; + bodies.extend((0..300).map(|i| format!("<< /Value {i} /Name ({}) >>", "s".repeat(i % 40)))); + let refs: Vec<_> = bodies.iter().map(String::as_str).collect(); + let doc = Document::open(Arc::new(build(&refs, "/Root 1 0 R"))).unwrap(); + let mut reader = doc.object_reader(); + for num in (1..=302).chain([5, 301, 99_999, 7]) { + let _ = reader.try_get(ObjRef::new(num, 0)); + let cache = reader.document.cache.lock().unwrap(); + let measured = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &reader.document.data, 0))); + assert_eq!((reader.counted_objects, reader.counted_bytes), (cache.len(), measured), "after object {num}"); + assert!(cache.len() < ObjectReader::OBJECTS); + } + } + #[test] fn temporary_reader_and_full_save_do_not_fill_snapshot_caches() { let mut source = Document::new_empty(); From be8345aa3f2d9930201e766a4c8a8f315a48f8d6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 21:57:29 +0100 Subject: [PATCH 015/562] perf(cos): rebuild a damaged cross-reference index without parsing every object Reconstruct parsed every object to see whether it is an object stream, and searched each object's next 1,024 bytes and the whole file byte by byte. Only objects whose dictionary holds ObjStm or a # (a name can hide letters as #xx) are now parsed for that check. Every /XRef position is found once, and find() scans for the needle's rarest byte in 32-byte blocks. The index, trailer and repair log are unchanged. Fixes #4868 Co-Authored-By: Claude Sonnet 5.5 --- crates/cos/src/document.rs | 72 ++++++++++++++++++++++++++++-- crates/cos/tests/it/main.rs | 1 + crates/cos/tests/it/reconstruct.rs | 57 +++++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 crates/cos/tests/it/reconstruct.rs diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 3c7f5fbe18..4ad24a11b1 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -2052,10 +2052,29 @@ impl Document { // Offsets of objects that may be cross-reference streams: their dictionaries are the // trailers of files that have no `trailer` keyword (§7.5.8). let mut xref_streams = Vec::new(); - for (num, offset, generation, body) in object_headers(&data) { + // Objects whose bytes (the last object of a number wins, as in `entries`) could hold an + // `/ObjStm` type; the others are not parsed for the object-stream check below. + let mut candidates: HashSet = HashSet::new(); + let headers = object_headers(&data); + // Where `/XRef` occurs in the file, found in one pass. + let mut xref_marks = Vec::new(); + let mut mark_from = 0; + while let Some(p) = find(&data, b"/XRef", mark_from, data.len()) { + xref_marks.push(p); + mark_from = p + 1; + } + for (i, &(num, offset, generation, body)) in headers.iter().enumerate() { entries.insert(num, XrefEntry::InFile { offset, generation }); objstms.push(num); - if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some() + let end = headers.get(i + 1).and_then(|h| usize::try_from(h.1).ok()).unwrap_or(data.len()); + if may_be_object_stream(&data, body, end) { + candidates.insert(num); + } else { + candidates.remove(&num); + } + // `/XRef` within the 1024 bytes after the header (the first one that is there). + let first = xref_marks.partition_point(|&p| p < body); + if xref_marks.get(first).is_some_and(|&p| p + 5 <= body.saturating_add(1024).min(data.len())) && let Ok(at) = usize::try_from(offset) { xref_streams.push(at); @@ -2068,7 +2087,8 @@ impl Document { self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); // Objects inside object streams. for num in objstms { - if let Ok(o) = self.try_get(num) + if candidates.contains(&num) + && let Ok(o) = self.try_get(num) && let Object::Stream(s) = &*o && s.dict.name(b"Type") == Some(b"ObjStm") && let Ok(stm) = self.objstm(num) @@ -2165,6 +2185,24 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option { } } +/// Whether the object whose body is `data[from..to]` could be an object stream: its dictionary +/// holds `ObjStm`, or a `#` (a name may hide its letters as `#xx`, as in `/Obj#53tm`). A stream's +/// data is not searched: it ends the dictionary, at the `stream` keyword after `>>`. +fn may_be_object_stream(data: &[u8], from: usize, to: usize) -> bool { + let Some(body) = data.get(from..to.min(data.len())) else { return false }; + let mut dict = body; + let mut at = 0; + while let Some(p) = find(body, b"stream", at, body.len()) { + at = p + 6; + let after_dict = body[..p].trim_ascii_end().ends_with(b">>"); + if after_dict && matches!(body.get(at), Some(b'\r' | b'\n')) { + dict = &body[..p]; + break; + } + } + dict.contains(&b'#') || find(dict, b"ObjStm", 0, dict.len()).is_some() +} + /// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position /// after `obj`). fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> { @@ -2241,7 +2279,33 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option< if from >= to || needle.is_empty() || to - from < needle.len() { return None; } - hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from) + let hay = &hay[from..to]; + // Look for the needle's rarest byte first, a plain byte scan, and compare the rest only there. + const COMMON: &[u8] = b" etaoinsrhldcumfpgwybvkxjqz"; + let (k, rare) = + needle.iter().copied().enumerate().max_by_key(|&(_, b)| COMMON.iter().position(|&c| c == b).unwrap_or(COMMON.len())).unwrap_or((0, 0)); + let mut i = k; + while let Some(p) = hay.get(i..).and_then(|rest| position_of(rest, rare)) { + let start = i + p - k; + if hay.get(start..start + needle.len()) == Some(needle) { + return Some(start + from); + } + i += p + 1; + } + None +} + +/// The first position of `byte` in `hay`. Whole 32-byte blocks are tested without branching, which +/// the compiler turns into vector compares; only a block that holds the byte is searched. +fn position_of(hay: &[u8], byte: u8) -> Option { + const BLOCK: usize = 32; + let (blocks, rest) = hay.as_chunks::(); + for (i, block) in blocks.iter().enumerate() { + if block.iter().fold(false, |any, &b| any | (b == byte)) { + return block.iter().position(|&b| b == byte).map(|p| i * BLOCK + p); + } + } + rest.iter().position(|&b| b == byte).map(|p| blocks.len() * BLOCK + p) } fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option { diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs index 52cf05d6c2..07883e7b5a 100644 --- a/crates/cos/tests/it/main.rs +++ b/crates/cos/tests/it/main.rs @@ -4,5 +4,6 @@ mod bounded_writer; mod corpus; mod encryption; mod proptests; +mod reconstruct; mod shared_streams; mod writer; diff --git a/crates/cos/tests/it/reconstruct.rs b/crates/cos/tests/it/reconstruct.rs new file mode 100644 index 0000000000..5f61867b1b --- /dev/null +++ b/crates/cos/tests/it/reconstruct.rs @@ -0,0 +1,57 @@ +//! Rebuilding the cross-reference index of a damaged file finds the objects inside object +//! streams however the stream's type is written. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, Object}; + +/// A file whose `startxref` points nowhere, with three object streams (type written plainly, as +/// `/Obj#53tm`, and after a string that contains the word `stream`), and one ordinary stream +/// whose data mentions `ObjStm` and `#`. +fn damaged() -> Vec { + let mut out = b"%PDF-1.5\n".to_vec(); + let mut add = |n: u32, body: &[u8]| { + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + let objstm = |dict_prefix: &str, members: &[(u32, &str)]| { + let mut header = String::new(); + let mut body = String::new(); + for (n, text) in members { + header.push_str(&format!("{n} {} ", body.len())); + body.push_str(text); + body.push('\n'); + } + let mut b = + format!("<< {dict_prefix} /N {} /First {} /Length {} >>\nstream\n", members.len(), header.len(), header.len() + body.len()).into_bytes(); + b.extend_from_slice(header.as_bytes()); + b.extend_from_slice(body.as_bytes()); + b.extend_from_slice(b"\nendstream"); + b + }; + add(1, b"<< /Type /Catalog /Pages 2 0 R >>"); + add(2, b"<< /Type /Pages /Kids [] /Count 0 >>"); + add(3, &objstm("/Type /ObjStm", &[(10, "<< /From 3 >>"), (11, "[3]")])); + add(4, &objstm("/Type /Obj#53tm", &[(12, "<< /From 4 >>")])); + add(5, &objstm("/Note (a stream) /Type /ObjStm", &[(13, "<< /From 5 >>")])); + let data = b"/Type /ObjStm # 14 0 << /From 6 >>"; + let mut plain = format!("<< /Length {} >>\nstream\n", data.len()).into_bytes(); + plain.extend_from_slice(data); + plain.extend_from_slice(b"\nendstream"); + add(6, &plain); + out.extend_from_slice(b"trailer\n<< /Size 20 /Root 1 0 R >>\nstartxref\n99999999\n%%EOF\n"); + out +} + +#[test] +fn objects_in_object_streams_are_found_however_the_type_is_written() { + let doc = Document::open(Arc::new(damaged())).expect("opens"); + assert!(doc.reconstructed()); + for n in [10, 11, 12, 13] { + let o = doc.try_get(n).unwrap_or_else(|e| panic!("object {n}: {e}")); + assert!(matches!(&*o, Object::Dict(_) | Object::Array(_)), "object {n} is {o:?}"); + } + // The data of an ordinary stream is not an object stream, whatever it says. + assert!(!doc.object_numbers().contains(&14)); +} From a7baf8a2af97eeed166325a990bb116cdf3e1d9b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 21:59:12 +0100 Subject: [PATCH 016/562] perf(cos): write_full keeps the objects it parsed while numbering write_full parsed each object once to number it and again, after the 128-object reader cache had dropped it, to write it. Keep the parsed object from the traversal and write that, within a budget of 64 MiB of owned bytes and 2M tree nodes; past it the object is read again as before, which keeps the memory bound and the "read differently while saving" check. Fixes #4869 Co-Authored-By: Claude Sonnet 5.5 --- crates/cos/src/document.rs | 2 +- crates/cos/src/writer.rs | 89 +++++++++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 74b773e026..9dca286d7e 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -530,7 +530,7 @@ const OWNED_BYTES_DEPTH: usize = 256; /// Bytes `object` holds of its own: string and stream data that is not a view into `data` /// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured /// and counts as too large to keep. -fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { +pub(crate) fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { if depth > OWNED_BYTES_DEPTH { return usize::MAX; } diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 627f716495..1ba4574318 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -15,7 +15,7 @@ use std::io::Write; use std::sync::Arc; use crate::CosError; -use crate::document::{Document, ObjectReader, XrefEntry}; +use crate::document::{Document, ObjectReader, XrefEntry, owned_bytes}; use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; /// Serialize one object. Deterministic: same object → same bytes. @@ -280,6 +280,10 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro let mut map: HashMap = HashMap::new(); // Record the type while traversing so classifying packed objects needs no second parse. let mut order: Vec<(ObjRef, bool)> = Vec::new(); + // The parsed object of each entry in `order`, kept for the write pass while the budget + // allows it. Past the budget the write pass reads the object again. + let mut kept: Vec>> = Vec::new(); + let mut kept_budget = KeptObjects::default(); let mut queue: VecDeque = VecDeque::new(); let visit_refs = |o: &Object, queue: &mut VecDeque| collect_refs(o, &mut |r| queue.push_back(r)); for key in [&b"Root"[..], b"Info", b"Encrypt"] { @@ -305,6 +309,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro map.insert(r, output_number(order.len())?); order.push((r, matches!(*o, Object::Stream(_)))); visit_refs(&o, &mut queue); + kept.push(kept_budget.admit(&o, reader.document.bytes()).then_some(o)); } let version = reader.document.version().chars().take(3).collect::(); let version = if opts.object_streams && version.as_str() < "1.5" { "1.5".to_string() } else { version }; @@ -329,14 +334,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro let mut rows: BTreeMap = BTreeMap::new(); // Keep only references, not renamed copies of the entire object graph. Standalone // objects still precede object streams, preserving the established output order. - let mut packed: Vec<(u32, ObjRef)> = Vec::new(); + let mut packed: Vec<(u32, ObjRef, Option>)> = Vec::new(); for (i, (r, is_stream)) in order.iter().enumerate() { let num = output_number(i)?; let in_stream = opts.object_streams && !is_stream && !encryption_objects.contains(r) && root_outside != Some(*r); if in_stream { - packed.push((num, *r)); + packed.push((num, *r, kept[i].take())); } else { - let o = renumber(&*reread(&mut reader, *r, *is_stream)?, &map); + let o = renumber(&*written_object(&mut reader, *r, *is_stream, kept[i].take())?, &map); rows.insert(num, Row::InFile(out.len() as u64, 0)); write_indirect(num, 0, &prepared(&reader.document, r.num, num, 0, &o), &mut out); } @@ -345,9 +350,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro // individually: the whole stream is (§7.5.7, §7.6.2). let mut next = output_number(order.len())?; let mut batch = ObjectStreamBatch::default(); - for (num, reference) in packed { + for (num, reference, parsed) in packed { let mut encoded = Vec::new(); - serialize(&renumber(&*reread(&mut reader, reference, false)?, &map), &mut encoded); + serialize(&renumber(&*written_object(&mut reader, reference, false, parsed)?, &map), &mut encoded); if batch.count > 0 && batch.body.len().saturating_add(encoded.len()).saturating_add(1) > OBJSTM_BYTES { batch.flush(&reader.document, &mut next, &mut rows, &mut out)?; } @@ -400,6 +405,56 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro Ok(out) } +/// What `write_full` keeps of the objects it parsed while numbering them: owned bytes (see +/// [`owned_bytes`]; streams read from the file are views and cost only their dictionary) and +/// tree nodes. Beyond either limit objects are read again when written, which bounds the memory +/// of a save of a huge document. +#[derive(Default)] +struct KeptObjects { + bytes: usize, + nodes: usize, +} + +impl KeptObjects { + const BYTES: usize = 64 * 1024 * 1024; + const NODES: usize = 2_000_000; + + /// Whether `object` still fits the budget; if so it is counted against it. + fn admit(&mut self, object: &Object, data: &Arc>) -> bool { + let bytes = self.bytes.saturating_add(owned_bytes(object, data, 0)); + let nodes = self.nodes.saturating_add(node_count(object, 0)); + if bytes > Self::BYTES || nodes > Self::NODES { + return false; + } + (self.bytes, self.nodes) = (bytes, nodes); + true + } +} + +/// Values in `object`'s tree, counting the object itself. Past the parser's nesting limit +/// the count is `usize::MAX`, so nothing that deep is kept. +fn node_count(object: &Object, depth: usize) -> usize { + if depth > 256 { + return usize::MAX; + } + let children = match object { + Object::Stream(s) => s.dict.iter().fold(0usize, |n, (_, v)| n.saturating_add(node_count(v, depth + 1))), + Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(node_count(v, depth + 1))), + Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(node_count(v, depth + 1))), + _ => 0, + }; + children.saturating_add(1) +} + +/// The object to write for `r`: the one parsed while numbering when it was kept, else `r` read +/// again. +fn written_object(reader: &mut ObjectReader, r: ObjRef, is_stream: bool, kept: Option>) -> Result, CosError> { + match kept { + Some(o) => Ok(o), + None => reread(reader, r, is_stream), + } +} + /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. @@ -674,6 +729,28 @@ mod tests { Lexer::new(&out, 0).object().expect("parses") } + #[test] + fn kept_objects_stop_at_their_byte_and_node_budget() { + let data = Arc::new(Vec::new()); + let string = Object::String(PdfString::literal(vec![b'x'; 1000])); + let array = Object::Array(vec![Object::Int(1), Object::Null]); + let mut kept = KeptObjects::default(); + assert!(kept.admit(&string, &data) && kept.admit(&array, &data)); + assert_eq!((kept.bytes, kept.nodes), (1000, 4)); + let mut full = KeptObjects { bytes: KeptObjects::BYTES - 999, nodes: 0 }; + assert!(!full.admit(&string, &data), "one byte over the byte budget"); + assert_eq!((full.bytes, full.nodes), (KeptObjects::BYTES - 999, 0), "a refused object is not counted"); + assert!(full.admit(&Object::Int(1), &data)); + let mut full = KeptObjects { bytes: 0, nodes: KeptObjects::NODES - 2 }; + assert!(!full.admit(&array, &data), "a node past the node budget"); + assert!(full.admit(&Object::Int(1), &data)); + let mut deep = Object::Null; + for _ in 0..300 { + deep = Object::Array(vec![deep]); + } + assert!(!KeptObjects::default().admit(&deep, &data), "nesting past the limit is never kept"); + } + #[test] fn a_second_read_that_differs_fails_the_save_instead_of_writing_null() { let mut doc = Document::new_empty(); From 929d350f1dfacb6da308569f2a7de8d412a4049a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 22:00:43 +0100 Subject: [PATCH 017/562] perf(cos): write hex strings, integers and references without the formatter serialize wrote every byte of a hex string, every integer and every object number through write!. A table lookup and a small digit loop give the same bytes in a fraction of the instructions. The octal escape in literal strings and the #xx escape in names use the same helpers. Fixes #4870 Co-Authored-By: Claude Sonnet 5.5 --- crates/cos/src/writer.rs | 53 ++++++++++-- crates/cos/tests/it/main.rs | 1 + crates/cos/tests/it/serialize_format.rs | 110 ++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 crates/cos/tests/it/serialize_format.rs diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 627f716495..b8a9c53e9c 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -23,9 +23,7 @@ pub fn serialize(o: &Object, out: &mut Vec) { match o { Object::Null => out.extend_from_slice(b"null"), Object::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }), - Object::Int(i) => { - let _ = write!(out, "{i}"); - } + Object::Int(i) => write_int(*i, out), Object::Real(r) => write_real(*r, out), Object::String(s) => write_string(s, out), Object::Name(n) => write_name(n, out), @@ -42,11 +40,46 @@ pub fn serialize(o: &Object, out: &mut Vec) { Object::Dict(d) => write_dict(d, out), Object::Stream(s) => write_stream(s, out), Object::Ref(r) => { - let _ = write!(out, "{} {} R", r.num, r.generation); + write_digits(u64::from(r.num), out); + out.push(b' '); + write_digits(u64::from(r.generation), out); + out.extend_from_slice(b" R"); } } } +/// Upper-case hexadecimal digits. +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +/// The decimal digits of `v`, the same bytes `write!(out, "{v}")` gives, without the formatter. +fn write_digits(mut v: u64, out: &mut Vec) { + // `u64::MAX` has 20 digits, so `at` never goes below 0. + let mut buf = [0u8; 20]; + let mut at = buf.len(); + loop { + at -= 1; + buf[at] = b'0' + (v % 10) as u8; + v /= 10; + if v == 0 { + break; + } + } + out.extend_from_slice(&buf[at..]); +} + +/// A signed integer in decimal, as `write!(out, "{i}")` writes it. +fn write_int(i: i64, out: &mut Vec) { + if i < 0 { + out.push(b'-'); + } + write_digits(i.unsigned_abs(), out); +} + +/// `b` as two upper-case hexadecimal digits. +fn hex_pair(b: u8) -> [u8; 2] { + [HEX[usize::from(b >> 4)], HEX[usize::from(b & 15)]] +} + fn write_real(r: f64, out: &mut Vec) { if !r.is_finite() { out.push(b'0'); @@ -55,7 +88,7 @@ fn write_real(r: f64, out: &mut Vec) { // A whole number is written as an integer only within the integer range Annex C gives // (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers. if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) { - let _ = write!(out, "{}", r as i32); + write_int(i64::from(r as i32), out); return; } // Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the @@ -76,7 +109,8 @@ fn write_name(n: &[u8], out: &mut Vec) { if regular { out.push(b); } else { - let _ = write!(out, "#{b:02X}"); + out.push(b'#'); + out.extend_from_slice(&hex_pair(b)); } } } @@ -84,9 +118,10 @@ fn write_name(n: &[u8], out: &mut Vec) { fn write_string(s: &PdfString, out: &mut Vec) { let binary = s.bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count(); if s.hex || binary * 4 > s.bytes.len().max(1) { + out.reserve(s.bytes.len().saturating_mul(2).saturating_add(2)); out.push(b'<'); - for b in &s.bytes { - let _ = write!(out, "{b:02X}"); + for &b in &s.bytes { + out.extend_from_slice(&hex_pair(b)); } out.push(b'>'); return; @@ -105,7 +140,7 @@ fn write_string(s: &PdfString, out: &mut Vec) { 0x0C => out.extend_from_slice(b"\\f"), b' '..=b'~' => out.push(b), _ => { - let _ = write!(out, "\\{b:03o}"); + out.extend_from_slice(&[b'\\', b'0' + (b >> 6), b'0' + ((b >> 3) & 7), b'0' + (b & 7)]); } } } diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs index 52cf05d6c2..4b2fe32560 100644 --- a/crates/cos/tests/it/main.rs +++ b/crates/cos/tests/it/main.rs @@ -4,5 +4,6 @@ mod bounded_writer; mod corpus; mod encryption; mod proptests; +mod serialize_format; mod shared_streams; mod writer; diff --git a/crates/cos/tests/it/serialize_format.rs b/crates/cos/tests/it/serialize_format.rs new file mode 100644 index 0000000000..19781a802f --- /dev/null +++ b/crates/cos/tests/it/serialize_format.rs @@ -0,0 +1,110 @@ +//! `serialize` writes integers, references, hexadecimal strings, octal escapes and `#xx` name +//! escapes with its own digit loops. They must give the bytes the standard formatter gives. + +use pdfcraft_cos::{ObjRef, Object, PdfString, serialize}; +use proptest::prelude::*; + +fn bytes_of(o: &Object) -> Vec { + let mut v = Vec::new(); + serialize(o, &mut v); + v +} + +#[test] +fn integer_extremes_and_digit_boundaries() { + let mut cases = vec![0i64, 1, -1, 9, 10, 99, 100, i64::MAX, i64::MIN, i64::MIN + 1, i64::from(i32::MAX), i64::from(i32::MIN)]; + let mut p = 1i64; + while let Some(next) = p.checked_mul(10) { + cases.extend([next - 1, next, next + 1, -next]); + p = next; + } + for i in cases { + assert_eq!(bytes_of(&Object::Int(i)), i.to_string().into_bytes(), "{i}"); + } +} + +#[test] +fn reference_extremes() { + for (n, g) in [(0, 0), (1, 0), (u32::MAX, u16::MAX), (10, 65535), (4_000_000_000, 7)] { + assert_eq!(bytes_of(&Object::Ref(ObjRef::new(n, g))), format!("{n} {g} R").into_bytes()); + } +} + +#[test] +fn every_byte_value_in_hex_strings_octal_escapes_and_names() { + let all: Vec = (0..=255).collect(); + let hex: String = all.iter().map(|b| format!("{b:02X}")).collect(); + assert_eq!(bytes_of(&Object::String(PdfString { bytes: all.clone(), hex: true })), format!("<{hex}>").into_bytes()); + // A literal string with a few control bytes escapes them in octal. + let lit = bytes_of(&Object::String(PdfString { bytes: b"abcdefghijkl\x01\x7f\xffz".to_vec(), hex: false })); + assert_eq!(lit, b"(abcdefghijkl\\001\\177\\377z)"); + let name = bytes_of(&Object::Name(all.clone())); + let mut want = b"/".to_vec(); + for &b in &all { + let regular = (b'!'..=b'~').contains(&b) && !matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' | b'#'); + if regular { + want.push(b); + } else { + want.extend_from_slice(format!("#{b:02X}").as_bytes()); + } + } + assert_eq!(name, want); +} + +proptest! { + #[test] + fn integers_match_the_formatter(i in any::()) { + prop_assert_eq!(bytes_of(&Object::Int(i)), format!("{i}").into_bytes()); + } + + #[test] + fn whole_reals_match_the_formatter(i in any::()) { + prop_assert_eq!(bytes_of(&Object::Real(f64::from(i))), format!("{i}").into_bytes()); + } + + #[test] + fn references_match_the_formatter(n in any::(), g in any::()) { + prop_assert_eq!(bytes_of(&Object::Ref(ObjRef::new(n, g))), format!("{n} {g} R").into_bytes()); + } + + #[test] + fn hex_strings_match_the_formatter(bytes in proptest::collection::vec(any::(), 0..300)) { + let mut want = String::from("<"); + for b in &bytes { + want.push_str(&format!("{b:02X}")); + } + want.push('>'); + prop_assert_eq!(bytes_of(&Object::String(PdfString { bytes, hex: true })), want.into_bytes()); + } + + #[test] + fn binary_literal_strings_match_the_formatter(bytes in proptest::collection::vec(any::(), 0..300)) { + // Mostly-binary input is written as hex; mostly-text input with escapes is a literal. + let got = bytes_of(&Object::String(PdfString { bytes: bytes.clone(), hex: false })); + let binary = bytes.iter().filter(|b| !(b' '..=b'~').contains(*b) && !matches!(b, b'\n' | b'\r' | b'\t')).count(); + let mut want = Vec::new(); + if binary * 4 > bytes.len().max(1) { + want.push(b'<'); + for b in &bytes { + want.extend_from_slice(format!("{b:02X}").as_bytes()); + } + want.push(b'>'); + } else { + want.push(b'('); + for &b in &bytes { + match b { + b'(' | b')' | b'\\' => want.extend_from_slice(&[b'\\', b]), + b'\n' => want.extend_from_slice(b"\\n"), + b'\r' => want.extend_from_slice(b"\\r"), + b'\t' => want.extend_from_slice(b"\\t"), + 0x08 => want.extend_from_slice(b"\\b"), + 0x0C => want.extend_from_slice(b"\\f"), + b' '..=b'~' => want.push(b), + _ => want.extend_from_slice(format!("\\{b:03o}").as_bytes()), + } + } + want.push(b')'); + } + prop_assert_eq!(got, want); + } +} From d04fad3acd79906445a47e46496fb09fbfffdc11 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 27 Oct 2026 19:45:53 +0100 Subject: [PATCH 018/562] fix(cos): recount the scan reader's cache when a read replaces entries The running total trusted cache length and pointer equality to mean that a read only added the requested object. A read whose nested loads made the editor cache evict (it then keeps only the newcomer) leaves the length unchanged, so a large string stayed cached past the 8 MiB budget. Count the inserts a read makes instead; anything but a hit or exactly one insert measures the whole cache again. Co-Authored-By: Claude Sonnet 5.5 --- crates/cos/src/document.rs | 41 ++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 74b773e026..b7fe54bc19 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -21,6 +21,13 @@ thread_local! { static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); } +thread_local! { + /// Objects this thread has put into a document's parsed-object cache. A reader compares + /// it before and after a read: a read that inserted exactly one object cannot have + /// evicted or replaced anything else, whatever the cache length says. + static CACHE_INSERTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream /// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is /// bounded as well. @@ -460,7 +467,9 @@ impl ObjectReader { } pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result, CosError> { + let inserts_before = CACHE_INSERTS.with(|c| c.get()); let object = self.document.try_get(reference.num); + let inserted = CACHE_INSERTS.with(|c| c.get()).wrapping_sub(inserts_before); // A missed eviction here only costs a re-parse, so never fail the read on a poisoned // lock. The editing-cache evictors propagate instead; they guard user edits. if let Ok(mut streams) = self.document.objstms.lock() { @@ -492,13 +501,16 @@ impl ObjectReader { // have cached large objects. if let Ok(mut cache) = self.document.cache.lock() { let data = &self.document.data; - // The common read parsed exactly this object and nothing else (a hit changes - // nothing): add its size to the running total. Anything else, such as a failed - // load, a /Length that cached further objects or a cache changed from outside, - // measures the whole cache again. - let grew_by_this_object = cache.len() == self.counted_objects.saturating_add(1) + // A hit inserted nothing; a read that inserted exactly this object (no nested + // /Length or object stream load) added only it. Cache length and pointer equality + // alone cannot tell that from a read that evicted and replaced entries (a large + // nested object makes the editor cache keep only the newcomer), so count the + // inserts. Anything else, such as a failed load, a /Length that cached further + // objects or a cache changed from outside, measures the whole cache again. + let grew_by_this_object = inserted == 1 + && cache.len() == self.counted_objects.saturating_add(1) && matches!((&object, cache.get(&reference.num)), (Ok(read), Some(cached)) if Arc::ptr_eq(read, cached)); - if object.is_ok() && cache.len() == self.counted_objects { + if object.is_ok() && inserted == 0 && cache.len() == self.counted_objects { // Unchanged: a hit, or an object held by the overlay. } else if let (true, Ok(read)) = (grew_by_this_object, &object) { self.counted_bytes = self.counted_bytes.saturating_add(owned_bytes(read, data, 0)); @@ -1075,6 +1087,7 @@ impl Document { // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); + CACHE_INSERTS.with(|c| c.set(c.get().wrapping_add(1))); self.evict_editor_cache_if_over_budget(num, &obj)?; Ok(obj) } @@ -3046,6 +3059,22 @@ endobj } } + #[test] + fn temporary_reader_recounts_when_a_read_replaces_cache_entries() { + // One cached object, then a read of a string over the editor cache's byte budget: the + // editor eviction keeps only the newcomer, so the cache length stays at one. The + // string is data of its own and must not stay cached past the reader's budget. + let big = format!("({})", "a".repeat(Document::EDITOR_CACHE_BYTES + 1)); + let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Small 1 >>", &big], "/Root 1 0 R"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let mut reader = doc.object_reader(); + let _ = reader.get(ObjRef::new(3, 0)); + assert_eq!((reader.document.cache.lock().unwrap().len(), reader.counted_objects), (1, 1)); + assert!(matches!(&*reader.get(ObjRef::new(4, 0)), Object::String(_))); + assert!(reader.document.cache.lock().unwrap().is_empty(), "a replaced entry must be counted: the string is over budget"); + assert_eq!((reader.counted_objects, reader.counted_bytes), (0, 0)); + } + #[test] fn temporary_reader_and_full_save_do_not_fill_snapshot_caches() { let mut source = Document::new_empty(); From 8bdd2d37504689fbff50eb198740d384a6a2c054 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 27 Oct 2026 19:46:44 +0100 Subject: [PATCH 019/562] fix(cos): read the dictionary's syntax before the object-stream pre-check The pre-check cut the dictionary at the next object header or at a '>>' + 'stream' byte pattern, so text inside a string could hide a later /Type /ObjStm. It now reads the dictionary as the parser does (strings, hex strings, comments, nesting) and treats an object whose end is not certain as a candidate, so the reconstructed object set stays the same. Co-Authored-By: Claude Sonnet 5.5 --- crates/cos/src/document.rs | 173 ++++++++++++++++++++++++++--- crates/cos/tests/it/reconstruct.rs | 7 +- 2 files changed, 162 insertions(+), 18 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 4ad24a11b1..ffc4f6be72 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -2063,11 +2063,10 @@ impl Document { xref_marks.push(p); mark_from = p + 1; } - for (i, &(num, offset, generation, body)) in headers.iter().enumerate() { + for &(num, offset, generation, body) in &headers { entries.insert(num, XrefEntry::InFile { offset, generation }); objstms.push(num); - let end = headers.get(i + 1).and_then(|h| usize::try_from(h.1).ok()).unwrap_or(data.len()); - if may_be_object_stream(&data, body, end) { + if may_be_object_stream(&data, body) { candidates.insert(num); } else { candidates.remove(&num); @@ -2185,22 +2184,107 @@ fn shifted_subsection_end(data: &[u8], at: usize) -> Option { } } -/// Whether the object whose body is `data[from..to]` could be an object stream: its dictionary -/// holds `ObjStm`, or a `#` (a name may hide its letters as `#xx`, as in `/Obj#53tm`). A stream's -/// data is not searched: it ends the dictionary, at the `stream` keyword after `>>`. -fn may_be_object_stream(data: &[u8], from: usize, to: usize) -> bool { - let Some(body) = data.get(from..to.min(data.len())) else { return false }; - let mut dict = body; - let mut at = 0; - while let Some(p) = find(body, b"stream", at, body.len()) { - at = p + 6; - let after_dict = body[..p].trim_ascii_end().ends_with(b">>"); - if after_dict && matches!(body.get(at), Some(b'\r' | b'\n')) { - dict = &body[..p]; - break; +/// Longest dictionary [`may_be_object_stream`] reads before it gives up and says "maybe". +const DICTIONARY_SCAN_LIMIT: usize = 1 << 20; + +/// Where the dictionary that starts at `data[from..]` (after `N G obj`) ends, reading it as the +/// parser does: strings, hex strings, comments, names and nested dictionaries. `Some(None)`: the +/// object is not a dictionary, so it is never a stream. `None`: the end is not certain (no end +/// within the limit, a hex string with a bad digit, a `>>` inside an array); the caller must +/// then treat the object as a possible object stream. +fn dictionary_end(data: &[u8], from: usize) -> Option> { + let limit = data.len().min(from.saturating_add(DICTIONARY_SCAN_LIMIT)); + let mut i = from; + while i < limit && (is_whitespace(data[i]) || data[i] == b'%') { + if data[i] == b'%' { + while i < limit && !matches!(data[i], b'\n' | b'\r') { + i += 1; + } + } else { + i += 1; + } + } + if data.get(i..i + 2) != Some(b"<<") { + return if i < limit { Some(None) } else { None }; + } + i += 2; + let (mut dicts, mut arrays) = (1usize, 0usize); + while i < limit { + match data[i] { + b'(' => { + let mut depth = 1usize; + i += 1; + while i < limit && depth > 0 { + match data[i] { + b'\\' => i += 1, + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + i += 1; + } + if depth > 0 { + return None; + } + } + b'<' if data.get(i + 1) == Some(&b'<') => { + dicts += 1; + i += 2; + } + b'<' => { + i += 1; + while i < limit && data[i] != b'>' { + if !data[i].is_ascii_hexdigit() && !is_whitespace(data[i]) { + return None; + } + i += 1; + } + i += 1; + } + b'>' if data.get(i + 1) == Some(&b'>') => { + if arrays > 0 { + return None; + } + dicts -= 1; + i += 2; + if dicts == 0 { + return Some(Some(i)); + } + } + b'%' => { + while i < limit && !matches!(data[i], b'\n' | b'\r') { + i += 1; + } + } + b'[' => { + arrays += 1; + i += 1; + } + b']' => { + arrays = arrays.saturating_sub(1); + i += 1; + } + _ => i += 1, + } + } + None +} + +/// Whether the object whose body starts at `data[from..]` could be an object stream: its +/// dictionary holds `ObjStm`, or a `#` (a name may hide its letters as `#xx`, as in +/// `/Obj#53tm`). Only the dictionary is searched, not the data of a stream. Where the +/// dictionary ends is read by [`dictionary_end`], so text inside a string (an object header, a +/// `stream` keyword) is not taken for syntax; when that is not certain the object counts as a +/// candidate, so a doubtful object is parsed as it always was. +fn may_be_object_stream(data: &[u8], from: usize) -> bool { + match dictionary_end(data, from) { + Some(None) => false, + None => true, + Some(Some(end)) => { + let dict = &data[from..end]; + dict.contains(&b'#') || find(dict, b"ObjStm", 0, dict.len()).is_some() } } - dict.contains(&b'#') || find(dict, b"ObjStm", 0, dict.len()).is_some() } /// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position @@ -3584,6 +3668,61 @@ endobj /// cross-reference data, as in #2365), is rebuilt from its object headers. The rebuild used /// to look only for the `trailer` keyword, so /Info and /ID were lost, and a full save after /// editing one Info field dropped every other. + /// Whether parsing `body` as an object gives a stream whose /Type is /ObjStm. + fn parses_as_object_stream(file: &[u8]) -> bool { + matches!(parse_indirect(file, 0, &|_| None), Ok((_, Object::Stream(s))) if s.dict.name(b"Type") == Some(b"ObjStm")) + } + + #[test] + fn the_object_stream_pre_check_never_rejects_what_parses_as_one() { + // Two inputs from review: header-shaped text, and `>>` + `stream` + newline, inside a + // string that comes before the /Type. + for dict in [ + "<< /Note (9 0 obj\n) /Type /ObjStm /N 0 /First 0 >>", + "<< /Note (>>stream\n) /Type /ObjStm /N 0 /First 0 >>", + "<< /Note (a (nested\n9 0 obj\nstream) \\) )) /Type /ObjStm >>", + "<< /Note <3e3e> /Other << /A (>>\nstream\n) >> /Type /ObjStm >>", + ] { + let file = format!("1 0 obj\n{dict}\nstream\nxx\nendstream\nendobj\n").into_bytes(); + assert!(parses_as_object_stream(&file), "test input must be an object stream: {dict}"); + assert!(may_be_object_stream(&file, 7), "{dict}"); + } + // Ordinary streams stay out; their data is never read. + let plain = b"1 0 obj\n<< /Length 9 >>\nstream\nObjStm # \nendstream\nendobj\n"; + assert!(!may_be_object_stream(plain, 7)); + assert!(!may_be_object_stream(b"1 0 obj\n[ /ObjStm ]\nendobj\n", 7)); + // Unterminated: not certain, so a candidate. + assert!(may_be_object_stream(b"1 0 obj\n<< /Note (open ", 7)); + // Random dictionaries built from tricky pieces: whatever parses as an object stream is + // a candidate. + let pieces: [&str; 22] = [ + "/Type /ObjStm", "/Type/ObjStm", "/Type /Obj#53tm", "/T#79pe /ObjStm", "(", ")", "\\", "(>>stream\n)", "(9 0 obj\n)", "<3e3e>", "<", ">", ">>", + "<<", "[", "]", "% c\n", "% >>\n", "/N 0", " ", "\n", "", + ]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut hits = 0; + for _ in 0..200_000 { + let mut dict = String::from("<<"); + for _ in 0..(next() % 9) { + dict.push_str(pieces[(next() % pieces.len() as u64) as usize]); + dict.push(' '); + } + dict.push_str(if next() % 8 == 0 { "" } else { ">>" }); + let file = format!("1 0 obj\n{dict}\nstream\nxx\nendstream\nendobj\n").into_bytes(); + if parses_as_object_stream(&file) { + hits += 1; + assert!(may_be_object_stream(&file, 7), "rejected an object stream: {dict:?}"); + } + } + assert!(hits > 1000, "the generator must produce object streams ({hits})"); + } + #[test] fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() { let table = build( diff --git a/crates/cos/tests/it/reconstruct.rs b/crates/cos/tests/it/reconstruct.rs index 5f61867b1b..4611c0d17e 100644 --- a/crates/cos/tests/it/reconstruct.rs +++ b/crates/cos/tests/it/reconstruct.rs @@ -40,6 +40,11 @@ fn damaged() -> Vec { plain.extend_from_slice(data); plain.extend_from_slice(b"\nendstream"); add(6, &plain); + // Strings before the type that look like an object header, or like the end of the + // dictionary and the `stream` keyword, and a hex string and a comment. + add(7, &objstm("/Note (\n99 0 obj\n) /Type /ObjStm", &[(15, "<< /From 7 >>")])); + add(8, &objstm("/Note (>>stream\n) /Type /ObjStm", &[(16, "<< /From 8 >>")])); + add(9, &objstm("/Note <3e3e> /Other << /A (a (b) \\) >>\nstream\n) >> % >>\nstream\n\n/Type /ObjStm", &[(17, "<< /From 9 >>")])); out.extend_from_slice(b"trailer\n<< /Size 20 /Root 1 0 R >>\nstartxref\n99999999\n%%EOF\n"); out } @@ -48,7 +53,7 @@ fn damaged() -> Vec { fn objects_in_object_streams_are_found_however_the_type_is_written() { let doc = Document::open(Arc::new(damaged())).expect("opens"); assert!(doc.reconstructed()); - for n in [10, 11, 12, 13] { + for n in [10, 11, 12, 13, 15, 16, 17] { let o = doc.try_get(n).unwrap_or_else(|e| panic!("object {n}: {e}")); assert!(matches!(&*o, Object::Dict(_) | Object::Array(_)), "object {n} is {o:?}"); } From 308d22cbadc84e45102c23b97d95205dbe81c7b4 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Wed, 28 Oct 2026 09:56:22 +0100 Subject: [PATCH 020/562] M10: Enhance scanned file cleans scanned pages in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan & OCR ▸ Enhance scanned file (Acrobat's Enhance ▸ Scanned document) now cleans up scanned pages: background removal (the camera clean-up's level 0-100), edge shadow removal (book gutters, copier borders) and despeckle, each its own switch. The camera tool renders the page and replaces it with a picture, which would drop a scan's invisible OCR text, comments and links. Instead each scan image (pictures covering at least half the page, each at least a tenth) is decoded, cleaned and written back into its own image object at the same size and colour space: a JPEG stays a JPEG (quality 90), other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit with its decode array, and every other dictionary entry (SMask, Interpolate, ...) is kept. An image several pages share is cleaned once. What can't be cleaned (CMYK, indexed, JPEG 2000, JBIG2, CCITT, colour-key masks, broken data, oversized images) is reported, never skipped silently; pages that are no scan are listed too. The engine works as a job like Optimize (scan_clean_job, run on a worker with progress and cancel, apply_scan_clean refuses a result for an older document state) and applies one undoable step, "Enhance scanned file". pdfcraft-optimize exposes decode_samples/encode_samples for in-place pixel edits. camera_clean gains clean_samples for grey and RGB samples with separate switches and an edge-shadow pass; the camera tool's output is unchanged. page_enhance_scan drives it headlessly. Not yet an option of Recognize text. Refs #4119. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/automation/src/dispatch.rs | 29 ++ crates/automation/src/tools.rs | 12 + crates/automation/tests/it/automation.rs | 56 +++ crates/engine/src/camera_clean.rs | 235 ++++++++++-- crates/engine/src/catalog.rs | 2 +- crates/engine/src/commands.rs | 1 + crates/engine/src/edit.rs | 7 + crates/engine/src/edit/labels.rs | 2 + crates/engine/src/edit/run.rs | 2 + crates/engine/src/history.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/scan_clean.rs | 276 ++++++++++++++ crates/engine/src/scan_clean/tests.rs | 338 ++++++++++++++++++ crates/engine/src/scan_fields.rs | 23 +- crates/optimize/src/images.rs | 10 +- crates/optimize/src/lib.rs | 2 + crates/optimize/src/samples.rs | 273 ++++++++++++++ crates/ui-egui/src/app.rs | 3 + crates/ui-egui/src/app/control.rs | 1 + crates/ui-egui/src/app/frame.rs | 1 + crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 10 + crates/ui-egui/src/i18n/de.tsv | 19 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/state.rs | 2 + crates/ui-egui/src/workflows.rs | 1 + crates/ui-egui/src/workflows/scan_clean_ui.rs | 253 +++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/scan_clean.rs | 77 ++++ docs/gaps.md | 5 +- docs/roadmap.md | 3 +- docs/target-app-parity.md | 3 +- parity/acrobat-features.toml | 24 +- 34 files changed, 1613 insertions(+), 67 deletions(-) create mode 100644 crates/engine/src/scan_clean.rs create mode 100644 crates/engine/src/scan_clean/tests.rs create mode 100644 crates/optimize/src/samples.rs create mode 100644 crates/ui-egui/src/workflows/scan_clean_ui.rs create mode 100644 crates/ui-egui/tests/it/scan_clean.rs diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..91e8bf1ece 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **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) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -200,6 +201,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 87f47e8ebe..b8dfd36388 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -106,6 +106,35 @@ impl Automation { out["detected"] = json!(given.is_none()); out } + "page_enhance_scan" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => Vec::new(), + }; + let level = match a.opt_int("level")? { + None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level); + if let Some(on) = a.opt_bool("edge_shadows")? { + settings.edge_shadows = on; + } + if let Some(on) = a.opt_bool("despeckle")? { + settings.despeckle = on; + } + let id = self.doc(&a)?.id; + let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?; + let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::>(); + let skipped: Vec = + report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect(); + let mut out = summary(self.doc(&a)?); + out["pages_cleaned"] = json!(numbered(&report.pages)); + out["images"] = json!(report.images); + out["not_scanned"] = json!(numbered(&report.not_scanned)); + out["skipped"] = json!(skipped); + out + } "page_delete" => { let pages = self.pages(&a, "pages")?; self.apply(&a, Edit::DeletePages { pages })? diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b4ac8873b..0d34911154 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -225,6 +225,18 @@ pub fn tools() -> Vec { }), &["doc", "page"], )), + t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, broken data, or nothing to change). One undoable step; none when nothing changed.") + .cmd("ocr.enhance") + .with(schema( + json!({ + "doc": doc(), + "pages": pages("to clean up (default: all)"), + "level": { "type": "integer", "minimum": 0, "maximum": 100 }, + "edge_shadows": { "type": "boolean" }, + "despeckle": { "type": "boolean" }, + }), + &["doc"], + )), t("page_delete", "Delete pages", "Delete pages. Undoable until saved.") .destructive() .cmd("page.delete") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 78038d8ebd..c6616f11f3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3376,6 +3376,62 @@ fn enhance_camera_image_through_tools() { )); } +#[test] +fn enhance_scanned_file_cleans_the_scan_in_place_through_tools() { + let dir = workdir("enhance-scan"); + // A 300 × 400 grey scan: dull paper, a dark gutter at the left, text bars and a speck. + let img = image::GrayImage::from_fn(300, 400, |x, y| { + let light = 0.55 + 0.45 * (x as f32 / 60.0).min(1.0); + let ink = if ((60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x)) || (x, y) == (150, 25) { 0.12 } else { 0.78 }; + image::Luma([(ink * light * 255.0) as u8]) + }); + img.save(dir.join("scan.jpg")).unwrap(); + let mut a = auto(&dir); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.jpg"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "before.jpg" })); + + let r = ok(&mut a, "page_enhance_scan", json!({ "doc": scan, "level": 100 })); + assert_eq!(r["undo"], "Enhance scanned file"); + assert_eq!((r["pages_cleaned"].clone(), r["images"].clone(), r["not_scanned"].clone()), (json!([1]), json!(1), json!([]))); + assert_eq!(r["skipped"], json!([])); + // In place: still one picture of the same size, still a JPEG, now with white paper. + let images = ok(&mut a, "page_images", json!({ "doc": scan, "page": 1 })); + assert_eq!((images["count"].clone(), images["images"][0]["pixels"].clone()), (json!(1), json!([300, 400]))); + let saved = ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "after" })); + assert_eq!(saved["format"], "jpg"); + let (before, after) = (image::open(dir.join("before.jpg")).unwrap().to_luma8(), image::open(dir.join("after.jpg")).unwrap().to_luma8()); + for (x, y) in [(150, 40), (3, 40), (3, 380), (150, 25)] { + assert!( + before.get_pixel(x, y)[0] < 200 && after.get_pixel(x, y)[0] >= 230, + "({x}, {y}): {:?} → {:?}", + before.get_pixel(x, y), + after.get_pixel(x, y) + ); + } + assert!(after.get_pixel(150, 62)[0] <= 60, "a stroke stays dark"); + // Undo puts the scan back as it was. + ok(&mut a, "edit_undo", json!({ "doc": scan })); + ok(&mut a, "image_save", json!({ "doc": scan, "page": 1, "image": 1, "path": "undone.jpg" })); + assert_eq!(std::fs::read(dir.join("undone.jpg")).unwrap(), std::fs::read(dir.join("before.jpg")).unwrap()); + + // Switches: despeckle alone leaves everything but the speck. + let r = ok(&mut a, "page_enhance_scan", json!({ "doc": scan, "pages": [1], "level": 0, "despeckle": true, "edge_shadows": false })); + assert_eq!(r["images"], 1); + // A document of text pages has no scans: nothing changes, and it says so. + let text = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "page_enhance_scan", json!({ "doc": text })); + assert_eq!((r["images"].clone(), r["not_scanned"].clone(), r["undo"].clone()), (json!(0), json!([1, 2, 3]), Value::Null)); + // Bad arguments are refused before anything changes. + for bad in [ + json!({ "doc": scan, "level": 101 }), + json!({ "doc": scan, "pages": [9] }), + json!({ "doc": scan, "despeckle": "yes" }), + json!({ "doc": scan, "pages": [] }), + ] { + assert!(matches!(a.call("page_enhance_scan", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn enhance_camera_image_finds_the_edges_by_itself() { let dir = workdir("enhance-camera-auto"); diff --git a/crates/engine/src/camera_clean.rs b/crates/engine/src/camera_clean.rs index 9c4120be56..d1768a2a83 100644 --- a/crates/engine/src/camera_clean.rs +++ b/crates/engine/src/camera_clean.rs @@ -1,6 +1,8 @@ -//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out -//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker -//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour. +//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level) or a +//! scanned page's picture (Enhance ▸ Scanned document, [`crate::scan_clean`]): even out the +//! lighting so the paper turns white (room-light colour casts, a hand's shadow, darker edges and +//! book gutters go with it), then raise the contrast and drop isolated specks. Colour ink stays +//! colour. //! //! The paper's illumination is estimated per channel on a coarse grid of blocks, from each //! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out. @@ -8,6 +10,8 @@ /// The strongest a level can be. pub const MAX_LEVEL: u8 = 100; +/// The level from which [`clean`] also drops isolated specks. +pub const DESPECKLE_FROM: u8 = 60; /// How an enhanced camera page looks. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -66,34 +70,158 @@ pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Opti let even = clean(rgb, width, height, 50)?; let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32; let mut grey: Vec = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect(); - despeckle_grey(&mut grey, w, h); + despeckle(&mut grey, 1, w, h); Pixels::Grey(grey) } }) } -/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest). -/// Returns `None` when the buffer doesn't match the size. +/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest), from +/// [`DESPECKLE_FROM`] without isolated specks. Returns `None` when the buffer doesn't match the +/// size. pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> { + clean_samples(rgb, 3, width, height, CleanUp { level, edge_shadows: false, despeckle: level >= DESPECKLE_FROM }) +} + +/// What [`clean_samples`] does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CleanUp { + /// 0 (unchanged) … 100: even out the paper's illumination and raise the contrast, as + /// [`clean`]'s level. + pub level: u8, + /// Even out shadows along the picture's edges first (a book's gutter, a copier's dark edge): + /// lightened line by line to the paper beside them, within the outer fifth of each side. + pub edge_shadows: bool, + /// Drop isolated dark specks. + pub despeckle: bool, +} + +/// [`clean`] for samples of `n` components (1: grey, 3: RGB), with each step chosen apart (each +/// works at level 0 too). Returns `None` when `n` is neither or the buffer doesn't match the size. +pub fn clean_samples(px: &[u8], n: usize, width: u32, height: u32, how: CleanUp) -> Option> { let (w, h) = (width as usize, height as usize); - if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() { + if !(n == 1 || n == 3) || w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(n)? != px.len() { return None; } - let level = level.min(MAX_LEVEL); - if level == 0 { - return Some(rgb.to_vec()); + let level = how.level.min(MAX_LEVEL); + let mut out = px.to_vec(); + if how.edge_shadows { + even_edges(&mut out, n, w, h); } + if level > 0 { + out = even_out(&out, n, w, h, level); + } + if how.despeckle { + despeckle(&mut out, n, w, h); + } + Some(out) +} + +/// The share of the width (and of the height) at each side where [`even_edges`] works. +const EDGE_BAND: f32 = 0.2; +/// The most [`even_edges`] lightens a line by; a darker line (a scanner's black border, a +/// picture) is not taken for shadowed paper and stays as it is. +const MAX_EDGE_GAIN: f32 = 3.0; + +/// Even out shadows that run along the picture's edges, in place: a book's gutter, a copier's +/// dark border, a page that curved away from the glass. Within the outer [`EDGE_BAND`] of each +/// side, each column (then each row) gets the paper's level along it (per channel, its 90th +/// percentile, so the text across it doesn't count), and is lightened to the brightest paper of +/// that band. The block grid of [`background`] is too coarse for such a narrow shadow: its +/// blocks borrow the brighter paper next to them. +fn even_edges(px: &mut [u8], n: usize, w: usize, h: usize) { + for vertical in [true, false] { + // Columns: lines across the width, each running down the height; rows the other way. + let (lines, along) = if vertical { (w, h) } else { (h, w) }; + let band = ((lines as f32 * EDGE_BAND) as usize).min(lines / 2); + if band == 0 { + continue; + } + let at = |line: usize, i: usize| if vertical { i * w + line } else { line * w + i }; + // The paper's level of every line in the bands and a few beyond them. + let reach = band.saturating_add(3).min(lines); + let measured = |line: usize| line < reach || line >= lines.saturating_sub(reach); + let mut level: Vec> = vec![None; lines]; + let mut hist = [[0u32; 256]; 3]; + for (line, slot) in level.iter_mut().enumerate().filter(|(line, _)| measured(*line)) { + hist.iter_mut().for_each(|hh| hh.fill(0)); + for i in 0..along { + let p = at(line, i) * n; + for (c, hh) in hist.iter_mut().enumerate().take(n) { + if let Some(v) = px.get(p + c) + && let Some(count) = hh.get_mut(*v as usize) + { + *count += 1; + } + } + } + let want = (along as f32 * 0.9) as u32; + *slot = Some(std::array::from_fn(|c| { + let mut seen = 0; + let v = hist.get(c).and_then(|hh| { + hh.iter().position(|&count| { + seen += count; + seen > want + }) + }); + v.unwrap_or(255) as f32 / 255.0 + })); + } + // Smoothed: the median of five neighbouring lines (fewer at the very edge, centred all + // the same, so a gradient isn't shifted), so one odd line (a rule, a fold) doesn't stand + // out. + let smooth: Vec> = (0..lines) + .map(|line| { + level.get(line).copied().flatten()?; + let r = line.min(lines - 1 - line).min(2); + Some(std::array::from_fn(|c| { + let mut near: Vec = (line - r..=line + r).filter_map(|l| level.get(l).copied().flatten()).map(|v| v[c]).collect(); + near.sort_by(f32::total_cmp); + near.get(near.len() / 2).copied().unwrap_or(1.0) + })) + }) + .collect(); + // Each side's band, and the lines its paper is measured on (the band and a few beyond). + for (shadowed, measured) in [(0..band, 0..reach), (lines - band..lines, lines.saturating_sub(reach)..lines)] { + // The brightest paper there: what the shadow is lightened to. + let paper: [f32; 3] = + std::array::from_fn(|c| measured.clone().filter_map(|l| smooth.get(l).copied().flatten()).map(|v| v[c]).fold(0.0, f32::max)); + for line in shadowed { + let Some(own) = smooth.get(line).copied().flatten() else { continue }; + let gain: [f32; 3] = std::array::from_fn(|c| { + let g = paper[c] / own[c].max(1e-3); + if (1.0..=MAX_EDGE_GAIN).contains(&g) { g } else { 1.0 } + }); + if gain.iter().take(n).all(|&g| g == 1.0) { + continue; + } + for i in 0..along { + let p = at(line, i) * n; + for (c, g) in gain.iter().enumerate().take(n) { + if let Some(v) = px.get_mut(p + c) { + *v = (*v as f32 * g).round().clamp(0.0, 255.0) as u8; + } + } + } + } + } + } +} + +/// The lighting evened out and the contrast raised at `level` (1–100); `px` holds `w` × `h` +/// pixels of `n` components. +fn even_out(px: &[u8], n: usize, w: usize, h: usize, level: u8) -> Vec { let k = level as f32 / MAX_LEVEL as f32; - let background = background(rgb, w, h); + let background = background(px, n, w, h); // Lighting is evened out fully from half strength on; contrast keeps growing to the top. let flatten = (2.0 * k).min(1.0); let white = 1.0 - 0.12 * k; // what reaches white let black = 0.30 * k; // what reaches black - let mut out = Vec::with_capacity(rgb.len()); - for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() { + let mut out = Vec::with_capacity(px.len()); + for (i, p) in px.chunks_exact(n).enumerate() { let (x, y) = (i % w, i / w); let bg = background.at(x, y); - for (c, &v) in px.iter().enumerate() { + for (c, &v) in p.iter().enumerate() { let v = v as f32 / 255.0; let b = bg.get(c).copied().unwrap_or(1.0).max(0.05); let even = (v / b).min(1.0); @@ -102,10 +230,7 @@ pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option> out.push((stretched * 255.0).round() as u8); } } - if level >= 60 { - despeckle(&mut out, w, h); - } - Some(out) + out } /// The paper's illumination: a grid of per-channel values, read back with bilinear @@ -140,9 +265,10 @@ impl Background { } } -/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright -/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed. -fn background(rgb: &[u8], w: usize, h: usize) -> Background { +/// Estimate the paper's illumination: per block, the 90th percentile of each of the `n` channels +/// (bright paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then +/// smoothed. Channels past `n` stay white. +fn background(px: &[u8], n: usize, w: usize, h: usize) -> Background { let block = (w.min(h) / 16).clamp(8, 128); let (cols, rows) = (w.div_ceil(block), h.div_ceil(block)); let mut cells = Vec::with_capacity(cols * rows); @@ -150,20 +276,20 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background { for by in 0..rows { for bx in 0..cols { hist.iter_mut().for_each(|hh| hh.fill(0)); - let mut n = 0u32; + let mut count = 0u32; for y in by * block..((by + 1) * block).min(h) { for x in bx * block..((bx + 1) * block).min(w) { - if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) { + if let Some(p) = px.get((y * w + x) * n..(y * w + x) * n + n) { for (c, &v) in p.iter().enumerate() { if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) { *slot += 1; } } - n += 1; + count += 1; } } } - let want = (n as f32 * 0.9) as u32; + let want = (count as f32 * 0.9) as u32; cells.push(std::array::from_fn(|c| { let mut seen = 0; let v = hist.get(c).and_then(|hh| { @@ -219,19 +345,15 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background { Background { cols, rows, block: block as f32, cells } } -/// [`despeckle`] for one grey byte per pixel. -fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) { - let mut rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect(); - despeckle(&mut rgb, w, h); - for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) { - *g = p[0]; - } -} - -/// Lighten dark pixels with at most one dark neighbour (specks), in place. -fn despeckle(rgb: &mut [u8], w: usize, h: usize) { - let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000; - let dark: Vec = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect(); +/// Lighten dark pixels with at most one dark neighbour (specks), in place; `px` holds `w` × `h` +/// pixels of `n` components (1 or 3). +fn despeckle(px: &mut [u8], n: usize, w: usize, h: usize) { + let lum = |p: &[u8]| match p { + [r, g, b] => (*r as u32 * 299 + *g as u32 * 587 + *b as u32 * 114) / 1000, + [v, ..] => *v as u32, + [] => 255, + }; + let dark: Vec = px.chunks_exact(n.max(1)).map(|p| lum(p) < 128).collect(); let is_dark = |x: isize, y: isize| { x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false) }; @@ -243,7 +365,7 @@ fn despeckle(rgb: &mut [u8], w: usize, h: usize) { let neighbours = (-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count(); if neighbours <= 1 - && let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3) + && let Some(p) = px.get_mut((y as usize * w + x as usize) * n..(y as usize * w + x as usize) * n + n) { p.fill(255); } @@ -388,5 +510,40 @@ mod tests { assert_eq!(clean(&[], 0, 0, 50), None); assert_eq!(clean(&[0; 12], 2, 3, 50), None); assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3)); + let every = CleanUp { level: 100, edge_shadows: true, despeckle: true }; + assert_eq!(clean_samples(&[0; 12], 2, 2, 3, every), None, "two components"); + assert_eq!(clean_samples(&[0; 5], 1, 2, 3, every), None); + for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 2), (5, 3)] { + assert_eq!(clean_samples(&vec![100; w * h], 1, w as u32, h as u32, every).map(|v| v.len()), Some(w * h)); + } + } + + #[test] + fn edge_shadows_lighten_shadowed_paper_but_not_a_black_border_or_the_middle() { + // Grey paper (200) with a shadow darkening the left 20 columns to 120, a black scanner + // border in the right 8 columns, and a dark block in the middle. + let (w, h) = (100usize, 60usize); + let mut grey = vec![200u8; w * h]; + for y in 0..h { + for x in 0..w { + let v = if x >= w - 8 { + 5 + } else if (40..60).contains(&x) && (20..40).contains(&y) { + 30 + } else if x < 20 { + 120 + (80 * x / 20) as u8 + } else { + 200 + }; + grey[y * w + x] = v; + } + } + let out = clean_samples(&grey, 1, w as u32, h as u32, CleanUp { level: 0, edge_shadows: true, despeckle: false }).unwrap(); + for x in [0, 5, 19] { + assert!(out[30 * w + x].abs_diff(200) <= 3, "column {x}: {} → {}", grey[30 * w + x], out[30 * w + x]); + } + assert_eq!(out[30 * w + w - 3], 5, "a black border is no shadow"); + assert_eq!(out[30 * w + 50], 30, "the middle is untouched"); + assert_eq!(out[30 * w + 30], 200); } } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index e479232bbb..47394a7bb5 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -265,7 +265,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ items: &[ item("In this file", "scan-text", "ocr.recognize", Ready), item("In multiple files", "files", "ocr.recognize_batch", Ready), - item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")), + item("Enhance scanned file", "sparkles", "ocr.enhance", Ready), item("Enhance camera image", "camera", "ocr.enhance_camera", Ready), item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")), ], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1f25c23c98..8c83996c6d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -352,6 +352,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"), c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"), c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"), + c("ocr.enhance", "Enhance scanned file…", None, None, Modification, "sparkles"), c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"), c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"), c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"), diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index c31984ca19..655a1ce558 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -375,6 +375,13 @@ pub enum Edit { page: usize, words: Vec, }, + /// Scan & OCR ▸ Enhance ▸ Scanned document: put the scan images + /// [`ScanCleanJob::run`](crate::scan_clean::ScanCleanJob::run) cleaned in place of the old + /// ones (each object must still be an image). Built by + /// [`Session::apply_scan_clean`](crate::Session::apply_scan_clean). + EnhanceScan { + images: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Stream)>, + }, /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on /// `page`, in its own font when it can show it, else in Helvetica. EditTextLine { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index ea25898068..ff86f21a08 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -98,6 +98,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Set alternate text", "Mark figure as decorative", "Recognize text", + "Enhance scanned file", "Run JavaScript", "Run form script", "Save as {level}", @@ -239,6 +240,7 @@ impl Edit { Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), Edit::XfaEvent { .. } => Label::of("Run form script"), // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 49f19fa637..e2a99665aa 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -121,6 +121,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MarkDecorative { .. } | Edit::MarkTagArtifact { .. } | Edit::AddOcrText { .. } + | Edit::EnhanceScan { .. } | Edit::SetDocumentScript { .. } | Edit::SetFieldScript { .. } | Edit::RemoveStandardId { .. } @@ -491,6 +492,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; } + Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?, Edit::EditPageImage { page, index, change } => { let img = pdfcraft_edit::page_images(doc, *page)? .into_iter() diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index fafa65b4c2..0fe874e711 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -77,6 +77,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } | Edit::EditPageImage { .. } + | Edit::EnhanceScan { .. } | Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 889b839368..9e2eb54389 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -43,6 +43,7 @@ mod pictures; pub mod print_raster; pub mod printcheck; mod protection; +pub mod scan_clean; mod scan_colours; mod scan_fields; mod session; diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs new file mode 100644 index 0000000000..bfc8188b29 --- /dev/null +++ b/crates/engine/src/scan_clean.rs @@ -0,0 +1,276 @@ +//! Scan & OCR ▸ Enhance ▸ Scanned document: clean up the pictures scanned pages are made of. +//! +//! Unlike Enhance ▸ Camera image, the page is not rendered and replaced: each scan image is +//! decoded, cleaned ([`crate::camera_clean::clean_samples`]) and written back **into the same +//! image object**, at the same size and in the same colour space. So everything else on the +//! page stays as it was: an invisible OCR text layer, comments, links, form fields, other +//! content, and the image's own soft mask, `/Interpolate` and other entries. A JPEG stays a JPEG +//! (at quality 90), any other grey or RGB image is stored losslessly (Flate), and a black and +//! white (1-bit) image stays 1-bit. An image several pages draw is cleaned once. +//! +//! A page is a scan when its pictures cover at least half of it (as for Recognize text); the +//! pictures each covering at least a tenth of the page are its scan. What is not cleaned is +//! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft +//! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping +//! decode array) or whose data is broken. +//! +//! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document, +//! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`] +//! makes the result one undoable step, "Enhance scanned file" (the name the Scan & OCR tool has). + +use pdfcraft_cos::{ObjRef, Object, Stream}; + +use crate::camera_clean::{CleanUp, MAX_LEVEL, clean_samples}; +use crate::edit::check_permission; +use crate::{DocId, Edit, EditError, Session, guard, optimize}; + +pub use crate::camera_clean::DESPECKLE_FROM; + +/// The JPEG quality a cleaned JPEG scan is written back at (as Enhance camera image). +const QUALITY: u8 = 90; +/// On a scanned page, an image covering at least this share of the page is part of the scan; +/// smaller ones (a logo or a signature pasted on) are left alone. +const PART_OF_SCAN: f64 = 0.1; + +/// How scanned pages are cleaned up. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScanCleanSettings { + /// Background removal, 0 (off) … 100 (strongest), as Enhance camera image's clean-up level: + /// the paper's illumination is evened out (fully from 50), so a grey, yellowed or unevenly lit + /// background turns white; then the contrast is raised so faint strokes darken. Colour ink + /// stays colour. + pub level: u8, + /// Edge shadow removal: shadows along the scan's edges (a book's gutter, a copier's dark + /// border) are lightened to the paper next to them, line by line, before the level applies. + pub edge_shadows: bool, + /// Despeckle: isolated dark specks (dust, scanner noise), dark pixels with at most one dark + /// neighbour, turn white. The only change a black and white (1-bit) scan takes. + pub despeckle: bool, +} + +impl ScanCleanSettings { + /// The level `level` (capped at 100) with edge shadow removal (unless the level is 0), and + /// despeckling from [`DESPECKLE_FROM`] as the camera tool does. + pub fn at(level: u8) -> Self { + let level = level.min(MAX_LEVEL); + Self { level, edge_shadows: level > 0, despeckle: level >= DESPECKLE_FROM } + } + + /// Whether these settings change anything at all. + pub fn changes_something(self) -> bool { + self.level > 0 || self.edge_shadows || self.despeckle + } +} + +impl Default for ScanCleanSettings { + fn default() -> Self { + Self::at(50) + } +} + +/// What a clean-up did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScanCleanReport { + /// Pages (0-based) whose scan was cleaned, in order. + pub pages: Vec, + /// Images rewritten. One drawn on several pages counts once. + pub images: usize, + /// Pages left alone because no picture covers most of them. + pub not_scanned: Vec, + /// Scan images left as they were, and why. + pub skipped: Vec, +} + +/// A scan image that was not cleaned. +#[derive(Clone, Debug, PartialEq)] +pub struct SkippedImage { + /// The pages (0-based) that draw it, among those asked for. + pub pages: Vec, + /// Its resource name on the first of them. + pub name: String, + /// Why, in a sentence that follows "skipped because". + pub reason: String, +} + +/// One image to clean: its object, its name and the pages that draw it. +struct Target { + object: ObjRef, + name: String, + pages: Vec, +} + +/// What cleaning scanned pages needs, independent of the session. +pub struct ScanCleanJob { + cos: pdfcraft_cos::Document, + targets: Vec, + settings: ScanCleanSettings, + /// The document's edit generation it was taken at: a result is for that state only. + generation: u64, + report: ScanCleanReport, +} + +/// The cleaned images, ready for [`Session::apply_scan_clean`], and the report. +pub struct ScanCleaned { + images: Vec<(ObjRef, Stream)>, + generation: u64, + pub report: ScanCleanReport, +} + +impl Session { + /// Capture what cleaning up the scans on `pages` (0-based; empty = all) of document `id` + /// needs. Errors when the document can't be changed (read-only, or its permissions forbid + /// changes) or a page doesn't exist. + pub fn scan_clean_job(&self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if let Some(why) = &doc.read_only_reason { + return Err(EditError::ReadOnly(why.clone())); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(String::new()))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&Edit::EnhanceScan { images: Vec::new() }, &p)?; + } + let all = doc.info.pages.len(); + let mut wanted: Vec = if pages.is_empty() { (0..all).collect() } else { pages.to_vec() }; + if let Some(p) = wanted.iter().find(|p| **p >= all) { + return Err(EditError::Invalid(format!("there is no page {}: the document has {all}", p.saturating_add(1)))); + } + wanted.sort_unstable(); + wanted.dedup(); + let mut report = ScanCleanReport::default(); + let mut targets: Vec = Vec::new(); + for page in wanted { + let Some(info) = doc.info.pages.get(page) else { continue }; + let Some(images) = crate::scan_fields::scan_images(doc, page, info) else { + report.not_scanned.push(page); + continue; + }; + for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) { + let Some(object) = image.object else { + report.skipped.push(SkippedImage { + pages: vec![page], + name: image.name, + reason: "it is stored inside the page's resources rather than as an object of its own".into(), + }); + continue; + }; + match targets.iter_mut().find(|t| t.object == object) { + Some(t) if !t.pages.contains(&page) => t.pages.push(page), + Some(_) => {} + None => targets.push(Target { object, name: image.name, pages: vec![page] }), + } + } + } + Ok(ScanCleanJob { cos: editor.cos.clone(), targets, settings, generation: doc.edit_generation(), report }) + } + + /// Write the images [`ScanCleanJob::run`] cleaned into document `id` as one undoable step, + /// "Enhance scanned file" (none when nothing was cleaned), and return the report. Refused + /// when the document changed since the job was taken. + pub fn apply_scan_clean(&mut self, id: DocId, cleaned: ScanCleaned) -> Result { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.edit_generation() != cleaned.generation { + return Err(EditError::Invalid("the document changed while its scans were being cleaned up; nothing was changed, try again".into())); + } + if !cleaned.images.is_empty() { + self.apply(id, Edit::EnhanceScan { images: cleaned.images })?; + } + Ok(cleaned.report) + } + + /// Enhance ▸ Scanned document in one call: [`Self::scan_clean_job`], run, then + /// [`Self::apply_scan_clean`] (the CLI and agents use this). + pub fn enhance_scanned_pages(&mut self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result { + let job = self.scan_clean_job(id, pages, settings)?; + let cleaned = job.run(|_, _| true)?; + self.apply_scan_clean(id, cleaned) + } +} + +impl ScanCleanJob { + /// The images to clean (one drawn on several pages counts once). + pub fn total(&self) -> usize { + self.targets.len() + } + + /// Clean every scan image. `progress(done, total)` is called before each image; returning + /// `false` stops with [`EditError::Cancelled`]. A panic is returned as an error, so a worker + /// thread always reports back. + pub fn run(self, mut progress: impl FnMut(usize, usize) -> bool) -> Result { + guard(move || self.work(&mut progress)).map_err(|m| EditError::Invalid(format!("cleaning up the scans failed unexpectedly ({m})")))? + } + + fn work(self, progress: &mut dyn FnMut(usize, usize) -> bool) -> Result { + let ScanCleanJob { cos, targets, settings, generation, mut report } = self; + let total = targets.len(); + let mut images = Vec::new(); + let mut changed_pages: Vec = Vec::new(); + for (done, target) in targets.into_iter().enumerate() { + if !progress(done, total) { + return Err(EditError::Cancelled); + } + let mut skip = |reason: String| report.skipped.push(SkippedImage { pages: target.pages.clone(), name: target.name.clone(), reason }); + match clean_image(&cos, target.object, settings) { + Ok(Some(stream)) => { + images.push((target.object, stream)); + changed_pages.extend(target.pages.iter().copied()); + } + Ok(None) => skip("it is already clean: nothing changes at these settings".into()), + Err(reason) => skip(reason), + } + } + changed_pages.sort_unstable(); + changed_pages.dedup(); + report.images = images.len(); + report.pages = changed_pages; + report.skipped.sort_by_key(|s| s.pages.first().copied()); + Ok(ScanCleaned { images, generation, report }) + } +} + +/// Image `object` cleaned with `settings`: the new stream, `None` when nothing changes, or why it +/// can't be cleaned. +fn clean_image(cos: &pdfcraft_cos::Document, object: ObjRef, settings: ScanCleanSettings) -> Result, String> { + let obj = cos.get(object); + let Object::Stream(s) = &*obj else { return Err("it is not an image".into()) }; + if s.dict.name(b"Subtype") != Some(b"Image") { + return Err("it is not an image".into()); + } + // A colour-key mask names sample values to leave unpainted: cleaned samples would match others. + if matches!(s.dict.get(b"Mask").map(|m| cos.resolve(m)).as_deref(), Some(Object::Array(_))) { + return Err("it has a colour-key mask, which cleaning would change".into()); + } + let samples = optimize::decode_samples(cos, s).map_err(|e| e.to_string())?; + let cleaned = if samples.bilevel { + if !settings.despeckle { + return Err("it is black and white, which only Despeckle changes, and Despeckle is off".into()); + } + clean_samples(&samples.data, 1, samples.width, samples.height, CleanUp { level: 0, edge_shadows: false, despeckle: true }) + } else { + if !settings.changes_something() { + return Ok(None); + } + let how = CleanUp { level: settings.level, edge_shadows: settings.edge_shadows, despeckle: settings.despeckle }; + clean_samples(&samples.data, samples.components, samples.width, samples.height, how) + } + .ok_or("its samples don't match its size")?; + if cleaned == samples.data { + return Ok(None); + } + let cleaned = optimize::Samples { data: cleaned, ..samples }; + optimize::encode_samples(s, &cleaned, QUALITY).map(Some).ok_or_else(|| "it could not be written back".into()) +} + +/// [`Edit::EnhanceScan`]: put the cleaned images in place. Each object must still be an image. +pub(crate) fn write_back(doc: &mut pdfcraft_cos::Document, images: &[(ObjRef, Stream)]) -> Result<(), EditError> { + for (object, stream) in images { + let is_image = matches!(&*doc.get(*object), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")); + if !is_image { + return Err(EditError::Invalid(format!("object {} is no longer an image; the scan was not changed", object.num))); + } + doc.set(*object, Object::Stream(stream.clone())); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs new file mode 100644 index 0000000000..561ba9e2e4 --- /dev/null +++ b/crates/engine/src/scan_clean/tests.rs @@ -0,0 +1,338 @@ +use std::sync::Arc; + +use pdfcraft_testkit::Pdf; + +use super::*; + +const W: usize = 300; +const H: usize = 400; +/// Isolated specks on the paper, away from the text rows. +const SPECKS: [(usize, usize); 3] = [(150, 25), (200, 85), (120, 125)]; + +/// The light falling on the paper at `x`: full in the middle, darker towards a shadowed left +/// edge (as a book's gutter) and a little at the right edge. +fn light(x: usize) -> f32 { + let left = (x as f32 / 60.0).min(1.0); + let right = ((W - 1 - x) as f32 / 30.0).min(1.0); + (0.55 + 0.45 * left) * (0.8 + 0.2 * right) +} + +fn is_stroke(x: usize, y: usize) -> bool { + (60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x) +} + +/// A synthetic scan, `n` components (1 grey, 3 RGB): greyish yellowed paper, a shadowed gutter, +/// dark text strokes and a few specks. +fn scan_pixels(n: usize) -> Vec { + let mut out = Vec::with_capacity(W * H * n); + for y in 0..H { + for x in 0..W { + let ink = if is_stroke(x, y) || SPECKS.contains(&(x, y)) { 0.12 } else { 0.8 }; + let v = ink * light(x); + let px = [v, v, v * 0.85]; + for c in px.iter().take(n) { + out.push((c * 255.0).round().clamp(0.0, 255.0) as u8); + } + } + } + out +} + +fn jpeg(px: &[u8], n: usize) -> Vec { + let mut out = Vec::new(); + let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 }; + image::ImageEncoder::write_image(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95), px, W as u32, H as u32, ty).unwrap(); + out +} + +/// Three pages: 1 a scan (the image `image_dict`/`data` as object 4) with an invisible text +/// layer and a comment, 2 a page of real text, 3 the same scan image drawn again. +fn scan_pdf(image_dict: &str, data: &[u8]) -> Vec { + let resources = "/Resources << /XObject << /Im0 4 0 R >> /Font << /F1 5 0 R >> >>"; + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 8 0 R 9 0 R] /Count 3 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 6 0 R /Annots [7 0 R] >>")) + .stream(&format!("/Type /XObject /Subtype /Image {image_dict}"), data) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q BT 3 Tr /F1 12 Tf 20 380 Td (Invisible OCR words) Tj ET")) + .obj("<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Contents (A comment) >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 10 0 R >>")) + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 11 0 R >>")) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q")) + .build() +} + +fn grey_scan() -> Vec { + scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true"), &scan_pixels(1)) +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, Arc::new(bytes), None).unwrap(); + (s, id) +} + +fn object(s: &Session, id: DocId, num: u32) -> Arc { + s.get(id).unwrap().editor.as_ref().unwrap().cos.get(ObjRef::new(num, 0)) +} + +/// The samples of the image page 1 draws, now. +fn samples(s: &Session, id: DocId) -> optimize::Samples { + let doc = s.get(id).unwrap(); + let object = doc.page_images(0)[0].object.unwrap(); + let cos = &doc.editor.as_ref().unwrap().cos; + let Object::Stream(img) = &*cos.get(object) else { panic!("the image") }; + optimize::decode_samples(cos, img).unwrap() +} + +fn grey_at(px: &optimize::Samples, x: usize, y: usize) -> u8 { + px.data[(y * W + x) * px.components] +} + +#[test] +fn a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place() { + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + let kept: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect(); + let text_before = s.get(id).unwrap().text_lines(0); + let original = s.get(id).unwrap().bytes.clone(); + + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.pages, vec![0, 2], "the shared image counts for both its pages"); + assert_eq!(report.images, 1); + assert_eq!(report.not_scanned, vec![1], "the text page is no scan"); + assert!(report.skipped.is_empty(), "{:?}", report.skipped); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file")); + + let after = samples(&s, id); + assert_eq!((after.width, after.height, after.components, after.jpeg), (W as u32, H as u32, 1, false)); + // Paper, also in the shadowed gutter and at the right edge, turns (nearly) white. + for (x, y) in [(150, 40), (150, 380), (3, 40), (3, 380), (30, 200), (W - 2, 40)] { + assert!(grey_at(&before, x, y) < 210, "the fixture's paper is grey at ({x}, {y})"); + assert!(grey_at(&after, x, y) >= 235, "paper at ({x}, {y}): {} → {}", grey_at(&before, x, y), grey_at(&after, x, y)); + } + // The edge is evened out: the gutter is as white as the middle. + assert!(grey_at(&after, 150, 40).abs_diff(grey_at(&after, 3, 40)) <= 12); + // Strokes stay dark (darker, with the contrast), also in the gutter's light. + for (x, y) in [(150, 62), (61, 102), (238, 342)] { + assert!(grey_at(&after, x, y) <= 40, "stroke at ({x}, {y}): {}", grey_at(&after, x, y)); + } + for (x, y) in SPECKS { + assert!(grey_at(&before, x, y) < 60); + assert!(grey_at(&after, x, y) >= 200, "speck at ({x}, {y}): {}", grey_at(&after, x, y)); + } + // Everything but the image is as it was: the page, its invisible text layer, the comment, the + // font, the other pages. The image keeps its entries. + let now: Vec> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect(); + assert_eq!(kept, now); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + let text_after = s.get(id).unwrap().text_lines(0); + assert!(text_after.iter().any(|l| l.text.contains("Invisible OCR words")), "{text_after:?}"); + assert_eq!(text_before.len(), text_after.len()); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.get(b"Interpolate"), Some(&Object::Bool(true))); + assert_eq!(img.dict.name(b"ColorSpace"), Some(&b"DeviceGray"[..])); + + // Undo brings the original image, and the original file, back. + s.undo(id).unwrap(); + assert_eq!(samples(&s, id), before); + assert_eq!(s.get(id).unwrap().bytes, original); + assert_eq!(s.get(id).unwrap().can_undo(), None); +} + +#[test] +fn a_jpeg_scan_stays_a_jpeg_in_colour() { + let px = scan_pixels(3); + let (mut s, id) = open(scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), &jpeg(&px, 3))); + let report = s.enhance_scanned_pages(id, &[0], ScanCleanSettings::at(80)).unwrap(); + // Only page 1 was asked for; the image it shares with page 3 is changed for both. + assert_eq!((report.pages.as_slice(), report.images), (&[0][..], 1)); + let after = samples(&s, id); + assert!(after.jpeg && after.components == 3); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..])); + // Yellowed paper becomes white; the strokes stay dark. + let at = |x: usize, y: usize| after.data[(y * W + x) * 3..][..3].to_vec(); + for (x, y) in [(150, 40), (5, 300)] { + assert!(at(x, y).iter().all(|&v| v >= 230), "paper at ({x}, {y}): {:?}", at(x, y)); + } + assert!(at(150, 63).iter().all(|&v| v <= 60), "stroke: {:?}", at(150, 63)); + // The file still renders. + let doc = s.get(id).unwrap(); + let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest { + page: 0, + scale: 1.0, + ..Default::default() + }); + assert!(shot.error.is_none(), "{:?}", shot.error); +} + +#[test] +fn despeckle_is_its_own_switch() { + let speck = |settings: ScanCleanSettings| { + let (mut s, id) = open(grey_scan()); + s.enhance_scanned_pages(id, &[0], settings).unwrap(); + let after = samples(&s, id); + SPECKS.iter().map(|&(x, y)| grey_at(&after, x, y)).max().unwrap_or(0) + }; + assert!(speck(ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(80) }) < 100, "kept without despeckle"); + assert!(speck(ScanCleanSettings::at(80)) >= 200); + // Despeckle alone, at level 0, changes nothing but the specks. + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: true }).unwrap(); + let after = samples(&s, id); + let changed: Vec = before.data.iter().zip(&after.data).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect(); + let mut specks: Vec = SPECKS.iter().map(|&(x, y)| y * W + x).collect(); + specks.sort_unstable(); + assert_eq!(changed, specks); + // Nothing to do: no undo step, and the report says why. + let (mut s, id) = open(grey_scan()); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }).unwrap(); + assert_eq!(report.images, 0); + assert!(report.pages.is_empty()); + assert_eq!(report.skipped.len(), 1); + assert!(report.skipped[0].reason.contains("already clean"), "{:?}", report.skipped); + assert_eq!(s.get(id).unwrap().can_undo(), None); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn a_black_and_white_scan_stays_one_bit_and_loses_its_specks() { + // 64 × 64, one bit per pixel, 1 = white: a black bar and two specks. + let (w, h) = (64usize, 64usize); + let mut bits = vec![0xFFu8; w / 8 * h]; + let mut black = |x: usize, y: usize| bits[y * w / 8 + x / 8] &= !(0x80 >> (x % 8)); + for y in 20..30 { + for x in 10..50 { + black(x, y); + } + } + black(5, 5); + black(58, 58); + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 64 64] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>") + .stream("/Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 1", &bits) + .stream("", "q 64 0 0 64 0 0 cm /Im0 Do Q") + .build(); + let (mut s, id) = open(bytes); + // Only Despeckle changes a black and white scan: without it the image is reported. + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(100) }).unwrap(); + assert_eq!(report.images, 0); + assert!(report.skipped.first().is_some_and(|k| k.reason.contains("black and white")), "{:?}", report.skipped); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.images, 1); + let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") }; + assert_eq!(img.dict.int(b"BitsPerComponent"), Some(1)); + let px = samples(&s, id); + assert!(px.bilevel); + let at = |x: usize, y: usize| px.data[y * w + x]; + assert_eq!((at(5, 5), at(58, 58)), (255, 255), "specks gone"); + assert_eq!((at(10, 20), at(30, 25), at(49, 29)), (0, 0, 0), "the bar stays"); + assert_eq!(px.data.iter().filter(|&&v| v == 0).count(), 40 * 10); +} + +#[test] +fn hostile_images_are_reported_not_a_panic() { + // Each page draws one image over the whole page. + let images: [(&str, Vec); 6] = [ + ("/Width 100000 /Height 100000 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![0; 64]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode", vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 3, 9]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![200; 1000]), + ("/Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![128]), + ("/Width 8 /Height 8 /ColorSpace /DeviceCMYK /BitsPerComponent 8", vec![0; 256]), + ("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Mask [250 255]", vec![200; 300 * 400]), + ]; + let n = images.len(); + let kids: String = (0..n).map(|i| format!("{} 0 R ", 3 + 3 * i)).collect(); + let mut pdf = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>").obj(format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>")); + for (i, (dict, data)) in images.iter().enumerate() { + let (img, contents) = (4 + 3 * i, 5 + 3 * i); + pdf = pdf + .obj(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 {img} 0 R >> >> /Contents {contents} 0 R >>" + )) + .stream(&format!("/Type /XObject /Subtype /Image {dict}"), data) + .stream("", "q 300 0 0 400 0 0 cm /Im0 Do Q"); + } + let (mut s, id) = open(pdf.build()); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!(report.images, 0); + assert_eq!(report.not_scanned, vec![3], "a 1 × 1 image is no scan"); + let why: Vec<(usize, &str)> = report.skipped.iter().map(|k| (k.pages[0], k.reason.as_str())).collect(); + assert_eq!(why.len(), 5, "{why:?}"); + assert!(why[0].0 == 0 && why[0].1.contains("80 million"), "{why:?}"); + assert!(why[1].0 == 1 && why[1].1.contains("could not be decoded"), "{why:?}"); + assert!(why[2].0 == 2 && why[2].1.contains("shorter"), "{why:?}"); + assert!(why[3].0 == 4 && why[3].1.contains("colour space"), "{why:?}"); + assert!(why[4].0 == 5 && why[4].1.contains("colour-key mask"), "{why:?}"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + // Pages that don't exist are an error, not a panic. + assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_)))); +} + +#[test] +fn protected_documents_keep_their_encryption_and_restrictions() { + let protect = |user: &str, permissions: i32| { + let mut doc = pdfcraft_cos::Document::open(Arc::new(grey_scan())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: user, + owner_password: "owner", + permissions, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) + }; + // Opened with the password: cleaned, saved still encrypted, and the clean scan reads back. + let mut s = Session::new(); + let id = s.open("x.pdf", None, protect("pw", -1), Some("pw")).unwrap(); + assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap().images, 1); + let saved = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected"); + let id2 = s.open("y.pdf", None, saved, Some("pw")).unwrap(); + assert!(grey_at(&samples(&s, id2), 3, 40) >= 235); + // Changes withheld: refused before any work. + let id = s.open("r.pdf", None, protect("", 0b0100), None).unwrap(); + assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::default()).unwrap_err(), EditError::NotPermitted("changes to the document")); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn a_result_for_an_older_state_is_refused() { + let (mut s, id) = open(grey_scan()); + let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap(); + assert_eq!(job.total(), 1); + let cleaned = job.run(|_, _| true).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + assert!(matches!(s.apply_scan_clean(id, cleaned), Err(EditError::Invalid(_)))); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Rotate page")); + // Cancelled from the progress callback. + let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap(); + assert!(matches!(job.run(|_, _| false), Err(EditError::Cancelled))); +} + +#[test] +fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() { + let gutter = |edge_shadows: bool| { + let (mut s, id) = open(grey_scan()); + s.enhance_scanned_pages(id, &[0], ScanCleanSettings { edge_shadows, ..ScanCleanSettings::at(100) }).unwrap(); + let after = samples(&s, id); + [(3, 40), (3, 380), (10, 200)].map(|(x, y)| grey_at(&after, x, y)) + }; + assert!(gutter(false).iter().any(|&v| v < 200), "{:?}", gutter(false)); + assert!(gutter(true).iter().all(|&v| v >= 235), "{:?}", gutter(true)); + // On its own, at level 0, it lightens the gutter's paper to the paper beside it. + let (mut s, id) = open(grey_scan()); + let before = samples(&s, id); + s.enhance_scanned_pages(id, &[0], ScanCleanSettings { level: 0, edge_shadows: true, despeckle: false }).unwrap(); + let after = samples(&s, id); + assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40)); + assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone"); +} diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs index 8287743fc5..769beaefca 100644 --- a/crates/engine/src/scan_fields.rs +++ b/crates/engine/src/scan_fields.rs @@ -25,24 +25,33 @@ const MIN_WORD_HEIGHT: f64 = 4.0; /// Whether `page` is a picture of a page: images cover most of it. pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { + scan_images(doc, page, info).is_some() +} + +/// The raster images (larger than one pixel) `page` draws, each with the share of the page (its +/// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise. +/// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]). +pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> { let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); let page_area = (cx1 - cx0) * (cy1 - cy0); if !page_area.is_finite() || page_area <= 0.0 { - return false; + return None; } - let covered: f64 = doc + let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc .page_images(page) - .iter() + .into_iter() .filter(|i| !i.is_form && i.width > 1 && i.height > 1) .map(|i| { let r = i.visible_rect.unwrap_or(i.rect); let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); - w * h + let share = w * h / page_area; + (i, share) }) - .filter(|a| a.is_finite()) - .sum(); - covered >= COVERED * page_area + .filter(|(_, share)| share.is_finite()) + .collect(); + let covered: f64 = images.iter().map(|(_, share)| share).sum(); + (covered >= COVERED).then_some(images) } /// The page as displayed, in points with y up: where detection on a scan works. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b9761b85fc..f19d7d8fc7 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -8,7 +8,7 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). -const MAX_PIXELS: u64 = 80_000_000; +pub(crate) const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { @@ -203,7 +203,7 @@ pub(crate) fn components(doc: &Document, cs: &Object) -> Option { } } -fn is_dct(s: &Stream) -> Option { +pub(crate) fn is_dct(s: &Stream) -> Option { match s.dict.get(b"Filter") { None => Some(false), Some(Object::Name(n)) => match n.as_slice() { @@ -288,7 +288,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { /// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where /// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters /// or the JPEG's segments can't be read. -fn ycc_encoded(doc: &Document, s: &Stream) -> Option { +pub(crate) fn ycc_encoded(doc: &Document, s: &Stream) -> Option { let entry = color_transform(doc, s)?; match adobe_transform(&s.raw)? { Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1), @@ -412,7 +412,7 @@ pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> }) } -fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { +pub(crate) fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 }; @@ -435,7 +435,7 @@ pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes /// nothing. Scanners write it on page images (#490). -fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { +pub(crate) fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { let decode = doc.resolve(decode); let Some(a) = decode.as_array() else { return false }; a.len() == 2 * n && a.chunks(2).all(|p| matches!(p, [lo, hi] if doc.resolve(lo).as_f64() == Some(0.0) && doc.resolve(hi).as_f64() == Some(1.0))) diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 9f9fcdf2be..cb6b537115 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -27,6 +27,7 @@ mod audit; pub mod fragments; mod images; mod links; +mod samples; mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; @@ -35,6 +36,7 @@ use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, ser use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; +pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; #[derive(Debug, thiserror::Error)] pub enum OptimizeError { diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs new file mode 100644 index 0000000000..7f402b51b8 --- /dev/null +++ b/crates/optimize/src/samples.rs @@ -0,0 +1,273 @@ +//! An image's samples read out and written back at the same size, for tools that change the +//! pixels of an image in place (Scan & OCR ▸ Enhance ▸ Scanned document). The image dictionary +//! stays as it was apart from the encoding: its size, colour space, masks, `/Interpolate`, +//! `/Intent`, `/Metadata` and every other entry are kept. + +use pdfcraft_cos::{Document, Object, Stream}; + +use crate::images::{MAX_PIXELS, components, is_dct, is_identity_decode, jpeg, pixels, ycc_encoded}; + +/// An image's samples, one byte each, row-major. +#[derive(Clone, Debug, PartialEq)] +pub struct Samples { + pub width: u32, + pub height: u32, + /// 1 (grey) or 3 (RGB). + pub components: usize, + /// The samples. A bi-level image's are 0 (black) or 255 (white) as drawn, whatever its + /// decode array. + pub data: Vec, + /// Stored as a JPEG (written back as one); otherwise written back losslessly (Flate). + pub jpeg: bool, + /// One bit per pixel (written back as one). + pub bilevel: bool, + /// A bi-level image whose 1 bits are black (decode array `[1 0]`): written back the same way, + /// so its decode array stays true. + pub inverted: bool, +} + +/// Why an image's samples were not read. Each says what the image is, so a report can name it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum NotDecoded { + #[error("it is a stencil mask, not a picture")] + StencilMask, + #[error("its colour space is not grey or RGB (CMYK, indexed and special colours are not supported yet)")] + ColourSpace, + #[error("it has {0} bits per sample (8, and 1 for black and white, are supported)")] + BitDepth(i64), + #[error("its decode array remaps the samples")] + Decode, + #[error("it is compressed in a format PdfCraft cannot read back yet (JPEG 2000, JBIG2 or CCITT fax)")] + Filter, + #[error("its JPEG colour transform is ambiguous, so readers may disagree about its colours")] + Ambiguous, + #[error("it is larger than 80 million pixels")] + TooLarge, + #[error("its data could not be decoded, or is shorter than its size says")] + Corrupt, +} + +/// Read the samples of image stream `s` (a dictionary with `/Subtype /Image`). +pub fn decode_samples(doc: &Document, s: &Stream) -> Result { + let d = &s.dict; + if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) { + return Err(NotDecoded::StencilMask); + } + let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?; + let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0); + let bilevel = match bpc { + 8 => false, + 1 if n == 1 => true, + other => return Err(NotDecoded::BitDepth(other)), + }; + let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; + let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if width == 0 || height == 0 { + return Err(NotDecoded::Corrupt); + } + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(NotDecoded::TooLarge); + } + if bilevel { + if jpeg { + // A JPEG has 8 bits per sample: the dictionary contradicts the data. + return Err(NotDecoded::Corrupt); + } + let inverted = match d.get(b"Decode") { + None => false, + Some(a) if is_identity_decode(doc, a, 1) => false, + Some(a) if is_inverted_decode(doc, a) => true, + Some(_) => return Err(NotDecoded::Decode), + }; + let data = unpack_bits(s, width, height, inverted).ok_or(NotDecoded::Corrupt)?; + return Ok(Samples { width, height, components: 1, data, jpeg: false, bilevel: true, inverted }); + } + if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) { + return Err(NotDecoded::Decode); + } + if jpeg && n == 3 && ycc_encoded(doc, s).is_none() { + return Err(NotDecoded::Ambiguous); + } + let data = pixels(doc, s, n).ok_or(NotDecoded::Corrupt)?; + Ok(Samples { width, height, components: n, data, jpeg, bilevel: false, inverted: false }) +} + +/// `[1 0]`: a bi-level image whose 1 bits are black. +fn is_inverted_decode(doc: &Document, decode: &Object) -> bool { + let decode = doc.resolve(decode); + matches!(decode.as_array().map(Vec::as_slice), Some([a, b]) if doc.resolve(a).as_f64() == Some(1.0) && doc.resolve(b).as_f64() == Some(0.0)) +} + +/// The bytes a `w` × `h` bi-level image takes: each row padded to whole bytes. +fn packed_len(w: u32, h: u32) -> Option { + usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?) +} + +/// A bi-level image's pixels, 0 (black) or 255 (white) as drawn. +fn unpack_bits(s: &Stream, w: u32, h: u32, inverted: bool) -> Option> { + let stride = usize::try_from(w).ok()?.div_ceil(8); + let need = packed_len(w, h)?; + // Some writers pad the data; anything far longer than the image needs is not this image. + let bits = s.decoded_within(need.saturating_add(need / 2).saturating_add(4096)).ok()?; + let bits = bits.get(..need)?; + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let mut out = Vec::with_capacity(w.checked_mul(h)?); + for row in bits.chunks(stride).take(h) { + for x in 0..w { + let byte = row.get(x / 8).copied().unwrap_or(0); + let one = byte & (0x80 >> (x % 8)) != 0; + // Without a decode array 0 is black and 1 white; [1 0] swaps them. + out.push(if one != inverted { 255 } else { 0 }); + } + } + Some(out) +} + +/// The pixels (0 black … 255 white, threshold at half) packed back to bits. +fn pack_bits(px: &[u8], w: u32, h: u32, inverted: bool) -> Option> { + let stride = usize::try_from(w).ok()?.div_ceil(8); + let mut out = vec![0u8; packed_len(w, h)?]; + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if px.len() != w.checked_mul(h)? { + return None; + } + for (y, row) in px.chunks(w.max(1)).enumerate() { + for (x, &v) in row.iter().enumerate() { + let white = v >= 128; + if white != inverted + && let Some(b) = out.get_mut(y * stride + x / 8) + { + *b |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Image stream `old` with its samples replaced by `samples` (same size and components): a JPEG +/// stays a JPEG (at `quality`, 1–100), anything else becomes Flate, and a bi-level image stays +/// one bit per pixel with its decode array. Filter parameters go (the new data needs none); every +/// other entry stays. `None` if the samples don't fit the image. +pub fn encode_samples(old: &Stream, samples: &Samples, quality: u8) -> Option { + let Samples { width, height, components: n, data, jpeg: as_jpeg, bilevel, inverted } = samples; + let (w, h, n) = (*width, *height, *n); + if !(n == 1 || n == 3) || data.len() != usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)? { + return None; + } + let mut d = old.dict.clone(); + for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + d.remove(k); + } + if *bilevel { + if n != 1 { + return None; + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + return Some(Stream::flate(d, &pack_bits(data, w, h, *inverted)?)); + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + if *as_jpeg { + let bytes = jpeg(data, n, w, h, quality)?; + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Some(Stream::from_raw(d, bytes)) + } else { + Some(Stream::flate(d, data)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::Dict; + + fn image(dict: &[(&str, Object)], raw: Vec) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + for (k, v) in dict { + d.set(k.as_bytes().to_vec(), v.clone()); + } + Stream::from_raw(d, raw) + } + + fn doc() -> Document { + let pdf = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + Document::open(std::sync::Arc::new(pdf.to_vec())).unwrap() + } + + #[test] + fn grey_samples_round_trip_losslessly_and_keep_the_other_entries() { + let px: Vec = (0..12 * 5).map(|i| (i * 4) as u8).collect(); + let s = image( + &[ + ("Width", Object::Int(12)), + ("Height", Object::Int(5)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(8)), + ("Interpolate", Object::Bool(true)), + ], + px.clone(), + ); + let got = decode_samples(&doc(), &s).unwrap(); + assert_eq!((got.width, got.height, got.components, got.jpeg, got.bilevel), (12, 5, 1, false, false)); + assert_eq!(got.data, px); + let back = encode_samples(&s, &got, 90).unwrap(); + assert_eq!(back.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(back.dict.get(b"Interpolate"), Some(&Object::Bool(true))); + assert_eq!(decode_samples(&doc(), &back).unwrap().data, px); + } + + #[test] + fn bilevel_samples_keep_their_decode_array_and_bits() { + // 10 × 2: first row black on the left half, second row all white; [1 0] makes 1 black. + let raw = vec![0b1111_1000, 0b0000_0000, 0, 0]; + let s = image( + &[ + ("Width", Object::Int(10)), + ("Height", Object::Int(2)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(1)), + ("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)])), + ], + raw.clone(), + ); + let got = decode_samples(&doc(), &s).unwrap(); + assert!(got.bilevel); + assert_eq!(&got.data[..10], &[0, 0, 0, 0, 0, 255, 255, 255, 255, 255]); + assert!(got.data[10..].iter().all(|&v| v == 255)); + assert!(got.inverted); + let back = encode_samples(&s, &got, 90).unwrap(); + assert_eq!(back.dict.int(b"BitsPerComponent"), Some(1)); + assert!(back.dict.get(b"Decode").is_some()); + assert_eq!(back.decoded().unwrap(), raw); + } + + #[test] + fn what_is_not_supported_says_why_and_never_panics() { + let base = |extra: &[(&str, Object)], raw: Vec| { + let mut d: Vec<(&str, Object)> = vec![ + ("Width", Object::Int(4)), + ("Height", Object::Int(4)), + ("ColorSpace", Object::name("DeviceGray")), + ("BitsPerComponent", Object::Int(8)), + ]; + d.extend(extra.iter().cloned()); + decode_samples(&doc(), &image(&d, raw)) + }; + assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace)); + assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4))); + assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter)); + assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask)); + assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode)); + // Too short, a huge size, a broken JPEG, no size at all. + assert_eq!(base(&[], vec![0; 5]), Err(NotDecoded::Corrupt)); + assert_eq!(base(&[("Width", Object::Int(1 << 30)), ("Height", Object::Int(1 << 30))], vec![0; 16]), Err(NotDecoded::TooLarge)); + assert_eq!(base(&[("Filter", Object::name("DCTDecode"))], vec![0xFF, 0xD8, 1, 2, 3]), Err(NotDecoded::Corrupt)); + assert_eq!(base(&[("Width", Object::Int(-3))], vec![0; 16]), Err(NotDecoded::Corrupt)); + // Samples that don't fit the image are refused when written back. + let s = image(&[("Width", Object::Int(4)), ("Height", Object::Int(4))], vec![0; 16]); + let wrong = Samples { width: 4, height: 4, components: 1, data: vec![0; 15], jpeg: false, bilevel: false, inverted: false }; + assert!(encode_samples(&s, &wrong, 90).is_none()); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8925a0dca1..1fd46a956f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -179,6 +179,8 @@ impl PdfCraftApp { stamp_draft: Default::default(), optimize_draft: OptimizeDraft::default(), optimize_run: None, + scan_clean_draft: scan_clean_ui::ScanCleanDraft::default(), + scan_clean_run: None, progress_notice: None, page_clipboard: None, grid_drop: None, @@ -508,6 +510,7 @@ impl eframe::App for PdfCraftApp { self.auto_ocr_scanned_page(); self.poll_ocr(); self.poll_optimize(); + self.poll_scan_clean(); self.poll_action(); self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index bb5a65cb34..3fe737b40b 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -101,6 +101,7 @@ impl PdfCraftApp { "export-all-images" => Some(Dialog::Export(export_ui::ExportKind::AllImages)), "accessibility-options" => Some(Dialog::AccessibilityOptions), "recognize-text" => Some(Dialog::RecognizeText), + "enhance-scan" => Some(Dialog::EnhanceScan), "js-console" => Some(Dialog::JsConsole), "document-js" => Some(Dialog::DocumentJs), "preferences" => Some(Dialog::Preferences), diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index e0a8370c60..3d6f493e10 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -5,6 +5,7 @@ impl PdfCraftApp { pub(super) fn show_progress(&mut self, ctx: &egui::Context) { if widgets::progress_notice(self, ctx) { self.cancel_optimize(); + self.cancel_scan_clean(); } } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index acccbcc55a..dec99e8b58 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -500,6 +500,7 @@ impl PdfCraftApp { "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), + "ocr.enhance" => self.dialog = Some(Dialog::EnhanceScan), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c21604a4da..a77b0e4d19 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -56,6 +56,7 @@ struct Pending { open_revision: Option, a11y_now: bool, ocr_now: bool, + scan_clean_now: bool, compare_now: bool, images_now: bool, stamp_now: bool, @@ -372,6 +373,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = go || cancel; return false; } + Dialog::EnhanceScan => { + let (go, cancel) = crate::scan_clean_ui::body(ui, app, &t); + req.scan_clean_now = go; + req.close = go || cancel; + return false; + } Dialog::AccessibilityOptions => { let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); req.a11y_now = start; @@ -1818,6 +1825,9 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: if req.ocr_now { app.start_ocr(); } + if req.scan_clean_now { + app.start_scan_clean(); + } if req.compare_now { app.run_compare(); } diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..076fb6bf66 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2558,3 +2558,22 @@ animation effects Off Aus Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen Extract the selected pages… Ausgewählte Seiten extrahieren… Delete the selected pages Ausgewählte Seiten löschen + Enhance scanned file… Gescannte Datei verbessern… + Enhance Scanned File Gescannte Datei verbessern + Cleans up the scanned pictures in place: recognized text, comments and links on the pages stay as they are. Bereinigt die gescannten Bilder an Ort und Stelle: erkannter Text, Kommentare und Links auf den Seiten bleiben erhalten. + Filters Filter + Background removal Hintergrund entfernen + Evens out the paper so a grey or yellowed background turns white, and darkens faint strokes. 0 is off. Gleicht das Papier aus, sodass ein grauer oder vergilbter Hintergrund weiß wird, und dunkelt blasse Striche nach. 0 ist aus. + Edge shadow removal Randschatten entfernen + Lightens shadows along the edges of the scan, such as a book's gutter or a copier's dark border Hellt Schatten an den Rändern des Scans auf, etwa den Bundsteg eines Buchs oder den dunklen Rand eines Kopierers + Despeckle Flecken entfernen + Removes isolated dark specks such as dust and scanner noise. The only filter black and white scans take. Entfernt einzelne dunkle Flecken wie Staub und Scannerrauschen. Der einzige Filter für Schwarzweiß-Scans. + Enhance Verbessern + No scanned pages were cleaned up. Es wurden keine gescannten Seiten bereinigt. + Scanned pages are already being enhanced Gescannte Seiten werden bereits verbessert + Couldn't enhance the scanned pages Die gescannten Seiten konnten nicht verbessert werden + Enhancing scans… image {d} of {t} Scans werden verbessert… Bild {d} von {t} + Enhancing the scanned pages was cancelled Das Verbessern der gescannten Seiten wurde abgebrochen +@plural Cleaned up the scan on {n} page.|Cleaned up the scans on {n} pages. Scan auf {n} Seite bereinigt.|Scans auf {n} Seiten bereinigt. +@plural {n} page is not a scan.|{n} pages are not scans. {n} Seite ist kein Scan.|{n} Seiten sind keine Scans. +@plural {n} image was left as it was (page {p}: {why}).|{n} images were left as they were (page {p}: {why}, …). {n} Bild blieb unverändert (Seite {p}: {why}).|{n} Bilder blieben unverändert (Seite {p}: {why}, …). diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65bc7e4b1b..e61e3827a5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,6 +162,7 @@ use workflows::js_ui; use workflows::ocr_ui; use workflows::optimize_ui; use workflows::print_ui; +use workflows::scan_clean_ui; use workflows::web_print; /// Files dropped on a document's page grid or its Pages panel. @@ -494,6 +495,9 @@ pub struct PdfCraftApp { pub optimize_draft: OptimizeDraft, /// The running optimization (Optimize PDF ▸ Advanced optimization). pub optimize_run: Option, + /// Scan & OCR ▸ Enhance scanned file: the dialog's choices and the running clean-up. + pub scan_clean_draft: scan_clean_ui::ScanCleanDraft, + pub scan_clean_run: Option, /// A background job's progress card (see [`widgets::progress_notice`]). pub progress_notice: Option, /// Pages copied or cut in Organize Pages, ready to paste (into any document). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index dd3b3e90aa..72d3b6ddf2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -162,6 +162,8 @@ pub enum Dialog { AccessibilityOptions, /// Scan & OCR ▸ Recognize text. RecognizeText, + /// Scan & OCR ▸ Enhance scanned file. + EnhanceScan, /// The JavaScript console (⌘J). JsConsole, /// Document JavaScripts. diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index 63686a70cc..9314914ec4 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -11,4 +11,5 @@ pub(super) mod js_ui; pub(super) mod ocr_ui; pub(super) mod optimize_ui; pub(super) mod print_ui; +pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs new file mode 100644 index 0000000000..a275b5af20 --- /dev/null +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -0,0 +1,253 @@ +//! Scan & OCR ▸ Enhance scanned file: the dialog (pages, background removal level, edge +//! shadows, despeckle) and the background run with its progress card. The scans are cleaned +//! on a worker thread ([`pdfcraft_engine::scan_clean`]); the result is applied as one undoable +//! step when it is done, unless the document changed meanwhile. + +use std::sync::{Arc, Mutex}; + +use egui::{Align, Layout}; +use pdfcraft_engine::scan_clean::{ScanCleanReport, ScanCleanSettings, ScanCleaned}; +use pdfcraft_engine::{DocId, EditError}; + +use crate::ocr_ui::OcrPages; +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +/// The dialog's choices (kept between runs). +#[derive(Clone, Debug, PartialEq)] +pub struct ScanCleanDraft { + pub pages: OcrPages, + pub from: usize, + pub to: usize, + pub settings: ScanCleanSettings, +} + +impl Default for ScanCleanDraft { + fn default() -> Self { + ScanCleanDraft { pages: OcrPages::All, from: 1, to: 1, settings: ScanCleanSettings::default() } + } +} + +/// Progress of a run: images done, total, the result once finished, and a cancel request. +#[derive(Default)] +pub struct ScanCleanProgress { + pub done: usize, + pub total: usize, + pub result: Option>, + pub cancel: bool, +} + +pub struct ScanCleanRun { + pub doc: DocId, + pub progress: Arc>, +} + +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len().max(1)); + let d = &mut app.scan_clean_draft; + d.to = d.to.clamp(1, pages); + d.from = d.from.clamp(1, d.to); + ui.label(egui::RichText::new(tl!("Enhance Scanned File")).font(theme::semibold(18.0))); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tl!("Cleans up the scanned pictures in place: recognized text, comments and links on the pages stay as they are.")) + .small() + .color(t.text_muted), + ); + ui.add_space(8.0); + let group = |ui: &mut egui::Ui, title: &str, body: &mut dyn FnMut(&mut egui::Ui)| { + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(13.0))); + egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui); + }); + ui.add_space(8.0); + }; + group(ui, "Pages", &mut |ui| { + widgets::radio_value(ui, &mut d.pages, OcrPages::All, tl!("All pages")); + widgets::radio_value(ui, &mut d.pages, OcrPages::Current, tl!("Current page")); + ui.horizontal(|ui| { + let from = widgets::radio_value(ui, &mut d.pages, OcrPages::Range, tl!("From")); + let on = d.pages == OcrPages::Range; + ui.add_enabled(on, egui::DragValue::new(&mut d.from).range(1..=pages)).labelled_by(from.id); + let to = ui.label(tl!("to")); + ui.add_enabled(on, egui::DragValue::new(&mut d.to).range(1..=pages)).labelled_by(to.id); + }); + }); + group(ui, "Filters", &mut |ui| { + ui.horizontal(|ui| { + let label = ui.label(tl!("Background removal")); + ui.spacing_mut().slider_width = 220.0; + ui.add(egui::Slider::new(&mut d.settings.level, 0..=pdfcraft_engine::camera_clean::MAX_LEVEL).trailing_fill(true)) + .labelled_by(label.id) + .on_hover_text(tl!("Evens out the paper so a grey or yellowed background turns white, and darkens faint strokes. 0 is off.")); + }); + ui.add(widgets::checkbox(&mut d.settings.edge_shadows, tl!("Edge shadow removal"))) + .on_hover_text(tl!("Lightens shadows along the edges of the scan, such as a book's gutter or a copier's dark border")); + ui.add(widgets::checkbox(&mut d.settings.despeckle, tl!("Despeckle"))) + .on_hover_text(tl!("Removes isolated dark specks such as dust and scanner noise. The only filter black and white scans take.")); + }); + ui.add_space(6.0); + let (mut go, mut cancel) = (false, false); + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let ready = d.settings.changes_something(); + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Enhance"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }) + }); + (go, cancel) +} + +/// What a finished run says: what was cleaned, what was not a scan, and what was left and why. +fn summary(r: &ScanCleanReport) -> String { + let lang = crate::i18n::current(); + let mut out = if r.pages.is_empty() { + tl!("No scanned pages were cleaned up.").to_string() + } else { + crate::i18n::trn(lang, r.pages.len() as u64, "Cleaned up the scan on {n} page.", "Cleaned up the scans on {n} pages.") + }; + if !r.not_scanned.is_empty() { + out.push(' '); + out.push_str(&crate::i18n::trn(lang, r.not_scanned.len() as u64, "{n} page is not a scan.", "{n} pages are not scans.")); + } + if let Some(first) = r.skipped.first() { + out.push(' '); + let page = first.pages.first().map_or_else(String::new, |p| (p + 1).to_string()); + // The reason is the engine's own sentence, in English. + out.push_str(&crate::i18n::fmt( + &crate::i18n::trn( + lang, + r.skipped.len() as u64, + "{n} image was left as it was (page {p}: {why}).", + "{n} images were left as they were (page {p}: {why}, …).", + ), + &[("p", &page), ("why", &first.reason)], + )); + } + out +} + +impl PdfCraftApp { + /// Clean up the scans on the pages chosen in the dialog, in the background. + pub fn start_scan_clean(&mut self) { + let Some((vi, id)) = self.active_ids() else { return }; + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } + // What's typed in a form field is part of the document (#166). + if !self.commit_form_typing() { + return; + } + let d = &self.scan_clean_draft; + let pages: Vec = match d.pages { + OcrPages::All => Vec::new(), + OcrPages::Current => self.views.get(vi).map(|v| vec![v.current]).unwrap_or_default(), + OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), + }; + let job = match self.session.scan_clean_job(id, &pages, d.settings) { + Ok(job) => job, + Err(e) => return self.notify(e.to_string()), + }; + let progress = Arc::new(Mutex::new(ScanCleanProgress { total: job.total(), ..Default::default() })); + let p = progress.clone(); + let work = move || { + let result = job.run(|done, total| { + let Ok(mut s) = p.lock() else { return false }; + s.done = done; + s.total = total; + !s.cancel + }); + if let Ok(mut s) = p.lock() { + s.result = Some(result); + } + }; + #[cfg(not(target_arch = "wasm32"))] + if self.run_inline { + work(); + } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-scan-clean".into()).spawn(work) { + // No worker: report the failure instead of waiting for a result that never comes. + if let Ok(mut s) = progress.lock() { + s.result = Some(Err(EditError::Invalid(e.to_string()))); + } + } + #[cfg(target_arch = "wasm32")] + work(); + self.scan_clean_run = Some(ScanCleanRun { doc: id, progress }); + self.poll_scan_clean(); + } + + /// Stop a running clean-up (nothing changes). + pub fn cancel_scan_clean(&mut self) { + if let Some(r) = &self.scan_clean_run + && let Ok(mut s) = r.progress.lock() + { + s.cancel = true; + } + } + + /// Show progress; apply the result once the worker is done. + pub(crate) fn poll_scan_clean(&mut self) { + let Some(run) = self.scan_clean_run.as_ref() else { return }; + let (doc, progress) = (run.doc, run.progress.clone()); + let Ok(mut s) = progress.lock() else { + // The worker panicked while holding the lock: it will never report back. + self.scan_clean_run = None; + self.progress_notice = None; + self.notify_tr("Couldn't enhance the scanned pages"); + return; + }; + let Some(result) = s.result.take() else { + let (cancelling, done, total) = (s.cancel, s.done, s.total); + drop(s); + let label = if cancelling { + tl!("Cancelling…").to_string() + } else { + crate::i18n::fmt( + tl!("Enhancing scans… image {d} of {t}"), + &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], + ) + }; + let fraction = if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }; + self.progress_notice = Some(crate::widgets::ProgressNotice { label, fraction, cancellable: !cancelling }); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + return; + }; + drop(s); + self.scan_clean_run = None; + self.progress_notice = None; + match result.and_then(|cleaned| self.session.apply_scan_clean(doc, cleaned)) { + Ok(report) => { + let msg = summary(&report); + self.notify(msg); + } + Err(EditError::Cancelled) => self.notify_tr("Enhancing the scanned pages was cancelled"), + Err(e) => self.notify(e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_engine::scan_clean::SkippedImage; + + #[test] + fn the_summary_says_what_was_done_and_what_was_left() { + let r = ScanCleanReport { + pages: vec![0, 2], + images: 1, + not_scanned: vec![1], + skipped: vec![SkippedImage { pages: vec![3], name: "Im0".into(), reason: "it is CMYK".into() }], + }; + assert_eq!(summary(&r), "Cleaned up the scans on 2 pages. 1 page is not a scan. 1 image was left as it was (page 4: it is CMYK)."); + assert_eq!(summary(&ScanCleanReport::default()), "No scanned pages were cleaned up."); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index a5c3192358..2df736c14c 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -68,6 +68,7 @@ mod reading_colors; mod recovery; mod redact; mod reload; +mod scan_clean; mod side_panels; mod signing; mod stamps; diff --git a/crates/ui-egui/tests/it/scan_clean.rs b/crates/ui-egui/tests/it/scan_clean.rs new file mode 100644 index 0000000000..afb4a4f75c --- /dev/null +++ b/crates/ui-egui/tests/it/scan_clean.rs @@ -0,0 +1,77 @@ +//! Scan & OCR ▸ Enhance scanned file in the real shell (egui_kittest): the dialog, the run and +//! its report, and that the page keeps its comment while its scan is cleaned in place. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Session, Shape, Style}; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A two-page PDF: page 1 a grey scan with a darker gutter and text bars (and a sticky note), +/// page 2 the same scan again. +fn scan() -> Vec { + let img = image::GrayImage::from_fn(300, 400, |x, y| { + let light = 0.55 + 0.45 * (x as f32 / 60.0).min(1.0); + let ink = if (60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x) { 0.12 } else { 0.78 }; + image::Luma([(ink * light * 255.0) as u8]) + }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(); + let bytes = s.create_from_images(&[("a.png".into(), png.clone()), ("b.png".into(), png)]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let shape = Shape::Note { at: [20.0, 20.0], icon: NoteIcon::Comment }; + let note = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "Keep me".into(), author: "Tester".into() }; + s.apply(id, Edit::AddAnnotation(note)).unwrap(); + s.save_bytes(id).unwrap().to_vec() +} + +fn harness() -> Harness<'static, PdfCraftApp> { + 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("scan.pdf", None, scan()).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + h +} + +#[test] +fn enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + assert!(h.state().session.get(id).unwrap().comment_props(0, 0).is_some()); + assert!(h.state_mut().execute("ocr.enhance")); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Background removal").count(), 2, "the label and the slider it names"); + h.get_by_label("Edge shadow removal"); + h.get_by_label("Despeckle"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Current page").click(); + h.run_steps(1); + h.get_by_label("Enhance").click(); + h.run_steps(3); + let app = h.state(); + assert!(app.scan_clean_run.is_none() && app.dialog.is_none(), "finished and closed"); + let toast = app.toast.clone().unwrap().0; + assert_eq!(toast, "Cleaned up the scan on 1 page."); + let doc = app.session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Enhance scanned file")); + assert_eq!(doc.info.pages.len(), 2); + assert_eq!(doc.comment_props(0, 0).map(|p| p.author), Some("Tester".to_string()), "the sticky note stays"); +} + +#[test] +fn nothing_to_enhance_is_said_not_done() { + let mut h = harness(); + let id = h.state().active_ids().unwrap().1; + h.state_mut().scan_clean_draft.settings = pdfcraft_engine::scan_clean::ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }; + assert!(h.state_mut().execute("ocr.enhance")); + h.run_steps(2); + // With every filter off the button is disabled; starting anyway changes nothing. + h.state_mut().start_scan_clean(); + h.run_steps(2); + let app = h.state(); + assert!(app.toast.clone().unwrap().0.starts_with("No scanned pages were cleaned up."), "{:?}", app.toast); + assert_eq!(app.session.get(id).unwrap().can_undo(), None); +} diff --git a/docs/gaps.md b/docs/gaps.md index 56ec3ae748..ab6f083ab1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro +> **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) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -164,7 +165,9 @@ update the numbers. ### 7. OCR reads only unaccented Latin - **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, - despeckle, background and camera clean-up; MRC compression; pluggable engines; OCR in the + despeckle and background clean-up as options of Recognize text (Enhance scanned file does them + on grey, RGB and 1-bit scans, not CMYK, indexed, JBIG2 or CCITT ones); MRC compression; + pluggable engines; OCR in the browser; scanner input. - **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without accents"); 19 of 26 OCR features planned; [#744](https://github.com/storytold/pdfcraft/issues/744) diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c1..6d910fc795 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **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) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -194,7 +195,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). 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 (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. 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. Missing: OCR languages beyond Latin, 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. | +| 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. Missing: OCR languages beyond Latin, 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). 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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c7920e33c..3c77c491cb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro +> **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) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -206,7 +207,7 @@ full parity in that area. | F Forms and JavaScript | 79 | 58 | 8 | 78.5% | 65% | 40–75 | Fill and author every AcroForm field type, Acrobat's AF functions in Acrobat's event order, sandboxed JavaScript (boa), data exchange, Fill & Sign; XFA static/dynamic layout, FormCalc and XFA data (partial). CLI info, text and render use the laid-out dynamic pages (#1645). Missing: wider JavaScript object model, document actions, submit, debugger, barcode fields, XFA flattening and full dynamic fidelity | | G Protect, redact, sanitize | 41 | 23 | 2 | 58.5% | 55% | 15–30 | Passwords and permissions, Remove security, redaction that removes glyphs, pixels, vectors, annotations and tags, with verification; word lists, patterns, FOIA/Privacy Act codes; sanitize. Missing: certificate security, pattern locales, custom code sets, partial-word redaction, folder redaction, protected view | | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | -| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | +| I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners. Background removal, edge shadow removal and despeckle work on scanned pages (Enhance scanned file) but not yet as options of Recognize text, nor on CMYK, indexed, JBIG2 or CCITT scans | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | | K Optimize and standards | 58 | 15 | 10 | 34.5% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2/CCITT | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 47ac06fdd4..1ea04254aa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6674,10 +6674,10 @@ area = "I" tier = "P1" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans (reported, not changed)." [[feature]] id = "ocr.background-removal" @@ -6686,10 +6686,10 @@ area = "I" tier = "P1" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images (Enhance camera image level) and scanned pages (Enhance scanned file ▸ Background removal, page_enhance_scan level): the paper's illumination is evened out per channel so a grey, yellowed or unevenly lit background turns white, then the contrast is raised; colour ink stays. Scans are cleaned in place in their own image objects (JPEG stays JPEG at quality 90, others lossless), so the OCR text layer, comments and links stay; one undo step. Missing: as an option of Recognize text; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed); a preview." [[feature]] id = "ocr.descreen" @@ -6722,10 +6722,10 @@ area = "I" tier = "P2" milestone = "M10" status = "partial" -commands = ["ocr.enhance_camera"] -tools = ["page_enhance_camera"] -evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"] -notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option." +commands = ["ocr.enhance_camera", "ocr.enhance"] +tools = ["page_enhance_camera", "page_enhance_scan"] +evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] +notes = "Camera images (the clean-up level divides out a smooth illumination estimate) and scanned pages (Enhance scanned file ▸ Edge shadow removal, page_enhance_scan edge_shadows): within the outer fifth of each side, every column and row is lightened to the brightest paper of that band, which removes book gutters and copier borders the coarse estimate misses; a black scanner border is left alone. Missing: as an option of Recognize text; curved gutters that also bend the text (no dewarping); the scan formats background removal also skips." [[feature]] id = "ocr.camera-perspective" From 622625a178b9bec514b586f12b53fe1c28ab8f33 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Wed, 28 Oct 2026 13:37:00 +0100 Subject: [PATCH 021/562] M10: Enhance scanned file reports scans inside Form XObjects and names JPEG 2000 - A scan drawn only through grouped Form artwork is reported in skipped (once per form, with every page that draws it) instead of being counted as no scan; a Form of vector artwork holding only a small picture (under 36 dpi over its area) still counts as no scan. - decode_samples checks the filter before the colour space and bit depth, so a JPEG 2000 image without /ColorSpace or /BitsPerComponent is reported as JPEG 2000, not as a colour-space or bit-depth problem. - Optimize and Enhance scanned file no longer start while the other runs: they share the progress card and capture the same document state. - docs/gaps.md: background and edge-shadow removal apply to grey and RGB scans, despeckle also to 1-bit ones. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 2 +- crates/engine/src/copy_image.rs | 2 +- crates/engine/src/scan_clean.rs | 25 ++++++- crates/engine/src/scan_clean/tests.rs | 53 ++++++++++++++ crates/engine/src/scan_fields.rs | 70 +++++++++++++++---- crates/optimize/src/samples.rs | 9 ++- crates/ui-egui/src/workflows/optimize_ui.rs | 23 ++++++ crates/ui-egui/src/workflows/scan_clean_ui.rs | 5 ++ docs/gaps.md | 6 +- parity/acrobat-features.toml | 2 +- 10 files changed, 176 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0d34911154..c718e4a47a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -225,7 +225,7 @@ pub fn tools() -> Vec { }), &["doc", "page"], )), - t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, broken data, or nothing to change). One undoable step; none when nothing changed.") + t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, a scan drawn inside grouped Form artwork, broken data, or nothing to change). One undoable step; none when nothing changed.") .cmd("ocr.enhance") .with(schema( json!({ diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index b377903ee4..1407c9f1d2 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -113,7 +113,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option { } /// The largest raster image Form `form` draws through its resources (nested Forms included). -fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { +pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { let mut seen = HashSet::from([form]); let mut best: Option<(u64, ObjRef)> = None; let res = resources(cos, form)?; diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs index bfc8188b29..48ff15093a 100644 --- a/crates/engine/src/scan_clean.rs +++ b/crates/engine/src/scan_clean.rs @@ -12,7 +12,7 @@ //! pictures each covering at least a tenth of the page are its scan. What is not cleaned is //! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft //! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping -//! decode array) or whose data is broken. +//! decode array, a scan drawn inside grouped Form artwork) or whose data is broken. //! //! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document, //! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`] @@ -31,6 +31,8 @@ const QUALITY: u8 = 90; /// On a scanned page, an image covering at least this share of the page is part of the scan; /// smaller ones (a logo or a signature pasted on) are left alone. const PART_OF_SCAN: f64 = 0.1; +/// Why a scan drawn through grouped Form artwork stays as it is. +const IN_FORM: &str = "it is drawn inside grouped artwork (a form XObject), which can't be cleaned up yet"; /// How scanned pages are cleaned up. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -138,10 +140,29 @@ impl Session { wanted.dedup(); let mut report = ScanCleanReport::default(); let mut targets: Vec = Vec::new(); + // Form artwork already reported, and where in `report.skipped`. + let mut forms_reported: Vec<(ObjRef, usize)> = Vec::new(); for page in wanted { let Some(info) = doc.info.pages.get(page) else { continue }; let Some(images) = crate::scan_fields::scan_images(doc, page, info) else { - report.not_scanned.push(page); + // A scan drawn through a Form XObject is a scan all the same: say why it stays. + let forms: Vec<_> = crate::scan_fields::scan_forms(doc, page, info).into_iter().filter(|(_, share)| *share >= PART_OF_SCAN).collect(); + if forms.is_empty() { + report.not_scanned.push(page); + } + for (form, _) in forms { + let known = form.object.and_then(|o| forms_reported.iter().find(|(r, _)| *r == o)).and_then(|(_, k)| report.skipped.get_mut(*k)); + match known { + Some(s) if !s.pages.contains(&page) => s.pages.push(page), + Some(_) => {} + None => { + if let Some(o) = form.object { + forms_reported.push((o, report.skipped.len())); + } + report.skipped.push(SkippedImage { pages: vec![page], name: form.name, reason: IN_FORM.into() }); + } + } + } continue; }; for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) { diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs index 561ba9e2e4..f39f8c0432 100644 --- a/crates/engine/src/scan_clean/tests.rs +++ b/crates/engine/src/scan_clean/tests.rs @@ -275,6 +275,59 @@ fn hostile_images_are_reported_not_a_panic() { assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_)))); } +#[test] +fn a_scan_drawn_inside_a_form_xobject_is_reported_not_called_no_scan() { + // Pages 1 and 3 draw the scan through Form Fm0 (as some scanners write it), page 2 is text. + let page = |contents: usize| { + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> /Font << /F1 9 0 R >> >> /Contents {contents} 0 R >>" + ) + }; + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 7 0 R 10 0 R] /Count 3 >>") + .obj(page(6)) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"), + ) + .stream(&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"), scan_pixels(1)) + .stream("", "/Fm0 Do") + .obj(page(8)) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj(page(11)) + .stream("", "q /Fm0 Do Q") + .build(); + let (mut s, id) = open(bytes); + let before = object(&s, id, 5); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert!(report.pages.is_empty() && report.images == 0, "{report:?}"); + assert_eq!(report.not_scanned, vec![1], "only the text page is no scan"); + assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}"); + let k = &report.skipped[0]; + assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 2][..], "Fm0")); + assert!(k.reason.contains("form XObject"), "{k:?}"); + assert_eq!(object(&s, id, 5), before, "the picture is left as it was"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + + // A page of vector artwork in a Form, holding only a small logo, is no scan. + let logo = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> >> /Contents 6 0 R >>")) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("0 0 {W} {H} re f q 40 0 0 20 10 370 cm /Im0 Do Q"), + ) + .stream("/Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceGray /BitsPerComponent 8", [128; 800]) + .stream("", "/Fm0 Do") + .build(); + let (mut s, id) = open(logo); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.not_scanned.as_slice(), report.skipped.len()), (&[0][..], 0), "{report:?}"); +} + #[test] fn protected_documents_keep_their_encryption_and_restrictions() { let protect = |user: &str, permissions: i32| { diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs index 769beaefca..d2e663f9a5 100644 --- a/crates/engine/src/scan_fields.rs +++ b/crates/engine/src/scan_fields.rs @@ -19,6 +19,9 @@ const SCALE: f32 = 2.0; const MAX_SIDE: f32 = 3000.0; /// A page is a scan when its images cover at least this share of it. const COVERED: f64 = 0.5; +/// A picture inside Form artwork is taken for a scan when it has at least this resolution over +/// the area the artwork covers (a quarter of the pixels a 72 dpi scan would have). +const MIN_FORM_DPI: f64 = 36.0; /// Recognised "words" less tall than this (points) are specks read as letters, and would make /// the box they sit on look filled in. const MIN_WORD_HEIGHT: f64 = 4.0; @@ -32,28 +35,69 @@ pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { /// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise. /// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]). pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> { - let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); - let page_area = (cx1 - cx0) * (cy1 - cy0); - if !page_area.is_finite() || page_area <= 0.0 { - return None; - } + let area = page_area(info)?; let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc .page_images(page) .into_iter() .filter(|i| !i.is_form && i.width > 1 && i.height > 1) - .map(|i| { - let r = i.visible_rect.unwrap_or(i.rect); - let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); - let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); - let share = w * h / page_area; - (i, share) - }) - .filter(|(_, share)| share.is_finite()) + .filter_map(|i| Some((share(info, area, &i)?, i))) + .map(|(share, i)| (i, share)) .collect(); let covered: f64 = images.iter().map(|(_, share)| share).sum(); (covered >= COVERED).then_some(images) } +/// The grouped Form artwork on `page` that holds a raster picture, each with the share of the +/// page it covers, when it and the page's own images together cover most of the page: a scan +/// drawn through a Form XObject, which [`scan_images`] does not count and Enhance ▸ Scanned +/// document can't clean yet, so it reports these. Empty otherwise. +pub(crate) fn scan_forms(doc: &Document, page: usize, info: &PageInfo) -> Vec<(pdfcraft_edit::PageImage, f64)> { + let (Some(area), Some(editor)) = (page_area(info), doc.editor.as_ref()) else { return Vec::new() }; + let mut covered = 0.0; + let mut forms = Vec::new(); + for i in doc.page_images(page) { + let Some(share) = share(info, area, &i) else { continue }; + if !i.is_form { + if i.width > 1 && i.height > 1 { + covered += share; + } + } else if i.object.is_some_and(|form| holds_scan(&editor.cos, form, share * area)) { + covered += share; + forms.push((i, share)); + } + } + if covered >= COVERED { forms } else { Vec::new() } +} + +/// Whether Form `form`, covering `points` square points of the page, draws a picture that could +/// be a scan of it: one with at least [`MIN_FORM_DPI`] over that area, not a logo inside a page +/// of vector artwork. +fn holds_scan(cos: &pdfcraft_cos::Document, form: pdfcraft_cos::ObjRef, points: f64) -> bool { + let Some(picture) = crate::copy_image::picture_in_form(cos, form) else { return false }; + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { return false }; + let px = |k: &[u8]| s.dict.int(k).unwrap_or(0).max(0) as f64; + let per_point = MIN_FORM_DPI / 72.0; + px(b"Width") * px(b"Height") >= points * per_point * per_point +} + +/// The area of `info`'s crop box, when it is a usable one. +fn page_area(info: &PageInfo) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let area = (cx1 - cx0) * (cy1 - cy0); + (area.is_finite() && area > 0.0).then_some(area) +} + +/// The share of the crop box (of area `area`) that `image` covers, when it is a number. +fn share(info: &PageInfo, area: f64, image: &pdfcraft_edit::PageImage) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let r = image.visible_rect.unwrap_or(image.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + let share = w * h / area; + share.is_finite().then_some(share) +} + /// The page as displayed, in points with y up: where detection on a scan works. struct Frame<'a>(&'a PageInfo); diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs index 7f402b51b8..1ed9f16b1f 100644 --- a/crates/optimize/src/samples.rs +++ b/crates/optimize/src/samples.rs @@ -53,6 +53,9 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) { return Err(NotDecoded::StencilMask); } + // The filter first: a JPEG 2000 image may leave its colour space and bit depth to the data, + // and the report should name the format, not the missing entries. + let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?; let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0); let bilevel = match bpc { @@ -60,7 +63,6 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result 1 if n == 1 => true, other => return Err(NotDecoded::BitDepth(other)), }; - let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); if width == 0 || height == 0 { @@ -258,6 +260,11 @@ mod tests { assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace)); assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4))); assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter)); + // JPEG 2000 may leave the colour space and bit depth to its data: the format is the reason. + let jpx = |dict: &[(&str, Object)]| decode_samples(&doc(), &image(dict, vec![0; 8])); + let size = [("Width", Object::Int(4)), ("Height", Object::Int(4)), ("Filter", Object::name("JPXDecode"))]; + assert_eq!(jpx(&size), Err(NotDecoded::Filter)); + assert_eq!(jpx(&[size[0].clone(), size[1].clone(), size[2].clone(), ("ColorSpace", Object::name("DeviceRGB"))]), Err(NotDecoded::Filter)); assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask)); assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode)); // Too short, a huge size, a broken JPEG, no size at all. diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 3bb80ac439..4e1a7c20bf 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -360,6 +360,11 @@ impl PdfCraftApp { self.notify_tr("Optimization is already running"); return; } + // Both jobs share the progress card and capture the same document state. + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, Err(e) => return self.save_optimized(id, kind, Err(e)), @@ -491,4 +496,22 @@ mod tests { app.optimize_with_draft(); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); } + + #[test] + fn optimize_and_enhance_scanned_file_do_not_run_at_once() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); + let (_, doc) = app.active_ids().unwrap(); + app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress: Arc::default() }); + app.optimize_with_draft(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Scanned pages are already being enhanced")); + assert!(app.optimize_run.is_none()); + + app.scan_clean_run = None; + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.start_scan_clean(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); + assert!(app.scan_clean_run.is_none()); + } } diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs index a275b5af20..64f8ce48eb 100644 --- a/crates/ui-egui/src/workflows/scan_clean_ui.rs +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -140,6 +140,11 @@ impl PdfCraftApp { self.notify_tr("Scanned pages are already being enhanced"); return; } + // Both jobs share the progress card and capture the same document state. + if self.optimize_run.is_some() { + self.notify_tr("Optimization is already running"); + return; + } // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; diff --git a/docs/gaps.md b/docs/gaps.md index ab6f083ab1..dacca2515b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,8 +165,10 @@ update the numbers. ### 7. OCR reads only unaccented Latin - **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, - despeckle and background clean-up as options of Recognize text (Enhance scanned file does them - on grey, RGB and 1-bit scans, not CMYK, indexed, JBIG2 or CCITT ones); MRC compression; + despeckle and background clean-up as options of Recognize text (Enhance scanned file removes + the background and edge shadows on grey and RGB scans and despeckles those and 1-bit ones; it + reports CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans drawn inside Form XObjects + and leaves them as they are); MRC compression; pluggable engines; OCR in the browser; scanner input. - **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1ea04254aa..5f48c815a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6677,7 +6677,7 @@ status = "partial" commands = ["ocr.enhance_camera", "ocr.enhance"] tools = ["page_enhance_camera", "page_enhance_scan"] evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] -notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans (reported, not changed)." +notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed)." [[feature]] id = "ocr.background-removal" From 412fbb397ae0f8087369d5673a735f5b60778091 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Thu, 29 Oct 2026 00:50:10 -0400 Subject: [PATCH 022/562] fix(forms): clearing a field's length limit leaves it with none, not its parent field's /MaxLen is inheritable (ISO 32000-2 12.7.5.3). Clearing a kid's limit removed its own key, so the parent's limit came back. When an ancestor sets /MaxLen, write /MaxLen 0, which read.rs already reads as no limit and which overrides the inherited one (#3323). Follow-up to #4991. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/automation/tests/it/automation.rs | 25 ++++++++++++++++++++++++ crates/forms/src/author/edit.rs | 3 +++ crates/forms/src/tests.rs | 23 ++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..1613236ba5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9433,6 +9433,31 @@ fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_ assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1"); } +/// Clearing a field's length limit through `form_set_props` (`"max_length": 0`) leaves it with +/// none, even when its parent field has one it would otherwise inherit. +#[test] +fn clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /T (address) /FT /Tx /MaxLen 3 /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /MaxLen 8 /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("kid-max-len"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "max_length": 0 })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Absalom" } })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); + assert!(city.get("max_length").is_none(), "no limit, not the parent's 3: {city}"); + assert_eq!(city["value"], "Absalom", "the value is not cut to the parent's 3 characters"); +} + /// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none, /// even when its parent field has one it would otherwise inherit. #[test] diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 54a05f3db5..df97c7841a 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -189,6 +189,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + // An absent key would inherit the parent's limit; zero is "no limit" (`read.rs`). + None if parent_limit => d.set(b"MaxLen".to_vec(), Object::Int(0)), None => { d.remove(b"MaxLen"); } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ac414af2ae..a326c0032a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5271,6 +5271,29 @@ fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { assert_eq!(selection_indices(&doc, &list), Some(vec![2])); } +#[test] +fn clearing_a_kids_length_limit_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let city = field(&fields(&doc), "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { max_len: Some(Some(8)), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").max_len, Some(8)); + set_props(&mut doc, "address.city", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert_eq!(f.max_len, None, "no limit, not the parent's 3"); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"MaxLen")).and_then(Object::as_int); + assert_eq!(own, Some(0), "the kid holds an explicit unlimited of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap(); + assert_eq!(field(&fields(&reopen(&doc)), "address.city").value, ["Absalom"], "not cut to 3 characters"); + // Without a parent limit, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { max_len: Some(Some(4)), ..FieldProps::default() }).unwrap(); + set_props(&mut doc, "name", &FieldProps { max_len: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"MaxLen").is_none()); +} + #[test] fn clearing_a_kids_default_does_not_bring_back_its_parents() { let mut doc = fixture(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..eb55829838 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5003,8 +5003,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"] -notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::button_caption_preserves_other_widgets_and_shared_dictionaries", "crates/forms/src/tests.rs::a_button_caption_is_drawn_in_its_fields_font_size_and_color", "crates/ui-egui/tests/it/prepare.rs::button_label_is_editable_without_renaming_the_field", "crates/automation/tests/it/button_caption.rs::button_caption_can_be_changed_after_creation", "crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/it/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/it/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/it/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::changing_a_lists_options_repoints_its_selected_indices", "crates/forms/src/tests.rs::shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row", "crates/forms/src/tests.rs::each_widget_with_its_own_top_row_scrolls_by_itself", "crates/forms/src/tests.rs::a_list_boxs_top_row_follows_a_font_size_changed_with_its_items", "crates/forms/src/tests.rs::sorting_a_combo_boxs_items_repoints_its_selected_index", "crates/automation/tests/it/automation.rs::changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items", "crates/forms/src/tests.rs::clearing_a_kids_default_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_kids_length_limit_does_not_bring_back_its_parents", "crates/automation/tests/it/automation.rs::clearing_a_kids_length_limit_through_tools_does_not_bring_back_its_parents", "crates/forms/src/tests.rs::clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off", "crates/automation/tests/it/automation.rs::clearing_a_kids_default_through_tools_does_not_bring_back_its_parents"] +notes = "push buttons: per-widget normal label independent of the field name, including clearing and save/reopen, drawn in the font, size and color of the button's /DA (the form's /DR font, else Helvetica; an automatic size fits the button) and centered by Helvetica's widths; icon-only layout retained, rollover/down labels and icon layout editing remain unavailable; text: alignment, default (clearing it leaves none, not a parent field's), multi-line, scroll, rich text flag, limit (clearing it leaves none, not a parent field's), password, file selection, spell check, comb; choices: items (the selected indices /I follow their items when the list is edited or sorted), default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" From 7878a4cb20604a57b7ae0cbc964bcf3c29b3a711 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Thu, 29 Oct 2026 16:37:23 +0100 Subject: [PATCH 023/562] M10: Enhance scanned file is tested to leave unchosen pages' scans alone A new engine test builds two pages that each draw their own scan image, cleans one page, and checks that page's image changed while the other image object, and its stream bytes, stayed identical; then the same with the other page chosen. Taking every page instead of the chosen ones fails it, on the report and, with that assertion removed, on the other image. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/scan_clean/tests.rs | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs index f39f8c0432..11a1bca9c9 100644 --- a/crates/engine/src/scan_clean/tests.rs +++ b/crates/engine/src/scan_clean/tests.rs @@ -389,3 +389,37 @@ fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() { assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40)); assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone"); } + +/// Two pages, each with its own scan image (objects 4 and 7), so cleaning one page can be told +/// apart from cleaning the other. +fn two_scans_pdf() -> Vec { + let dict = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"); + let draw = format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"); + Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>")) + .stream(&dict, scan_pixels(1)) + .stream("", &draw) + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 7 0 R >> >> /Contents 8 0 R >>")) + .stream(&dict, scan_pixels(1)) + .stream("", &draw) + .build() +} + +#[test] +fn only_the_chosen_pages_scan_is_cleaned() { + // Clean page 1, then page 2 of a fresh copy: the other page's image stays byte-identical. + for (chosen, image, other) in [(0usize, 4u32, 7u32), (1, 7, 4)] { + let (mut s, id) = open(two_scans_pdf()); + let (image_before, other_before) = (object(&s, id, image), object(&s, id, other)); + assert_eq!(s.get(id).unwrap().page_images(chosen)[0].object, Some(ObjRef::new(image, 0)), "the fixture"); + let report = s.enhance_scanned_pages(id, &[chosen], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.pages.as_slice(), report.images), (&[chosen][..], 1), "page {}", chosen + 1); + assert_ne!(object(&s, id, image), image_before, "page {}'s image was not cleaned", chosen + 1); + assert_eq!(object(&s, id, other), other_before, "cleaning page {} changed the other page's image", chosen + 1); + let (Object::Stream(was), Object::Stream(now)) = (&*other_before, &*object(&s, id, other)) else { panic!("the images") }; + assert_eq!(was.raw, now.raw, "the other image's stream bytes"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file")); + } +} From cc9d95e1fbd6f9bff0ef93e4a194e9ac1ebf3cbd Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:29:23 -0400 Subject: [PATCH 024/562] fix(edit): an edited OCR phrase under its scan image is drawn on top (#3973) Some OCR tools write the invisible text layer first and the opaque page image after it. Edit text rewrote such a phrase in the text's place, so the cover and the new visible text were painted under the scan: the edit was saved (text extraction finds it) but nothing showed. When the rewritten paragraph was invisible and an XObject, inline image or shading is painted after its place, the cover and the new text now go on top of the page in their own q/Q (place_tagged, tagged Edited), drawn from the initial graphics state with the paragraph's CTM. Only the state the following text expects stays in place. Both rewrite paths (single style and styled rows) do this. An OCR layer drawn over its scan, the common case, is rewritten in place as before, so reading order is kept. The test builds a scan whose OCR word comes before the image and checks the cover and new text show, for one line and for a typed line break. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/lib.rs | 2 +- crates/edit/src/place.rs | 36 ++++++++++++ crates/edit/src/text/blocks.rs | 3 +- crates/edit/src/text/interp.rs | 20 +++++++ crates/edit/src/text/rewrite.rs | 26 +++++++-- crates/edit/src/text_regions/styled.rs | 12 +++- crates/engine/tests/it/ocr_edit.rs | 81 ++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 174 insertions(+), 10 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7c8556332b..1ca43d20a8 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -68,7 +68,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, page_list, place_tagged}; +use place::{check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 2f2c24c862..3056d3f71b 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -346,6 +346,42 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: Ok(mark_ref) } +/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged +/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR +/// layer written before the scan image it lies under would keep the revealed text hidden (#3973). +pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> { + if ops.is_empty() { + return Ok(()); + } + use pdfcraft_content::Op; + let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())]; + all.extend_from_slice(ops); + all.push(Op::new("Q", vec![])); + place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) +} + +/// Whether a rewritten paragraph that was `invisible` goes on top of the page: anything after +/// operator `at` can paint over it there (an XObject, an inline image or a shading), as the scan +/// image does over an OCR layer written before it (#3973). +pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op], at: usize) -> bool { + invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) +} + +/// The operators to draw on top of `page` ([`draw_on_top`]): all of `paragraph` when `over`, +/// after checking the page takes them, so a refusal leaves the document as it was. +pub(crate) fn lift_on_top( + doc: &Document, + page: &pdfcraft_model::Page, + over: bool, + paragraph: &mut Vec, +) -> Result, EditError> { + if !over { + return Ok(Vec::new()); + } + check_on_top(doc, page)?; + Ok(std::mem::take(paragraph)) +} + fn too_deep_on_top() -> EditError { EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) } diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 615e7699a2..d1cb997c89 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -28,7 +28,8 @@ pub struct TextBlock { /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose - /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). + /// pixels show the words. A rewrite draws the new text visibly, on top of the page when + /// the scan is drawn after it (see [`BlockStyle::cover`]). pub invisible: bool, } diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 16b04b406f..08e9b2c5d7 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -25,6 +25,26 @@ pub(super) struct TextState { } impl TextState { + /// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on + /// top of the page, after its content wrapped in `q`/`Q`. + pub(crate) fn initial() -> Self { + TextState { + font: None, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + fill: Vec::new(), + render_mode: 0, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, + } + } + pub(super) fn of(ts: &Ts) -> Self { TextState { font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 513077a027..f03174885a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -865,9 +865,26 @@ pub(crate) fn rewrite_block_from( char_spacing: o_state.char_spacing, lead, }; - let plan = - BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let at = if split { start } else { emit.bt_op }; + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let over = crate::revealed_under_paint(b.invisible, &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; + let plan = BlockPlan { + block: &b, + lines: &lines, + style, + emit, + after: drawn_from, + movement, + state: o_state, + tm, + size, + underline: style.underline == Some(true), + }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { @@ -875,7 +892,6 @@ pub(crate) fn rewrite_block_from( block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !dropped.contains(&i)) @@ -892,12 +908,14 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; + crate::draw_on_top(doc, &p, emit.ctm, &on_top)?; Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a /// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. -/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it. +/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one +/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]). pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e8b3b683d0..c03b75d3f4 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,3 +1,4 @@ +use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, @@ -783,6 +784,12 @@ pub(crate) fn rewrite_styled_block( let o = &lead.origin; // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; + let at = if split { start } else { o.bt_op }; + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let over = crate::revealed_under_paint(block.invisible, &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -869,7 +876,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(after)); + block_ops.extend(state.paint_ops(drawn_from)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); @@ -967,13 +974,13 @@ pub(crate) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); + let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); @@ -981,5 +988,6 @@ pub(crate) fn rewrite_styled_block( d.set(b"Contents".to_vec(), contents); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + crate::draw_on_top(doc, &p, o.ctm, &on_top)?; Ok(LineEdit { substituted }) } diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 8009ef1bbf..86fc1c9df2 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -207,3 +207,84 @@ fn visible_stretched_text_rewrites_unchanged() { let i = ((y * out.width + x) * 4) as usize; assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]); } + +/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools +/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. +fn ocr_under_scan(text: &str) -> std::sync::Arc> { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; + let (w, h) = (200u32, 100u32); + let mut rgb = Vec::new(); + for y in 0..h { + for x in 0..w { + let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y); + rgb.extend(if ink { INK } else { PAPER }); + } + } + let mut d = Document::new_empty(); + let pages = d.add(Object::Dict(Dict::new())); + let content = format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET q 200 0 0 100 0 0 cm /Im Do Q"); + let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(i64::from(w))); + image.set(b"Height".to_vec(), Object::Int(i64::from(h))); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let image = d.add(Object::Stream(Stream::flate(image, &rgb))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font)); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + let page = d.add(Object::Dict(p)); + d.update_dict(pages, |p| { + p.set(b"Type".to_vec(), Object::name("Pages")); + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut root = Dict::new(); + root.set(b"Type".to_vec(), Object::name("Catalog")); + root.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = d.add(Object::Dict(root)); + d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) +} + +/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, +/// not in the OCR text's place where the image would hide it (#3973). One line takes the +/// single-style rewrite, a typed line break the styled one. +#[test] +fn an_ocr_layer_under_the_scan_image_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].invisible, "the OCR layer is invisible text"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); + + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: edit.into(), style: Default::default() }).unwrap(); + + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.iter().map(|b| b.text.replace('\n', " ")).collect::>(), [edit.replace('\n', " ")], "{edit:?}"); + let px = word_pixels(&s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{edit:?}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{edit:?}: the new text shows: {ink} of {} ink", px.len()); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..aaaf789f39 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6628,8 +6628,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the new text is drawn on top of the page, so it shows also when the OCR layer was written under the scan image (#3973); the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample" From 270a11e699f8f224b67a5e8d45cc85596797fd5f Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:43:25 -0400 Subject: [PATCH 025/562] fix(edit): refuse before changing anything; cover undo, reopen, re-edit and the split case An OCR paragraph that may go on top of the page is checked (`check_on_top`) before either rewrite path changes the document, so a page whose marked content nests too deep is refused with nothing changed. `lift_on_top` no longer checks. Tests: undo restores the hidden layer and the scan, redo, save and reopen, and a second edit of the moved text all show the edit; a phrase split from visible text in the same text object shows its edit; a refused rewrite on a too-deep page leaves the saved bytes unchanged. Each new test was seen to fail with its part of the fix reverted. Parity: the note says when the edit is drawn on top and lists what is still missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/place.rs | 17 ++---- crates/edit/src/tests.rs | 15 ++++++ crates/edit/src/text/rewrite.rs | 7 ++- crates/edit/src/text_regions/styled.rs | 2 +- crates/engine/tests/it/ocr_edit.rs | 73 ++++++++++++++++++++++---- parity/acrobat-features.toml | 4 +- 6 files changed, 92 insertions(+), 26 deletions(-) diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 3056d3f71b..f1acb6bdca 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -367,19 +367,10 @@ pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op] invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) } -/// The operators to draw on top of `page` ([`draw_on_top`]): all of `paragraph` when `over`, -/// after checking the page takes them, so a refusal leaves the document as it was. -pub(crate) fn lift_on_top( - doc: &Document, - page: &pdfcraft_model::Page, - over: bool, - paragraph: &mut Vec, -) -> Result, EditError> { - if !over { - return Ok(Vec::new()); - } - check_on_top(doc, page)?; - Ok(std::mem::take(paragraph)) +/// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, +/// none otherwise. The page was checked to take them ([`check_on_top`]) before the edit began. +pub(crate) fn lift_on_top(over: bool, paragraph: &mut Vec) -> Vec { + if over { std::mem::take(paragraph) } else { Vec::new() } } fn too_deep_on_top() -> EditError { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 359155015f..2d3ba5301f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,6 +4550,21 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } +/// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep +/// for that is refused before either rewrite path changes anything. +#[test] +fn an_ocr_rewrite_refused_on_top_leaves_the_document_as_it_was() { + let content = format!("{} BT /F1 20 Tf 3 Tr 42 44 Td (John) Tj ET /Im Do {}", "/X BMC ".repeat(10_001), "EMC ".repeat(10_001)); + let mut doc = text_page(&content); + assert!(text::text_blocks(&doc, 0).unwrap()[0].invisible); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + for edit in ["Jane", "Jane\nDoe"] { + let error = text::rewrite_block(&mut doc, 0, 0, Some(edit), &text::BlockStyle::default()).unwrap_err(); + assert!(error.to_string().contains("nested too deeply"), "{edit:?}: {error}"); + assert!(write_full(&doc, &SaveOptions::default()).unwrap() == before, "{edit:?}: a refused rewrite changes nothing"); + } +} + #[test] fn group_move_budget_refusals_keep_the_original_page_selectable() { let text = text_page(&format!("{} BT /F1 12 Tf 40 400 Td (Target) Tj ET", "n ".repeat(99_995))); diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index f03174885a..42db5c4af3 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -755,6 +755,11 @@ pub(crate) fn rewrite_block_from( let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; members.iter().try_for_each(|line| line.ensure_editable())?; let p = page_dict(doc, page)?; + // Revealed OCR text may go on top of the page (see `revealed_under_paint`): a page that can't + // take that is refused here, before either rewrite path changes anything. + if b.invisible { + crate::check_on_top(doc, &p)?; + } let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; @@ -884,7 +889,7 @@ pub(crate) fn rewrite_block_from( underline: style.underline == Some(true), }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; - let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; + let on_top = crate::lift_on_top(over, &mut block_ops); let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index c03b75d3f4..8ff4b0b62e 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -974,7 +974,7 @@ pub(crate) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); - let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; + let on_top = crate::lift_on_top(over, &mut block_ops); block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 86fc1c9df2..a51ce8ddcb 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -211,6 +211,11 @@ fn visible_stretched_text_rewrites_unchanged() { /// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools /// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. fn ocr_under_scan(text: &str) -> std::sync::Arc> { + under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET")) +} + +/// A page whose `text` content is followed by the opaque 200 × 100 scan image. +fn under_scan(text: &str) -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; let (w, h) = (200u32, 100u32); let mut rgb = Vec::new(); @@ -222,7 +227,7 @@ fn ocr_under_scan(text: &str) -> std::sync::Arc> { } let mut d = Document::new_empty(); let pages = d.add(Object::Dict(Dict::new())); - let content = format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET q 200 0 0 100 0 0 cm /Im Do Q"); + let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q"); let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); let mut image = Dict::new(); image.set(b"Type".to_vec(), Object::name("XObject")); @@ -264,9 +269,27 @@ fn ocr_under_scan(text: &str) -> std::sync::Arc> { std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) } +/// The scanned word's box shows the cover and new ink, not the scan. +fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) { + let px = word_pixels(s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len()); +} + +fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec { + s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect() +} + +fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) { + s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap(); +} + /// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, /// not in the OCR text's place where the image would hide it (#3973). One line takes the -/// single-style rewrite, a typed line break the styled one. +/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and +/// the edit survives redo, a save and reopen, and being edited again. #[test] fn an_ocr_layer_under_the_scan_image_shows_its_edit() { for edit in ["Jane", "Jane\nDoe"] { @@ -277,14 +300,46 @@ fn an_ocr_layer_under_the_scan_image_shows_its_edit() { assert!(blocks[0].invisible, "the OCR layer is invisible text"); assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); - s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: edit.into(), style: Default::default() }).unwrap(); + edit_block(&mut s, id, 0, edit); + assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}"); + assert_edit_shows(&s, id, edit); + s.undo(id).unwrap(); + assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo"); + assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again"); + s.redo(id).unwrap(); + assert_edit_shows(&s, id, &format!("{edit:?} redo")); + + let saved = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened"); + assert_edit_shows(&reopened, id, &format!("{edit:?} reopened")); + + edit_block(&mut reopened, id, 0, "Ann"); + assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again"); + assert_edit_shows(&reopened, id, &format!("{edit:?} edited again")); + } +} + +/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still +/// goes on top of the scan, and the visible text stays. +#[test] +fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET"); + let id = s.open("split.pdf", None, page, None).unwrap(); let blocks = s.get(id).unwrap().text_blocks(0); - assert_eq!(blocks.iter().map(|b| b.text.replace('\n', " ")).collect::>(), [edit.replace('\n', " ")], "{edit:?}"); - let px = word_pixels(&s, id); - let paper = px.iter().filter(|p| near(**p, PAPER)).count(); - let ink = px.iter().filter(|p| near(**p, INK)).count(); - assert!(paper * 10 > px.len() * 4, "{edit:?}: the cover is drawn over the scan: {paper} of {} paper", px.len()); - assert!(ink * 100 > px.len() * 3, "{edit:?}: the new text shows: {ink} of {} ink", px.len()); + let john = blocks.iter().position(|b| b.text == "John").unwrap(); + assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}"); + edit_block(&mut s, id, john, edit); + let mut after = texts(&s, id); + after.sort(); + let mut want = vec!["Head".to_string(), edit.replace('\n', " ")]; + want.sort(); + assert_eq!(after, want, "{edit:?}"); + assert_edit_shows(&s, id, &format!("{edit:?} split")); } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index aaaf789f39..68716cf5fd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6628,8 +6628,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the new text is drawn on top of the page, so it shows also when the OCR layer was written under the scan image (#3973); the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image, inline image or shading is painted after the OCR layer (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 5a2f9273a39de3c529b9108fd41735478ff43f46 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 00:46:27 -0400 Subject: [PATCH 026/562] test(edit): pin when revealed OCR text moves on top A table test for `revealed_under_paint`: only invisible text with an XObject, inline image or shading after its place moves; a filled path, visible text or nothing after it does not. Seen to fail with the check reduced to `invisible`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2d3ba5301f..a51612106b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,6 +4550,22 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } +/// Only invisible text with an XObject, inline image or shading painted after its place moves on top. +#[test] +fn revealed_text_moves_on_top_only_when_painted_over() { + let ops = pdfcraft_content::parse(b"BT (a) Tj ET q /Im Do Q BT (b) Tj ET BI /W 1 ID x EI /Sh sh").ops; + let at = |name: &str, n: usize| ops.iter().enumerate().filter(|(_, o)| o.is(name)).nth(n).map(|(i, _)| i).unwrap(); + let after_bi = at("BI", 0) + 1; + assert!(revealed_under_paint(true, &ops, at("BT", 0))); + assert!(revealed_under_paint(true, &ops, at("BT", 1)), "inline image and shading after it"); + assert!(revealed_under_paint(true, &ops, after_bi), "a shading after it"); + assert!(!revealed_under_paint(true, &ops, ops.len()), "nothing after it"); + assert!(!revealed_under_paint(true, &ops, ops.len() + 5), "past the end"); + assert!(!revealed_under_paint(false, &ops, 0), "visible text stays where it is"); + let text_only = pdfcraft_content::parse(b"BT (a) Tj ET 0 0 10 10 re f").ops; + assert!(!revealed_under_paint(true, &text_only, 0), "a filled path does not move it"); +} + /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep /// for that is refused before either rewrite path changes anything. #[test] From 3c11186dbd9d919aefedb3f74f6f808570dd353a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 01:22:41 -0400 Subject: [PATCH 027/562] fix(edit): move revealed OCR text on top only when an XObject covers it An unrelated image later on the page no longer moves the paragraph and its cover on top, where the cover could hide visible content inside the paragraph's box. An XObject now counts only when it is drawn at or after the paragraph and its visible box overlaps the paragraph's (`images::painted_over`, from `page_images`). An inline image or a shading after the paragraph still counts anywhere, and so does any XObject when the page's images cannot be listed. The trigger table now covers an image elsewhere, an image drawn first, visible text, a filled path, a shading and an inline image. It fails with the old any-`Do` trigger. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/images.rs | 6 ++++++ crates/edit/src/place.rs | 16 +++++++++----- crates/edit/src/tests.rs | 29 +++++++++++++++----------- crates/edit/src/text/rewrite.rs | 2 +- crates/edit/src/text_regions/styled.rs | 2 +- parity/acrobat-features.toml | 2 +- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5180e76018..7322b8f9d6 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -536,6 +536,12 @@ pub(crate) fn selectable_images( .collect()) } +/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows +/// inside `rect` (user space). +pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result { + Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some())) +} + /// Find the saved graphics-state scope containing just this image and its crop. Applying /// the transform before that scope moves the actual clipping path as well as the pixels. /// A shared clip must never be moved along with unrelated artwork or text. diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index f1acb6bdca..e040acf1f3 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -360,11 +360,17 @@ pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) } -/// Whether a rewritten paragraph that was `invisible` goes on top of the page: anything after -/// operator `at` can paint over it there (an XObject, an inline image or a shading), as the scan -/// image does over an OCR layer written before it (#3973). -pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op], at: usize) -> bool { - invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) +/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after +/// operator `at` of the page's joined `ops` paints over its `rect` (user space), as the scan image +/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline +/// image or a shading after it counts anywhere, and so does any XObject when the page's images +/// cannot be listed. +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, rect: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { + let later = ops.get(at..).unwrap_or_default(); + if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { + return invisible; + } + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, rect, at).unwrap_or(true) } /// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a51612106b..4349ce7ad6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,20 +4550,25 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } -/// Only invisible text with an XObject, inline image or shading painted after its place moves on top. +/// Invisible text moves on top only when something painted after it covers it: an XObject drawn +/// over its box, or any inline image or shading. #[test] fn revealed_text_moves_on_top_only_when_painted_over() { - let ops = pdfcraft_content::parse(b"BT (a) Tj ET q /Im Do Q BT (b) Tj ET BI /W 1 ID x EI /Sh sh").ops; - let at = |name: &str, n: usize| ops.iter().enumerate().filter(|(_, o)| o.is(name)).nth(n).map(|(i, _)| i).unwrap(); - let after_bi = at("BI", 0) + 1; - assert!(revealed_under_paint(true, &ops, at("BT", 0))); - assert!(revealed_under_paint(true, &ops, at("BT", 1)), "inline image and shading after it"); - assert!(revealed_under_paint(true, &ops, after_bi), "a shading after it"); - assert!(!revealed_under_paint(true, &ops, ops.len()), "nothing after it"); - assert!(!revealed_under_paint(true, &ops, ops.len() + 5), "past the end"); - assert!(!revealed_under_paint(false, &ops, 0), "visible text stays where it is"); - let text_only = pdfcraft_content::parse(b"BT (a) Tj ET 0 0 10 10 re f").ops; - assert!(!revealed_under_paint(true, &text_only, 0), "a filled path does not move it"); + let on_top = |content: &str| { + let doc = image_page_with(content); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) + }; + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); + assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); + assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first"); + assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is"); + assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); } /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 42db5c4af3..768693e70a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -873,7 +873,7 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(b.invisible, &ops, at); + let over = crate::revealed_under_paint(doc, page, b.invisible, b.rect, &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; let plan = BlockPlan { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 8ff4b0b62e..6ea69b4dc8 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -787,7 +787,7 @@ pub(crate) fn rewrite_styled_block( let at = if split { start } else { o.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(block.invisible, &ops, at); + let over = crate::revealed_under_paint(doc, page, block.invisible, block.rect, &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 68716cf5fd..5b5927d91d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6629,7 +6629,7 @@ status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image, inline image or shading is painted after the OCR layer (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn over the OCR layer's box after it, or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 705d862328fb40e2aeda77874d3e41d420fed4b6 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 02:08:08 -0400 Subject: [PATCH 028/562] fix(edit): check revealed OCR text where the rewrite lands The on-top trigger compared later paint with the paragraph's old box. A rewrite that is moved (BlockStyle::offset), rewrapped wider or grown to more lines can land under an image the old box is clear of, and stayed underneath it. Both rewrite paths now test a Reach: the old box (where the cover goes) plus the moved box grown to the wrap width either way and down to the drawn line count (the widest measured line when wider), and up by one line pitch for a larger size; turned text grows by the lines' diagonal; any non-finite number makes it unbounded. Over-reaching is harmless: the cover still fills only the old box. The parity notes say so. Tests: an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands (move, width and line growth, both paths) and a_rewrites_reach_covers_its_move_ width_and_lines. Seen failing with each path reverted to the old box and with growth zeroed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/lib.rs | 2 +- crates/edit/src/place.rs | 48 ++++++++++++++++++++++++-- crates/edit/src/tests.rs | 40 +++++++++++++++++++++ crates/edit/src/text/rewrite.rs | 4 ++- crates/edit/src/text_regions/styled.rs | 15 +++++--- parity/acrobat-features.toml | 2 +- 6 files changed, 100 insertions(+), 11 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 1ca43d20a8..059e94d927 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -68,7 +68,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; +use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index e040acf1f3..0d2f138a41 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -360,17 +360,59 @@ pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) } +/// Where a rewritten paragraph can be drawn, in user space ([`revealed_under_paint`]). +pub(crate) struct Reach { + /// The paragraph's old box, where its cover goes. + pub rect: [f64; 4], + /// The move asked for (`BlockStyle::offset`). + pub offset: [f64; 2], + /// The widest line the rewrite may draw, how many lines it has, and how far apart they are. + pub width: f64, + pub rows: usize, + pub pitch: f64, + pub upright: bool, +} + +impl Reach { + /// The old box and the moved one, grown to `width` either way (alignment may grow a line left + /// or right), down to `rows` lines and up to one `pitch` above its bottom (a larger size); turned text grows by the lines' diagonal every way. + /// Unbounded when a number is not finite. + pub(crate) fn bounds(&self) -> [f64; 4] { + const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY]; + let ([dx, dy], r) = (self.offset, self.rect); + if !r.iter().chain(&self.offset).chain([&self.width, &self.pitch]).all(|v| v.is_finite()) { + return UNBOUNDED; + } + let moved = [r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]; + let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX)); + let (width, height) = (self.width.abs(), self.pitch.abs() * rows); + let grown = if self.upright { + [ + moved[0].min(moved[2] - width), + moved[1].min(moved[3] - height), + moved[2].max(moved[0] + width), + moved[3].max(moved[1] + self.pitch.abs()), + ] + } else { + let d = width.hypot(height); + [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d] + }; + let b = [r[0].min(grown[0]), r[1].min(grown[1]), r[2].max(grown[2]), r[3].max(grown[3])]; + if b.iter().all(|v| v.is_finite()) { b } else { UNBOUNDED } + } +} + /// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after -/// operator `at` of the page's joined `ops` paints over its `rect` (user space), as the scan image +/// operator `at` of the page's joined `ops` paints over `reach` (user space, [`Reach::bounds`]), as the scan image /// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline /// image or a shading after it counts anywhere, and so does any XObject when the page's images /// cannot be listed. -pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, rect: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, reach: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { let later = ops.get(at..).unwrap_or_default(); if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { return invisible; } - later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, rect, at).unwrap_or(true) + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, reach, at).unwrap_or(true) } /// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4349ce7ad6..95119c802e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4571,6 +4571,46 @@ fn revealed_text_moves_on_top_only_when_painted_over() { assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); } +/// Revealed OCR text is checked where the rewrite lands, not only where it was: an image drawn +/// after it over just the moved or grown text still lifts the text on top, on both rewrite paths. +#[test] +fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; + let cases = [ + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), + ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there, this line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()), + ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()), + ]; + for (content, edit, style) in cases { + let mut doc = image_page_with(&format!("{content} {scan}")); + let before = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + assert!(!revealed_under_paint(&doc, 0, true, before.rect, &ops, at), "{edit:?}: the old box alone is clear of the scan"); + text::rewrite_block(&mut doc, 0, 0, Some(edit), style).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let scan_at = ops.iter().position(|o| o.is("Do")).unwrap(); + let shows = ["Tj", "TJ", "'", "\""]; + assert!(ops.iter().skip(scan_at).any(|o| shows.iter().any(|s| o.is(s))), "{edit:?}: the rewrite is drawn over the scan"); + } +} + +/// [`crate::Reach`] grows the old box to the move, the width either way and the lines below. +#[test] +fn a_rewrites_reach_covers_its_move_width_and_lines() { + let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds(); + assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 100.0]); + assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]); + assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY); + let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true }; + assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 114.0], "a larger size grows the line up as well as down"); +} + /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep /// for that is refused before either rewrite path changes anything. #[test] diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 768693e70a..e08d54ca4e 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -873,7 +873,9 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(doc, page, b.invisible, b.rect, &ops, at); + let widest = wrapped.iter().map(|l| advance.of(l)).fold(width, f64::max); + let reach = crate::Reach { rect: b.rect, offset: [dx, dy], width: widest, rows: wrapped.len(), pitch: lead * k, upright }; + let over = crate::revealed_under_paint(doc, page, b.invisible, reach.bounds(), &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; let plan = BlockPlan { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 6ea69b4dc8..2b19a00108 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -785,11 +785,6 @@ pub(crate) fn rewrite_styled_block( // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; let at = if split { start } else { o.bt_op }; - // Revealed OCR text that something is painted over goes on top of the page, where it starts - // from the initial state. - let over = crate::revealed_under_paint(doc, page, block.invisible, block.rect, &ops, at); - let initial = TextState::initial(); - let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -815,6 +810,16 @@ pub(crate) fn rewrite_styled_block( }) .rfind(|v| *v > 0.0) .unwrap_or(first.size * 1.5); + // Revealed OCR text that something is painted over goes on top of the page, where it starts + // from the initial state. + let largest = style.ranges.iter().filter_map(|r| r.size).chain(style.size).fold(first.size, f64::max); + let pitch = source_leading.max(largest * style.line_spacing.unwrap_or(1.2)); + let widest = measured.iter().copied().fold(width, f64::max); + // This path lays rows out along user-space baselines, so the paragraph is upright here. + let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; + let over = crate::revealed_under_paint(doc, page, block.invisible, reach.bounds(), &ops, at); + let initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; let mut baseline = first.origin.baseline; for (row_index, row) in rows.iter().enumerate() { let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5b5927d91d..ef7f0f53fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6629,7 +6629,7 @@ status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn over the OCR layer's box after it, or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample" From 8fd151ff79ec2f8b35449bf6f1c24dc32a644095 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 02:49:28 -0400 Subject: [PATCH 029/562] fix(edit): reach the styled rewrite's rows at the step they are written The styled path writes rows `line_spacing` (1.5 by default) times the size apart when a size or spacing is set, but its Reach assumed 1.2, so an image over only the lower rows of a larger rewrite was missed. The pitch now comes from the row writer's own step (row_step, shared by both), at the largest size among the paragraph's lines and ranges. The landing test gains a six-row rewrite at twice the size over an image covering only its last row; it fails with the 1.2 step. A case with only line_spacing set covers the other branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 9 ++++++++- crates/edit/src/text_regions/styled.rs | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 95119c802e..e728a90393 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4577,6 +4577,8 @@ fn revealed_text_moves_on_top_only_when_painted_over() { fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; + let sized = text::BlockStyle { size: Some(24.0), ..Default::default() }; + let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() }; let cases = [ ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), @@ -4584,7 +4586,12 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { ("BT /F1 12 Tf 3 Tr 40 150 Td (Hi) Tj ET", "Hello there,\nthis line now runs well past the scan's left edge", &text::BlockStyle::default()), ("BT /F1 12 Tf 3 Tr 110 230 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &text::BlockStyle::default()), ]; - for (content, edit, style) in cases { + let low = "q 200 0 0 30 100 300 cm /Im0 Do Q"; + let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([ + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low), + ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low), + ]); + for (content, edit, style, scan) in cases { let mut doc = image_page_with(&format!("{content} {scan}")); let before = text::text_blocks(&doc, 0).unwrap().remove(0); let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 2b19a00108..a606952233 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -402,6 +402,16 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) } +/// How far below a row of `size` the next one is written: the source's leading unless a size +/// or line spacing is chosen. +fn row_step(style: &BlockStyle, size: f64, source_leading: f64) -> f64 { + if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(size) + } +} + fn wrap_styled_rows( chars: &[StyledChar], width: f64, @@ -812,8 +822,9 @@ pub(crate) fn rewrite_styled_block( .unwrap_or(first.size * 1.5); // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let largest = style.ranges.iter().filter_map(|r| r.size).chain(style.size).fold(first.size, f64::max); - let pitch = source_leading.max(largest * style.line_spacing.unwrap_or(1.2)); + let largest = + block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); + let pitch = source_leading.max(row_step(style, largest, source_leading)); let widest = measured.iter().copied().fold(width, f64::max); // This path lays rows out along user-space baselines, so the paragraph is upright here. let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; @@ -972,11 +983,7 @@ pub(crate) fn rewrite_styled_block( x += segment_width; pos = end; } - baseline -= if style.line_spacing.is_none() && style.size.is_none() { - source_leading - } else { - style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) - }; + baseline -= row_step(style, anchor.size, source_leading); } block_ops.push(Op::new("Q", vec![])); let on_top = crate::lift_on_top(over, &mut block_ops); From 8e5f423d6d777380db7807a4e1c9dafb098368c9 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 03:31:03 -0400 Subject: [PATCH 030/562] fix(edit): reach as high as a larger range's glyphs With only a range enlarged, row_step is the source leading, so the styled rewrite's reach rose one source line above its old box while a 120-point range draws far higher, and an image there was missed. The pitch is now at least 1.2 times the largest size. The landing test gains a 120-point range on a 12-point OCR line under an image above the old box; it fails without the floor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/tests.rs | 13 +++++++++++++ crates/edit/src/text_regions/styled.rs | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e728a90393..dead2dcef0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4579,6 +4579,18 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { let moved = text::BlockStyle { offset: Some([-290.0, -450.0]), ..Default::default() }; let sized = text::BlockStyle { size: Some(24.0), ..Default::default() }; let spaced = text::BlockStyle { line_spacing: Some(3.0), ..Default::default() }; + let range = text::RangeStyle { + range: 0..2, + family: None, + resource: None, + size: Some(120.0), + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let enlarged = text::BlockStyle { ranges: vec![range], ..Default::default() }; + let above = "q 200 0 0 60 100 200 cm /Im0 Do Q"; let cases = [ ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved", &moved), ("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET", "Moved\nTwice", &moved), @@ -4590,6 +4602,7 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { let cases = cases.into_iter().map(|(c, e, s)| (c, e, s, scan)).chain([ ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &sized, low), ("BT /F1 12 Tf 3 Tr 110 500 Td (Above) Tj ET", "One\nTwo\nThree\nFour\nFive\nSix", &spaced, low), + ("BT /F1 12 Tf 3 Tr 110 150 Td (Hi there) Tj ET", "Hi there", &enlarged, above), ]); for (content, edit, style, scan) in cases { let mut doc = image_page_with(&format!("{content} {scan}")); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index a606952233..7fe7a6a066 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -824,7 +824,8 @@ pub(crate) fn rewrite_styled_block( // from the initial state. let largest = block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); - let pitch = source_leading.max(row_step(style, largest, source_leading)); + // At least one line of the largest glyphs, so a larger range reaches up as far as it draws. + let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * 1.2); let widest = measured.iter().copied().fold(width, f64::max); // This path lays rows out along user-space baselines, so the paragraph is upright here. let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; From ffa08fa00d664898222ba63fd04ffc45703fe634 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 03:37:21 -0400 Subject: [PATCH 031/562] fix(edit): reach one line above the paragraph's top (#3973) A larger range on the first line of a multi-line paragraph rises from the first line's baseline, not from the old box's bottom, so the reach now extends one pitch above the moved box's top. Name the 1.2 em line floor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/place.rs | 9 ++------- crates/edit/src/tests.rs | 6 +++--- crates/edit/src/text_regions/styled.rs | 3 ++- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 0d2f138a41..b55e71a6ff 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -375,7 +375,7 @@ pub(crate) struct Reach { impl Reach { /// The old box and the moved one, grown to `width` either way (alignment may grow a line left - /// or right), down to `rows` lines and up to one `pitch` above its bottom (a larger size); turned text grows by the lines' diagonal every way. + /// or right), down to `rows` lines and up one `pitch` above its top (a larger size on the first line, however many lines the old box had); turned text grows by the lines' diagonal every way. /// Unbounded when a number is not finite. pub(crate) fn bounds(&self) -> [f64; 4] { const UNBOUNDED: [f64; 4] = [f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY]; @@ -387,12 +387,7 @@ impl Reach { let rows = f64::from(u32::try_from(self.rows).unwrap_or(u32::MAX)); let (width, height) = (self.width.abs(), self.pitch.abs() * rows); let grown = if self.upright { - [ - moved[0].min(moved[2] - width), - moved[1].min(moved[3] - height), - moved[2].max(moved[0] + width), - moved[3].max(moved[1] + self.pitch.abs()), - ] + [moved[0].min(moved[2] - width), moved[1].min(moved[3] - height), moved[2].max(moved[0] + width), moved[3] + self.pitch.abs()] } else { let d = width.hypot(height); [moved[0] - d, moved[1] - d, moved[2] + d, moved[3] + d] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index dead2dcef0..7ee78e882b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4622,13 +4622,13 @@ fn an_ocr_rewrite_goes_on_top_of_paint_over_where_it_lands() { #[test] fn a_rewrites_reach_covers_its_move_width_and_lines() { let reach = |offset, width, rows, upright| crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset, width, rows, pitch: 10.0, upright }.bounds(); - assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 20.0, 1, true), [10.0, 90.0, 30.0, 110.0]); assert_eq!(reach([5.0, -50.0], 20.0, 1, true), [10.0, 40.0, 35.0, 100.0]); - assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 100.0]); + assert_eq!(reach([0.0, 0.0], 50.0, 3, true), [-20.0, 70.0, 60.0, 110.0]); assert_eq!(reach([0.0, 0.0], 30.0, 4, false), [-40.0, 40.0, 80.0, 150.0]); assert_eq!(reach([f64::NAN, 0.0], 20.0, 1, true)[0], f64::NEG_INFINITY); let larger = crate::Reach { rect: [10.0, 90.0, 30.0, 100.0], offset: [0.0, 0.0], width: 20.0, rows: 1, pitch: 24.0, upright: true }; - assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 114.0], "a larger size grows the line up as well as down"); + assert_eq!(larger.bounds(), [10.0, 76.0, 30.0, 124.0], "a larger size grows the line up as well as down"); } /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 7fe7a6a066..943bece7af 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -825,7 +825,8 @@ pub(crate) fn rewrite_styled_block( let largest = block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size).chain(style.ranges.iter().filter_map(|r| r.size)).fold(first.size, f64::max); // At least one line of the largest glyphs, so a larger range reaches up as far as it draws. - let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * 1.2); + const LINE_EM: f64 = 1.2; + let pitch = source_leading.max(row_step(style, largest, source_leading)).max(largest * LINE_EM); let widest = measured.iter().copied().fold(width, f64::max); // This path lays rows out along user-space baselines, so the paragraph is upright here. let reach = crate::Reach { rect: block.rect, offset: [dx, dy], width: widest, rows: rows.len(), pitch, upright: true }; From f84290b294a6f40b11b90c683c4226f3d73aa346 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 08:23:56 -0400 Subject: [PATCH 032/562] perf(render): undo PNG predictors a byte row at a time (#5430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored hayro-syntax ran PNG predictors through the bit-level BitChunks/BitWriter machinery TIFF predictors need, although PNG filters are defined on bytes (ISO 32000-2 §7.4.4.4, PNG specification §9). A 993 x 936 RGB image took about 63 ms to unfilter whatever its row filters; undone on byte slices it takes about 3 ms (Up) to 16 ms (Paeth) in a release build. It also fixes a row that ends part-way through a pixel (three 4-bit samples at an odd width): the bit-level path dropped its last byte, each later row started a byte early and the picture came out sheared. TIFF predictor 2 keeps the old path. Vendored hayro-syntax patch 29. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/render/src/raster/tests.rs | 3 + .../src/raster/tests/png_predicted_images.rs | 69 ++++ vendor/README.md | 2 +- vendor/hayro-syntax/src/filter/lzw_flate.rs | 336 ++++++++++++------ 4 files changed, 308 insertions(+), 102 deletions(-) create mode 100644 crates/render/src/raster/tests/png_predicted_images.rs diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index a4e4404e6b..68b0c0cb08 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -7004,6 +7004,9 @@ mod default_colour_spaces; // (vendored hayro-syntax patch 28, hayro-interpret patch 55). mod devicen_inks; +// #5430: PNG-predicted Flate images are undone byte by byte (vendored hayro-syntax patch 29). +mod png_predicted_images; + // #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro // patch 27). mod isolated_cmyk_groups; diff --git a/crates/render/src/raster/tests/png_predicted_images.rs b/crates/render/src/raster/tests/png_predicted_images.rs new file mode 100644 index 0000000000..14675adab0 --- /dev/null +++ b/crates/render/src/raster/tests/png_predicted_images.rs @@ -0,0 +1,69 @@ +//! #5430: Flate images with PNG predictors (ISO 32000-2 §7.4.4.4) are undone a byte row at a +//! time (vendored hayro-syntax patch 29). The bit-level decoder they went through before dropped +//! the last byte of each filtered row that ends part-way through a pixel, so every later row +//! started a byte early and the picture came out sheared. + +use super::rgb_at; +use crate::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use flate2::{Compression, write::ZlibEncoder}; +use std::io::Write; +use std::sync::Arc; + +/// The page is 150 × 200 pt, drawn at 72 dpi; the image fills it. +const WIDTH: usize = 150; +const HEIGHT: usize = 200; + +/// The colour of each of the image's four rows, top first, as 4-bit samples. +const ROWS: [[u8; 3]; 4] = [[15, 0, 0], [0, 15, 0], [0, 0, 15], [15, 15, 0]]; + +#[test] +fn a_four_bit_rgb_image_of_odd_width_with_up_predicted_rows_is_not_sheared() { + // Three 4-bit RGB pixels are 4.5 bytes, so each row is five bytes and ends half-way through a + // two-byte pixel. + let rows: Vec> = ROWS + .iter() + .map(|rgb| { + let nibbles: Vec = (0..3).flat_map(|_| rgb.iter().copied()).chain([0]).collect(); + nibbles.chunks(2).map(|pair| (pair[0] << 4) | pair[1]).collect() + }) + .collect(); + let mut predicted = Vec::new(); + let mut above = vec![0u8; 5]; + for row in &rows { + predicted.push(2); + predicted.extend(row.iter().zip(&above).map(|(b, a)| b.wrapping_sub(*a))); + above.clone_from(row); + } + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(&predicted).unwrap(); + let data = encoder.finish().unwrap(); + + let content = "q 150 0 0 200 0 0 cm /Im0 Do Q"; + let mut pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 150 200] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 3 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 4 \ + /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /Length {} >> stream\n", + content.len(), + data.len() + ) + .into_bytes(); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (WIDTH as u32, HEIGHT as u32)); + + // Each image pixel is 50 pt square; look at the middle of each. + for (row, rgb) in ROWS.iter().enumerate() { + let expected = rgb.map(|sample| sample * 17); + for column in 0..3 { + let (x, y) = (column * 50 + 25, row * 50 + 25); + assert_eq!(rgb_at(&page.rgba, WIDTH, x, y), expected, "image row {row}, column {column}"); + } + } +} diff --git a/vendor/README.md b/vendor/README.md index 84b2546bf3..dfba101330 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,7 +43,7 @@ Other resource categories retain their existing parent-chain lookup. | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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) | (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` | +| 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) PNG predictors (`lzw_flate::unfilter_png`) are undone a byte row at a time on slices, as the PNG filters are defined, instead of through the bit-level readers TIFF predictors need: a 993 × 936 RGB image took about 63 ms to unfilter whatever its row filters, and now takes about 3 ms (Up) to 16 ms (Paeth) in a release build (#5430); a row ending part-way through a pixel (three 4-bit samples, an odd width) keeps its last byte, where the bit-level readers dropped it and each later row started a byte early, shearing the picture | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `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` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs index f1ab1aae73..5a0027597b 100644 --- a/vendor/hayro-syntax/src/filter/lzw_flate.rs +++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs @@ -1008,15 +1008,16 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { return None; } - let (bit_size, chunk_len) = if is_png_predictor { - ( - 8, - // PdfCraft patch: in usize (the u8 product overflowed). - (usize::from(params.colors) * usize::from(params.bits_per_component)).div_ceil(8), - ) - } else { - (params.bits_per_component, params.colors as usize) - }; + if is_png_predictor { + // PdfCraft patch: PNG predictors work on bytes whatever the bit depth, so they are + // undone a row at a time on byte slices instead of through the bit-level readers. + // In usize (the u8 product overflowed). + let bpp = (usize::from(params.colors) * usize::from(params.bits_per_component)) + .div_ceil(8); + return Some(unfilter_png(&data, row_len, bpp)); + } + + let (bit_size, chunk_len) = (params.bits_per_component, params.colors as usize); let zero_row = vec![0; row_len]; let mut prev_row = BitChunks::new(&zero_row, bit_size, chunk_len)?; let zero_col = BitChunk::new(0, chunk_len); @@ -1024,58 +1025,7 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { let mut writer = BitWriter::new(&mut out, bit_size)?; for in_row in data.chunks_exact(total_row_len) { - if is_png_predictor { - let predictor = in_row[0]; - let in_data = &in_row[1..]; - let in_data_chunks = BitChunks::new(in_data, bit_size, chunk_len)?; - - match predictor { - 1 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - 2 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - 3 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - 4 => apply::( - prev_row, - zero_col.clone(), - zero_col.clone(), - in_data_chunks, - &mut writer, - chunk_len, - bit_size, - )?, - _ => { - // Just copy the data. - let mut reader = BitReader::new(in_data); - - while let Some(data) = reader.read(bit_size) { - writer.write(data); - } - } - } - } else if i == 2 { + if i == 2 { apply::( prev_row, zero_col.clone(), @@ -1104,6 +1054,98 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> { } } +/// PdfCraft patch: undoes PNG row filters (ISO 32000-2 §7.4.4.4, PNG specification §9) in +/// rows of `row_len` bytes, each after its filter-type byte, with `bpp` bytes per complete pixel +/// (at least 1). An unknown filter type copies the row as it is. Bytes after the last complete +/// row are dropped. +fn unfilter_png(data: &[u8], row_len: usize, bpp: usize) -> Vec { + let bpp = bpp.max(1); + let in_row_len = row_len.saturating_add(1); + let num_rows = data.len() / in_row_len; + let mut out = vec![0; num_rows.saturating_mul(row_len)]; + let zero_row = vec![0; row_len]; + let mut prev: &[u8] = &zero_row; + + for (cur, in_row) in out + .chunks_exact_mut(row_len) + .zip(data.chunks_exact(in_row_len)) + { + let Some((&filter, src)) = in_row.split_first() else { + break; + }; + + match filter { + 1 => { + let mut left: &[u8] = &zero_row; + for (px, src) in cur.chunks_mut(bpp).zip(src.chunks(bpp)) { + for ((c, s), l) in px.iter_mut().zip(src).zip(left) { + *c = s.wrapping_add(*l); + } + left = px; + } + } + 2 => { + for ((c, s), u) in cur.iter_mut().zip(src).zip(prev) { + *c = s.wrapping_add(*u); + } + } + 3 => { + let mut left: &[u8] = &zero_row; + for ((px, src), up) in cur + .chunks_mut(bpp) + .zip(src.chunks(bpp)) + .zip(prev.chunks(bpp)) + { + for (((c, s), l), u) in px.iter_mut().zip(src).zip(left).zip(up) { + // The sum is taken before halving, without wrapping. + let avg = (u16::from(*l) + u16::from(*u)) / 2; + *c = s.wrapping_add(avg as u8); + } + left = px; + } + } + 4 => { + let mut left: &[u8] = &zero_row; + let mut up_left: &[u8] = &zero_row; + for ((px, src), up) in cur + .chunks_mut(bpp) + .zip(src.chunks(bpp)) + .zip(prev.chunks(bpp)) + { + for ((((c, s), l), u), ul) in + px.iter_mut().zip(src).zip(left).zip(up).zip(up_left) + { + *c = s.wrapping_add(paeth_byte(*l, *u, *ul)); + } + left = px; + up_left = up; + } + } + _ => { + for (c, s) in cur.iter_mut().zip(src) { + *c = *s; + } + } + } + + prev = cur; + } + + out +} + +/// The Paeth predictor on bytes: the neighbour nearest `left + up - up_left`, preferring left, +/// then up. +fn paeth_byte(left: u8, up: u8, up_left: u8) -> u8 { + let (a, b, c) = (i16::from(left), i16::from(up), i16::from(up_left)); + let pa = (b - c).abs(); + let pb = (a - c).abs(); + let pc = (a + b - c - c).abs(); + + let not_left = if pb <= pc { up } else { up_left }; + if pa <= pb && pa <= pc { left } else { not_left } +} + fn apply<'a, T: Predictor>( prev_row: BitChunks<'a>, mut prev_col: BitChunk, @@ -1153,46 +1195,6 @@ impl Predictor for Sub { } } -struct Up; -impl Predictor for Up { - fn predict(cur_row: u16, prev_row: u16, _: u16, _: u16) -> u16 { - cur_row.wrapping_add(prev_row) - } -} - -struct Avg; -impl Predictor for Avg { - fn predict(cur_row: u16, prev_row: u16, prev_col: u16, _: u16) -> u16 { - cur_row.wrapping_add(((prev_col as u32 + prev_row as u32) / 2) as u16) - } -} - -struct Paeth; -impl Predictor for Paeth { - fn predict(cur_row: u16, prev_row: u16, prev_col: u16, top_left: u16) -> u16 { - fn paeth(a: u16, b: u16, c: u16) -> u16 { - let a = a as i32; - let b = b as i32; - let c = c as i32; - - let p = a + b - c; - let pa = (p - a).abs(); - let pb = (p - b).abs(); - let pc = (p - c).abs(); - - if pa <= pb && pa <= pc { - a as u16 - } else if pb <= pc { - b as u16 - } else { - c as u16 - } - } - - cur_row.wrapping_add(paeth(prev_col, prev_row, top_left)) - } -} - #[cfg(test)] #[rustfmt::skip] mod tests { @@ -1357,3 +1359,135 @@ mod tests { ); } } + +/// PdfCraft patch tests: PNG predictors are undone byte by byte (`unfilter_png`). +#[cfg(test)] +mod pdfcraft_png_predictor_tests { + use crate::filter::lzw_flate::{PredictorParams, apply_predictor}; + + /// PNG unfiltering written out from the PNG specification (§9.2–9.4), one byte at a time. + fn reference(data: &[u8], row_len: usize, bpp: usize) -> Vec { + let mut out: Vec = Vec::new(); + for (r, row) in data.chunks_exact(row_len + 1).enumerate() { + let start = r * row_len; + for x in 0..row_len { + let left = if x >= bpp { out[start + x - bpp] } else { 0 }; + let up = if r > 0 { out[start + x - row_len] } else { 0 }; + let up_left = if r > 0 && x >= bpp { + out[start + x - row_len - bpp] + } else { + 0 + }; + let predicted = match row[0] { + 1 => left, + 2 => up, + 3 => ((u16::from(left) + u16::from(up)) / 2) as u8, + 4 => { + let p = i16::from(left) + i16::from(up) - i16::from(up_left); + let (pa, pb, pc) = ( + (p - i16::from(left)).abs(), + (p - i16::from(up)).abs(), + (p - i16::from(up_left)).abs(), + ); + if pa <= pb && pa <= pc { + left + } else if pb <= pc { + up + } else { + up_left + } + } + _ => 0, + }; + out.push(row[1 + x].wrapping_add(predicted)); + } + } + out + } + + #[test] + fn png_predictors_match_the_specification_at_every_depth_and_pixel_size() { + let mut seed: u32 = 0x2545_f491; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed + }; + for case in 0..400 { + let colors = 1 + (next() % 4) as u8; + let bits_per_component = [1u8, 2, 4, 8, 16][(next() % 5) as usize]; + let columns = 1 + (next() % 40) as usize; + let bpp = (usize::from(colors) * usize::from(bits_per_component)).div_ceil(8); + let row_len = + (columns * usize::from(colors) * usize::from(bits_per_component)).div_ceil(8); + let rows = 1 + (next() % 6) as usize; + let mut data = Vec::new(); + for _ in 0..rows { + // Filter types 0–4, and 5–7, which are not PNG filters and copy the row. + data.push((next() % 8) as u8); + data.extend((0..row_len).map(|_| next() as u8)); + } + // A partial row at the end is not decoded. + let tail = (next() % 3) as usize; + data.extend((0..tail.min(row_len)).map(|_| next() as u8)); + + let params = PredictorParams { + predictor: 10 + (next() % 6) as u8, + colors, + bits_per_component, + columns, + early_change: false, + }; + let got = apply_predictor(data.clone(), ¶ms).unwrap(); + assert_eq!( + got, + reference(&data, row_len, bpp), + "case {case}: {colors} colours at {bits_per_component} bits, {columns} columns" + ); + } + } + + #[test] + fn a_row_ending_part_way_through_a_pixel_keeps_its_last_byte() { + // Three 4-bit samples are a byte and a half, so a three-column row is five bytes and ends + // half-way through a two-byte pixel. Each row is filtered Up. + let data = [ + 2, 10, 11, 12, 13, 14, // + 2, 20, 21, 22, 23, 24, // + 2, 30, 31, 32, 33, 34, + ]; + let params = PredictorParams { + predictor: 15, + colors: 3, + bits_per_component: 4, + columns: 3, + early_change: false, + }; + assert_eq!( + apply_predictor(data.to_vec(), ¶ms).unwrap(), + [10, 11, 12, 13, 14, 30, 32, 34, 36, 38, 60, 63, 66, 69, 72] + ); + } + + #[test] + fn png_average_and_paeth_carry_the_left_pixel_across_a_wide_row() { + // Three 16-bit samples (6 bytes) per pixel: the left neighbour is six bytes back. + let row_len = 6 * 3; + let mut data = vec![3u8]; + data.extend((0..row_len).map(|i| (i * 37 + 200) as u8)); + data.push(4); + data.extend((0..row_len).map(|i| (255 - i * 11) as u8)); + let params = PredictorParams { + predictor: 15, + colors: 3, + bits_per_component: 16, + columns: 3, + early_change: false, + }; + assert_eq!( + apply_predictor(data.clone(), ¶ms).unwrap(), + reference(&data, row_len, 6) + ); + } +} From c0e3c6bec5c5d63296a60b6b1ace6d604f519e85 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 17:09:02 +0100 Subject: [PATCH 033/562] M11: the docs say which black-and-white images monochrome downsampling averages since #5426 The integration in #5426 made downsampling conservative: dark_is_one answers only for DeviceGray and image masks (a decode array of two different numbers or none), and not under /DefaultGray or /DefaultRGB, and a square of four new pixels that each cover both samples keeps the image at its resolution. The README, the crate docs, mono()'s comment and the optimize.downsample-images parity note still said separations and indexed images were averaged, a tie going to full ink or entry 0. Measured on main: a 600 ppi page of the ragged-text fixture is no longer averaged (it is with the square guard switched off), so the docs say so. Co-Authored-By: Claude Opus 5.5 --- crates/optimize/README.md | 19 ++++++++++++------- crates/optimize/src/images.rs | 5 ++++- crates/optimize/src/lib.rs | 3 ++- parity/acrobat-features.toml | 2 +- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 9f96b17218..4ac11e5a83 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -60,13 +60,18 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image - mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and - entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet). - Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, - most of whose new pixels would cover both black and white, so that taking the sample covering - more would turn its tones solid (a 50% screen black, a 25% one white); and not one wider than - a fax row (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each - column. On a 600 ppi text page, Group 4 at 300 ppi is half the size. + mask (a 0, or a 1 with an inverting decode array). Only images whose dark sample is known so + are averaged: `DeviceGray` (grey and RGB packed to one bit among them) and image masks, with no + decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or + `/DefaultRGB` is or may be in force. Not a separation, a DeviceN, calibrated or ICC grey, or an + indexed image, whose palette isn't read. Not one with a soft mask, whose own size would have to + follow; not a halftone or a dither, most of whose new pixels would cover both black and white, + so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% + one white), nor one with a square of four new pixels that each would, as a small screened + picture on a white page has: ragged scanned text has such squares along its strokes too, and + is then left at its resolution. And not one wider than a fax row + (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each column. On a + 600 ppi page of text that is averaged, Group 4 at 300 ppi is about half the size. - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 9338061978..36fb2f6b02 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1000,9 +1000,12 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. /// /// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, +/// if its dark sample is known ([`dark_is_one`]: grey or an image mask, not under `defaults`), /// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a /// dither: most of its new pixels would cover both black and white, and taking the sample that -/// covers more would turn its tones solid (a 50% screen black, a 25% one white). +/// covers more would turn its tones solid (a 50% screen black, a 25% one white). So would a +/// square of four new pixels that each cover both, as a small screened picture on a white page +/// has (and ragged scanned text, which is then left at its resolution too). /// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only /// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again /// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 76e30da7ac..6e8988fd1b 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -13,7 +13,8 @@ //! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, //! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images //! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a -//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit +//! target resolution when grey or image masks, halftones and dithers aside (see the README), and +//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit //! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..ed1f162bad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB, and indexed images that use only palette entries 0 and 1) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, ink in a separation, after the decode array; an indexed image's entry 0, its palette not read yet), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not monochrome images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey images and image masks (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations, DeviceN, calibrated or ICC grey, or indexed images (their dark sample isn't read), images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" From ea6c329731bb5f05cc984f8a68972eb3a8024a6a Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 17:13:38 +0100 Subject: [PATCH 034/562] M2.6: JBIG2 decoding in pdfcraft-filters; Document::decoded_image reads JBIG2 images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode_jbig2 sits next to decode_ccitt, through hayro-jbig2 as Export ▸ Images used it, and shares CCITT's row sink. The JBIG2 page has to be the image's size: PdfCraft's renderer draws the whole page into the image (black box: a 4-row page behind /Height 2 fills the image box with all four rows), so the rows of a taller page aren't the image's. The decoder decodes a page whole, so the page is checked before it is decoded: that size, within the caller's bound as rows of one bit, and at most 2^28 pixels (MAX_JBIG2_PIXELS; the decoder keeps a bit a pixel, about 34 MB there). Document::decoded_image_within decodes a JBIG2 image as it does a fax image: the filters before it undone first, then the decoder with the /JBIG2Globals of the image's own /DecodeParms entry (/Crypt counts in the position; fax_params and the globals now share last_filter_parms). Globals have a limit of their own, MAX_JBIG2_GLOBALS (32 MiB, moved from Export), and globals that can't be read are an error rather than ignored. Export ▸ Images reads JBIG2 through it, so its own decoder, row sink and DecodeParms reader go, and hayro-jbig2 leaves pdfcraft-create. JBIG2 behind another filter now exports, and so copies (Copy Image uses the same function); a page taller than the image, which Export cut to the image's rows, now skips the image, as one of another width did. doc_export_all_images' description no longer lists JBIG2 inside other filters as skipped. The optimizer's monochrome step read images through decoded_image_within and relied on JBIG2 failing there: it now leaves JBIG2 images alone explicitly, as before (a resampled one would come out Flate under Retain), and the README and parity note list them as not yet handled. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - crates/automation/src/tools.rs | 2 +- crates/cos/README.md | 6 +- crates/cos/src/document.rs | 2 +- crates/cos/src/document/objects.rs | 99 ++++++++++++------ crates/cos/src/document/tests.rs | 58 +++++++++++ crates/cos/src/lib.rs | 2 +- crates/cos/src/object.rs | 6 ++ crates/create/Cargo.toml | 2 - crates/create/README.md | 13 +-- crates/create/src/extract.rs | 115 ++------------------- crates/create/src/tests.rs | 66 ++++++++---- crates/filters/Cargo.toml | 7 +- crates/filters/README.md | 8 +- crates/filters/src/ccitt.rs | 28 ++++-- crates/filters/src/jbig2.rs | 5 +- crates/filters/src/jbig2/decode.rs | 155 +++++++++++++++++++++++++++++ crates/filters/src/lib.rs | 6 +- crates/optimize/README.md | 3 +- crates/optimize/src/images.rs | 3 + crates/optimize/tests/optimize.rs | 52 ++++++++++ parity/acrobat-features.toml | 14 +-- 22 files changed, 449 insertions(+), 204 deletions(-) create mode 100644 crates/filters/src/jbig2/decode.rs diff --git a/Cargo.lock b/Cargo.lock index cfcb9dc362..bf3be727f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4336,7 +4336,6 @@ version = "0.15.0" dependencies = [ "encoding_rs", "hayro-ccitt", - "hayro-jbig2", "hayro-syntax", "image", "pdfcraft-cos", diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c6c200cece..a816413a79 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1281,7 +1281,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ diff --git a/crates/cos/README.md b/crates/cos/README.md index 2cf3342954..8362012225 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -90,6 +90,6 @@ recognisable in a diff and byte-stable when re-serialized. - Linearized output, Fast Web View (`core.linearization`, M11). - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. -- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding - (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer - (`core.image-filters`, M2.6). +- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding + (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB); + DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0d9b84b749..c0bd656536 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, Weak}; use crate::CosError; -use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream}; +use crate::object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream}; use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; mod objects; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 4f05d03ae0..bec4a90717 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,9 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared}; +use super::{ + Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, +}; impl Document { pub fn bytes(&self) -> &Arc> { @@ -233,18 +235,7 @@ impl Document { if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) { return None; } - // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the - // last filter but a /Crypt, which `filters` leaves out wherever it stands. - let index = match s.dict.get(b"Filter") { - Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), - _ => 0, - }; - let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { - Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), - Some(p) if index == 0 => p.as_dict().cloned(), - _ => None, - } - .unwrap_or_default(); + let parms = self.last_filter_parms(s); let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default); let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() { Some(Object::Bool(b)) => *b, @@ -262,23 +253,62 @@ impl Document { }) } + /// The `/DecodeParms` dictionary of stream `s`'s last filter, resolved: that of a lone filter, + /// or its entry in an array. Empty when there is none. + fn last_filter_parms(&self, s: &Stream) -> Dict { + // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the + // last filter but a /Crypt, which `filters` leaves out wherever it stands. + let index = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), + _ => 0, + }; + match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { + Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), + Some(p) if index == 0 => p.as_dict().cloned(), + _ => None, + } + .unwrap_or_default() + } + /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a - /// CCITT fax image (its last filter), through the filters before it and then - /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode` - /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter - /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to - /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged - /// data gives fewer, so a caller checks it has the rows it needs. + /// CCITT fax or JBIG2 image (its last filter), through the filters before it and then + /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], or + /// [`pdfcraft_filters::decode_jbig2`] with its `/JBIG2Globals`, as `CCITTFaxDecode` and + /// `JBIG2Decode` give them (ISO 32000-2 §7.4.6, §7.4.7). Parameters given by reference count, + /// for every filter ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its + /// `/Width`, and a JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the + /// whole page into the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says, + /// within [`MAX_DECODED`] bytes; damaged fax data gives fewer, so a caller checks it has the + /// rows it needs. Damaged JBIG2 data, and globals that can't be read (within + /// [`MAX_JBIG2_GLOBALS`]), are an error. pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { self.decoded_image_within(s, MAX_DECODED) } /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than - /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than - /// `max` bytes. + /// [`MAX_DECODED`]: no stage, the filters before a fax or JBIG2 decoder included, may produce + /// more than `max` bytes. A JBIG2 image's `/JBIG2Globals` are another stream, shared between + /// images, and are decoded within [`MAX_JBIG2_GLOBALS`] whatever `max` is. pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); + let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); + let chain = s.readable_filters().map_err(filter)?; + // The filters before the image codec, such as ASCII85Decode, are undone first. + let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)]| match before { + [] => Ok(Cow::Borrowed(&s.raw[..])), + _ => pdfcraft_filters::decode_tolerant(before, &s.raw, max).map(|(data, _)| Cow::Owned(data)).map_err(filter), + }; + // `/Width` and `/Height` are read as given directly, as every caller reads them first: one + // given by reference has the image refused there, before it gets here. + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); + if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() { + let Some(columns) = s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()) else { + return Err(CosError::Filter("a JBIG2 image's /Width isn't a size".into())); + }; + let globals = self.jbig2_globals(s)?; + return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref(), columns, height, max).map_err(filter); + } let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and // rows past /Height aren't drawn. @@ -286,18 +316,25 @@ impl Document { if s.dict.int(b"Width") != Some(p.columns) { return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); } - let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); - // The filters before it, such as ASCII85Decode, are undone first. - let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; - let data = match chain.split_last() { - Some((_, before)) if !before.is_empty() => { - Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0) - } - _ => Cow::Borrowed(&s.raw[..]), - }; - pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string())) + let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?; + pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter) + } + + /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) + /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one. Globals that can't be read + /// are an error, rather than the image decoded without them. + fn jbig2_globals(&self, s: &Stream) -> Result>, CosError> { + let globals = self.last_filter_parms(s).get(b"JBIG2Globals").map(|g| self.resolve(g)); + let Some(Object::Stream(g)) = globals.as_deref() else { return Ok(None) }; + self.with_direct_parms(g).decoded_within(MAX_JBIG2_GLOBALS).map(Some).map_err(|e| { + let detail = match e { + CosError::Filter(detail) => detail, + e => e.to_string(), + }; + CosError::Filter(format!("the JBIG2 globals can't be read: {detail}")) + }) } pub fn try_get(&self, num: u32) -> Result, CosError> { diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index f5e36f9c83..7110fa6b72 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -173,6 +173,64 @@ fn fax_images_decode_with_their_parameters() { assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); } +/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of +/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference +/// too): globals that can't be read are an error, and ones at another filter's place aren't its +/// own, nor are those of a lone dictionary when other filters come first. Its page has to be its +/// /Width × /Height, and fit the bound. +#[test] +fn jbig2_images_decode_with_their_globals() { + // 10 × 3: the first five samples of each row white, the rest black. + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + let mut doc = Document::new_empty(); + // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips, Flate-encoded; and data + // that isn't JBIG2. + let globals = |doc: &mut Document, data: &[u8]| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(data)))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + Object::Dict(parms) + }; + let (good, bad) = (globals(&mut doc, &[0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]), globals(&mut doc, b"not JBIG2")); + let indirect = Object::Ref(doc.add(bad.clone())); + let image = |filters: &[&str], parms: Option, data: &[u8], (width, height): (i64, i64), max: usize| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect())); + if let Some(p) = parms { + d.set(b"DecodeParms".to_vec(), p); + } + doc.decoded_image_within(&Stream::from_raw(d, data.to_vec()), max) + }; + let jbig2 = ["JBIG2Decode"]; + let full = (10, 3); + assert_eq!(image(&jbig2, None, &coded, full, MAX_DECODED).unwrap(), bits); + assert_eq!(image(&jbig2, Some(good.clone()), &coded, full, MAX_DECODED).unwrap(), bits, "globals it skips"); + for parms in [bad.clone(), Object::Array(vec![bad.clone()]), indirect.clone(), Object::Array(vec![indirect])] { + let e = image(&jbig2, Some(parms.clone()), &coded, full, MAX_DECODED).unwrap_err(); + assert!(e.to_string().contains("JBIG2"), "{parms:?}: {e}"); + } + let behind = ["ASCIIHexDecode", "JBIG2Decode"]; + assert_eq!(image(&behind, None, &hex, full, MAX_DECODED).unwrap(), bits, "behind ASCIIHexDecode"); + assert!(image(&behind, Some(Object::Array(vec![Object::Null, bad.clone()])), &hex, full, MAX_DECODED).is_err(), "its own globals"); + assert_eq!(image(&behind, Some(Object::Array(vec![bad.clone(), Object::Null])), &hex, full, MAX_DECODED).unwrap(), bits, "another's"); + assert_eq!(image(&behind, Some(bad.clone()), &hex, full, MAX_DECODED).unwrap(), bits, "a lone dictionary, the first filter's"); + let after_crypt = ["Crypt", "JBIG2Decode"]; + assert!(image(&after_crypt, Some(Object::Array(vec![Object::Null, bad.clone()])), &coded, full, MAX_DECODED).is_err(), "after /Crypt"); + assert_eq!(image(&after_crypt, Some(Object::Array(vec![bad, Object::Null])), &coded, full, MAX_DECODED).unwrap(), bits, "/Crypt's"); + + for size in [(11, 3), (9, 3), (10, 2), (10, 4)] { + assert!(image(&jbig2, None, &coded, size, MAX_DECODED).is_err(), "a page other than {size:?}"); + } + assert_eq!(image(&jbig2, None, &coded, full, 6).unwrap(), bits, "the page within the bound"); + assert!(image(&jbig2, None, &coded, full, 5).is_err(), "the page past the bound"); +} + #[test] fn a_missing_file_id_is_derived_from_the_encryption_seed() { let mut doc = Document::new_empty(); diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index f3be66b9a1..a35902a031 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -25,7 +25,7 @@ mod writer; pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; -pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; +pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char, diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 17ee7844cf..400fc29962 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -213,6 +213,12 @@ pub struct Stream { /// Upper bound for a single decoded stream (decompression-bomb defence). pub const MAX_DECODED: usize = 1 << 30; +/// The most an image's `/JBIG2Globals` stream may decode to: the symbol and pattern dictionaries an +/// encoder shares between pages, themselves coded, so far less than any stream may decode to +/// ([`MAX_DECODED`]). They are decoded before the image's JBIG2 page is read, so the page's size +/// can't bound them: they have a limit of their own. +pub const MAX_JBIG2_GLOBALS: usize = 32 << 20; + impl Stream { /// A new stream from already-encoded bytes (`/Length` is set at write time). pub fn from_raw(dict: Dict, raw: Vec) -> Self { diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index b41caa2b88..51eca668d0 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -15,8 +15,6 @@ pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } # For `image::bmp_with_pixel_offset`, shared with XFA pictures. pdfcraft-xfa = { workspace = true } -# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images. -hayro-jbig2.workspace = true png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } diff --git a/crates/create/README.md b/crates/create/README.md index d359eed9ae..555d8b3aba 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -27,11 +27,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. -JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, -as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more -pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the -page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image -exported opaque, as JPX and CCITT masks are. +JBIG2 images and masks are decoded through `pdfcraft_cos::Document::decoded_image` (`pdfcraft_filters::decode_jbig2`), behind +other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image +whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), +or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a +limit of their own), is skipped; such a mask is left out and the image exported opaque, as JPX and CCITT +masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. @@ -43,4 +44,4 @@ CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::D `ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine` says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or, -at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported. +at `/K 0`, may come out wrong. JPEG 2000 images are still reported, not exported. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b12bd50ec2..22943fd11b 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -100,114 +100,14 @@ fn filters(s: &Stream) -> Vec> { } } -/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given -/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor -/// given so would otherwise be lost), or, for a JBIG2 image -/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples -/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). +/// The samples of image `s`, decoded ([`Document::decoded_image`]): through its filters, with a +/// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by +/// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image +/// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`. +/// A JBIG2 page has to be the image's size, which the caller checked against the export's limit, +/// before the page is decoded. fn decoded(doc: &Document, s: &Stream) -> Result, String> { - match filters(s).as_slice() { - [f] if f == b"JBIG2Decode" => jbig2(doc, s), - _ => doc.decoded_image(s).map_err(|e| e.to_string()), - } -} - -/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2). -fn decode_parms(doc: &Document, s: &Stream) -> Dict { - let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() }; - let p = match &*p { - Object::Array(a) => match a.first() { - Some(first) => doc.resolve(first), - None => return Dict::default(), - }, - _ => p, - }; - p.as_dict().cloned().unwrap_or_default() -} - -/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder -/// shares between pages, themselves coded, so far less than any stream may decode to -/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image. -const MAX_GLOBALS: usize = 32 << 20; - -/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller -/// checked against the export's size limit, and within that limit itself: the decoder decodes the -/// whole page, rows past the image's height too, so a small `/Height` over a far taller page -/// would cost more than the limit allows. Rows past the image's height are dropped, and too few -/// rows are refused by the caller. Globals that can't be read refuse the image, rather than -/// decoding it without them. They are decoded before the page's size is known, so the page's -/// limit can't bound them; they have [`MAX_GLOBALS`] of their own. -fn jbig2(doc: &Document, s: &Stream) -> Result, String> { - let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { - Some(Object::Stream(g)) => { - Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?) - } - _ => None, - }; - let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; - if s.dict.int(b"Width") != Some(i64::from(image.width())) { - return Err("the JBIG2 image's width isn't the image's width".into()); - } - if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS { - return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height())); - } - let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); - let mut rows = Rows::new(image.width() as usize, height); - image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; - Ok(rows.out) -} - -/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns` -/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so -/// the output is bounded by the size the caller gave. -struct Rows { - out: Vec, - row: Vec, - x: usize, - columns: usize, - rows: usize, - done: usize, -} - -impl Rows { - fn new(columns: usize, rows: usize) -> Self { - Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 } - } - - /// `count` pixels, as 1 samples when `one`. - fn push(&mut self, one: bool, count: usize) { - let end = self.x.saturating_add(count); - if one { - for x in self.x..end.min(self.columns) { - if let Some(b) = self.row.get_mut(x / 8) { - *b |= 0x80 >> (x % 8); - } - } - } - self.x = end; - } - - fn end_row(&mut self) { - if self.done < self.rows { - self.out.extend_from_slice(&self.row); - self.done += 1; - } - self.row.fill(0); - self.x = 0; - } -} - -/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. -impl hayro_jbig2::Decoder for Rows { - fn push_pixel(&mut self, black: bool) { - self.push(!black, 1); - } - fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { - self.push(!black, (chunks as usize).saturating_mul(8)); - } - fn next_line(&mut self) { - self.end_row(); - } + doc.decoded_image(s).map_err(|e| e.to_string()) } /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft @@ -218,7 +118,6 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), - Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 03c93d4a51..bfb57ab835 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2054,10 +2054,10 @@ fn symbol_coded_jbig2_images_export_as_their_picture() { /// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as /// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in -/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the -/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, -/// or larger than an exported image may be (however small the image's `/Height`), or data that -/// isn't JBIG2, skips the image. +/// an array, so globals that can't be read skip the image rather than being ignored. A JBIG2 page +/// of another size than the image (a taller one too: PdfCraft's renderer draws the whole page into +/// the image), refused before it is decoded however large, or data that isn't JBIG2, skips the +/// image. #[test] fn jbig2_images_export_as_their_picture() { let (w, h) = (203i64, 41i64); @@ -2118,33 +2118,24 @@ fn jbig2_images_export_as_their_picture() { for in_array in [false, true] { assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}"); } - assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height"); - for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { + for (width, height, data) in + [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h - 5, &jbig2[..]), (w, h, &b"not JBIG2"[..])] + { assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); } // The same stream with its page information (the first segment's data, after its 11-byte - // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the - // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page - // (it takes pages of up to about 2^28 pixels), so it is refused first. + // header: width, then height) claiming a 20,000 × 10,000 page, behind an image of 20,000 × 4: + // the decoder would decode the whole page, so it is refused first. let mut large = jbig2.clone(); large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); - assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + assert!(skipped(&page(20_000, 4, &large, None, false)), "a page far taller than the image"); // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque. for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] { let rgba = exported_rgba(&page(width, height, mask, None, true)).1; assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask"); assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out"); } - // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. - for (rows, refused) in [(5_000u32, false), (5_001, true)] { - large[15..19].copy_from_slice(&rows.to_be_bytes()); - let doc = page(20_000, 4, &large, None, false); - assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}"); - if !refused { - assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4); - } - } // Damaged data never panics. for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); @@ -2155,6 +2146,38 @@ fn jbig2_images_export_as_their_picture() { assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); } +/// A JBIG2 image behind another filter (ASCIIHexDecode) exports as its picture, as a fax image does, +/// with the `/JBIG2Globals` at its own place in a `/DecodeParms` array: globals that can't be read +/// there skip the image, and ones at the other filter's place aren't the image's. +#[test] +fn jbig2_images_behind_another_filter_export_as_their_picture() { + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let hex: Vec = jbig2.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); + // With unreadable globals at place `at` of its /DecodeParms. + let page = |at: Option| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")])); + if let Some(at) = at { + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec()))))); + let mut entries = vec![Object::Null, Object::Null]; + entries[at] = Object::Dict(parms); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + } + Stream::from_raw(d, hex.clone()) + }); + doc + }; + assert_eq!(exported_rgba(&page(None)), ("png", picture.clone())); + assert_eq!(exported_rgba(&page(Some(0))).1, picture, "globals at ASCIIHexDecode's place"); + let out = extract_images(&page(Some(1)), &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "unreadable globals at its own place"); +} + /// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have /// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to /// one byte more skip the image, with the limit in the reason, though they take a few kilobytes @@ -2162,7 +2185,8 @@ fn jbig2_images_export_as_their_picture() { /// indirect `/DecodeParms`, in an array or not. #[test] fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { - const LIMIT: usize = 32 << 20; + const LIMIT: usize = pdfcraft_cos::MAX_JBIG2_GLOBALS; + assert_eq!(LIMIT, 32 << 20); let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); @@ -2206,6 +2230,6 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { let out = extract_images(&page(&over, form), &[0], 0); assert!(out.images.is_empty(), "globals over the limit, form {form}"); let why = &out.skipped.first().expect("the image is skipped").2; - assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); + assert!(why.contains("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); } } diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index aa8aaa0c4d..bfb930fe5f 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding, JBIG2 encoding and decoding" version.workspace = true edition.workspace = true license.workspace = true @@ -12,13 +12,12 @@ workspace = true [dependencies] fax.workspace = true -# CCITT fax decoding (`decode_ccitt`); already in the tree through hayro. +# CCITT fax and JBIG2 decoding (`decode_ccitt`, `decode_jbig2`); already in the tree through hayro. hayro-ccitt.workspace = true +hayro-jbig2.workspace = true flate2.workspace = true thiserror.workspace = true [dev-dependencies] -hayro-ccitt.workspace = true -hayro-jbig2.workspace = true proptest.workspace = true weezl.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 0c34857c43..59403e4a2b 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,7 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's, and JBIG2 decoding the `hayro-jbig2` crate's (both MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. @@ -26,6 +26,7 @@ encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS +decode_jbig2(data, globals, columns, rows, max) -> Result, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS ``` @@ -47,7 +48,7 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | +| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, about the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::Document::decoded_image` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests @@ -57,12 +58,13 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `jbig2/decode.rs`: `decode_jbig2` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream. - `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the DCT and JPX decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs index e87a82fe16..7c188d701c 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -132,9 +132,10 @@ fn starts_with_end_of_line(data: &[u8]) -> bool { } /// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at -/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. -struct Rows { - out: Vec, +/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. The JBIG2 decoder +/// ([`crate::decode_jbig2`]) fills them too. +pub(crate) struct Rows { + pub(crate) out: Vec, row: Vec, x: usize, columns: usize, @@ -144,12 +145,12 @@ struct Rows { } impl Rows { - fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + pub(crate) fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 } } /// `count` pixels, white or black. - fn push(&mut self, white: bool, count: usize) { + pub(crate) fn push(&mut self, white: bool, count: usize) { let end = self.x.saturating_add(count); if white != self.black_is_1 { for x in self.x..end.min(self.columns) { @@ -160,6 +161,16 @@ impl Rows { } self.x = end; } + + /// The row filled so far kept, if fewer than `rows` are, and the next one begun. + pub(crate) fn end_row(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } } impl hayro_ccitt::Decoder for Rows { @@ -170,12 +181,7 @@ impl hayro_ccitt::Decoder for Rows { self.push(white, (chunks as usize).saturating_mul(8)); } fn next_line(&mut self) { - if self.done < self.rows { - self.out.extend_from_slice(&self.row); - self.done += 1; - } - self.row.fill(0); - self.x = 0; + self.end_row(); } } diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index 343fbbd27d..a36a81e1fb 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -2,10 +2,13 @@ //! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ //! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from //! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 -//! (the segments) and D.3 (the embedded organisation PDF uses). +//! (the segments) and D.3 (the embedded organisation PDF uses). Decoding is in [`decode`]. +mod decode; mod symbols; +pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2}; + use crate::FilterError; use crate::mq::{Context, MqEncoder}; diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs new file mode 100644 index 0000000000..ae72f9bc83 --- /dev/null +++ b/crates/filters/src/jbig2/decode.rs @@ -0,0 +1,155 @@ +//! JBIG2 decoding for `JBIG2Decode` (ISO 32000-2 §7.4.7), through `hayro-jbig2`. + +use crate::FilterError; +use crate::ccitt::Rows; + +/// The largest JBIG2 page [`decode_jbig2`] decodes, in pixels. The decoder decodes a page whole, +/// and takes pages of up to about this many (20,000 × 10,000 decodes). +pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; + +/// A `JBIG2Decode` stream's `data`, with what its `/JBIG2Globals` stream decodes to, decoded as the +/// filter gives it: `rows` rows of `columns` 1-bit samples, most significant bit first, each +/// starting on a new byte, with black as 0 (JBIG2 codes black as 1). +/// +/// The JBIG2 page has to be the image's size, `columns` × `rows`: PdfCraft's renderer draws the +/// whole page into the image, so the rows of a page of another height wouldn't be the image's. The +/// decoder decodes the page whole, so it is checked before it is decoded, and has to fit in `max` +/// bytes as such rows and have at most [`MAX_JBIG2_PIXELS`]. Regions inside it are bounded by the +/// decoder alone: a region may claim more pixels than its page, and is decoded before it is cut to +/// the page. With no rows asked for, nothing is decoded. +/// +/// Errors: `data` or `globals` that don't decode, or a page of another size +/// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or +/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`]). +pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { + if rows == 0 { + return Ok(Vec::new()); + } + let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail }; + let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?; + let (width, height) = (image.width(), image.height()); + if (width, height) != (columns, rows) { + return Err(corrupt(format!("a page of {width} × {height} pixels for an image of {columns} × {rows}"))); + } + if u64::from(width) * u64::from(height) > MAX_JBIG2_PIXELS { + return Err(FilterError::Unsupported(format!("JBIG2 pages of {width} × {height} pixels"))); + } + // u32 to usize: wasm32 and up have at least 32-bit usize. + let (width, height) = (width as usize, height as usize); + if width.div_ceil(8).checked_mul(height).is_none_or(|bytes| bytes > max) { + return Err(FilterError::LimitExceeded(max)); + } + let mut out = Rows::new(width, height, false); + image.decode(&mut out).map_err(|e| corrupt(e.to_string()))?; + Ok(out.out) +} + +/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. +impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.push(!black, 1); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.push(!black, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{encode_jbig2, encode_jbig2_generic}; + + /// `rows` rows of `columns` samples of noise, the bits that pad each row to a byte 0. + fn noise(columns: u32, rows: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut data: Vec = (0..stride * rows as usize) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let pad = (stride * 8 - columns as usize) as u32; + data.chunks_mut(stride).for_each(|row| *row.last_mut().unwrap() &= 0xFFu8.checked_shl(pad).unwrap_or(0)); + data + } + + /// The same stream with its page information (the first segment's data, after its 11-byte + /// header: width, then height) claiming a page of `width` × `height`. + fn page_of(data: &[u8], width: u32, height: u32) -> Vec { + let mut data = data.to_vec(); + data[11..15].copy_from_slice(&width.to_be_bytes()); + data[15..19].copy_from_slice(&height.to_be_bytes()); + data + } + + /// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic + /// region of noise, and symbols for letters repeated across the page. With no rows asked for, + /// nothing is decoded. + #[test] + fn both_codings_decode_to_their_samples() { + let (w, h) = (203u32, 41u32); + let bits = noise(w, h); + let generic = encode_jbig2_generic(&bits, w, h).unwrap(); + assert_eq!(decode_jbig2(&generic, None, w, h, usize::MAX).unwrap(), bits); + assert_eq!(decode_jbig2(b"not JBIG2", None, w, 0, usize::MAX).unwrap(), b"", "no rows asked for"); + + // Four lines of three letters, black on white. + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400u32, 40u32); + let stride = (w as usize).div_ceil(8); + let mut marks = vec![0xFFu8; stride * h as usize]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w as usize - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + marks[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let symbols = encode_jbig2(&marks, w, h).unwrap(); + assert!(symbols.len() < encode_jbig2_generic(&marks, w, h).unwrap().len(), "symbol coded"); + assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); + } + + /// The page has to be the image's size, and is bounded before it is decoded, being decoded + /// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given + /// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged + /// data, which never panics. + #[test] + fn what_it_cannot_decode_is_an_error() { + let (w, h) = (203u32, 41u32); + let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap(); + for (columns, rows) in [(w - 1, h), (w + 1, h), (w, h - 1), (w, h + 1)] { + let e = decode_jbig2(&data, None, columns, rows, usize::MAX).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{columns} × {rows}: {e}"); + } + let page = 26 * h as usize; + assert!(matches!(decode_jbig2(&data, None, w, h, page - 1), Err(FilterError::LimitExceeded(m)) if m == page - 1), "a byte short"); + assert_eq!(decode_jbig2(&data, None, w, h, page).unwrap().len(), page, "the page within max"); + // 16,384 × 16,384 is 2^28 pixels, and fails only later; a row more is past the cap. + let large = |rows| decode_jbig2(&page_of(&data, 16_384, rows), None, 16_384, rows, usize::MAX); + assert!(matches!(large(16_385), Err(FilterError::Unsupported(_))), "{:?}", large(16_385)); + assert!(!matches!(large(16_384), Err(FilterError::Unsupported(_))), "{:?}", large(16_384)); + + // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips: its 11-byte header + // gives the length of the data after it. + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let generic = decode_jbig2(&data, None, w, h, usize::MAX).unwrap(); + assert_eq!(decode_jbig2(&data, Some(&skipped), w, h, usize::MAX).unwrap(), generic); + assert!(matches!(decode_jbig2(&data, Some(b"not JBIG2"), w, h, usize::MAX), Err(FilterError::Corrupt { .. }))); + for end in 0..data.len() { + let _ = decode_jbig2(&data[..end], None, w, h, usize::MAX); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 7eac1a83e1..ba455e2bae 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,9 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 +//! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own +//! ([`decode_ccitt`], [`decode_jbig2`]), as `pdfcraft_cos::Document::decoded_image` +//! does after the filters before them. 1-bit samples can be encoded as CCITT Group 4 //! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb @@ -27,7 +29,7 @@ mod runlength; use std::borrow::Cow; pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, encode_jbig2, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 4ac11e5a83..af9b5385e0 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -85,7 +85,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit - art Flate can be smaller. Not yet: inline images, an image's own `/SMask` or `/Mask` + art Flate can be smaller. Not yet: JBIG2 images themselves (left as they are, neither + downsampled nor stored again, though `Document::decoded_image` reads them), inline images, an image's own `/SMask` or `/Mask` image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 samples. diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 36fb2f6b02..4c5cf54667 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -802,6 +802,9 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve let len = stride.checked_mul(hu)?; let max = match s.filters().last() { Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + // `decoded_image_within` reads JBIG2 too, but not for this yet: a resampled JBIG2 image + // would come out Flate under Retain, and nothing weighs JBIG2 again against it. + Some((pdfcraft_filters::Filter::Jbig2, _)) => return None, _ => allowance(len, h)?, }; let mut data = doc.decoded_image_within(s, max).ok()?; diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2e3ea2d42f..8f55d90214 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2044,6 +2044,58 @@ fn rendered_text_becomes_jbig2_symbols() { assert!(render(&doc) == before, "the page looks the same"); } +/// A JBIG2 image is left as it is above 450 ppi, whatever the compression asked for, and so is one +/// behind another filter: `Document::decoded_image` reads JBIG2, but the monochrome step doesn't +/// take it yet (a resampled one would come out Flate under Retain). The same picture stored with +/// Flate is averaged down under each, so it is the JBIG2 that stops it. +#[test] +fn jbig2_images_are_left_as_they_are() { + // Black squares 16 samples a side on even rows and columns: at 600 ppi, halving leaves no new + // pixel covering both samples, so nothing keeps the picture at its resolution. + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut squares = vec![0xFFu8; stride * h as usize]; + for (top, left) in (0..h as usize / 32).flat_map(|r| (0..w as usize / 32).map(move |c| (r * 32 + 8, c * 32 + 8))) { + for y in top..top + 16 { + for x in left..left + 16 { + squares[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + for compression in [MonoCompression::CcittG4, MonoCompression::Jbig2, MonoCompression::Retain] { + let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; + // 144 × 57.6 pt is 600 ppi. + let content = "q 144 0 0 57.6 20 600 cm /Im Do Q"; + let mut doc = Document::new_empty(); + let im = mono_image(&mut doc, w, h, &squares, None, &grey); + page(&mut doc, &[("Im", im)], content); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_resampled, 1, "Flate, {compression:?}: {r:?}"); + for (filters, data) in + [(vec![Object::name("JBIG2Decode")], &coded), (vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")], &hex)] + { + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + for (k, v) in &grey { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d.set(b"Filter".to_vec(), Object::Array(filters.clone())); + let im = doc.add(Object::Stream(Stream::from_raw(d, data.clone()))); + page(&mut doc, &[("Im", im)], content); + let before = stream(&doc, im); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{filters:?}, {compression:?}: {r:?}"); + assert_eq!(stream(&doc, im), before, "{filters:?}, {compression:?}"); + } + } +} + /// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the /// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ed1f162bad..0ad21640cb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -130,8 +130,8 @@ milestone = "M1" status = "partial" commands = [] tools = [] -evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters"] -notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" +evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals"] +notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images and Copy Image. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" [[feature]] id = "core.decompression-limits" @@ -1599,7 +1599,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] -notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images and JBIG2 inside other filters, multi-format clipboard (file and bitmap at once), the web build" +notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images, multi-format clipboard (file and bitmap at once), the web build" [[feature]] id = "view.rulers" @@ -7188,8 +7188,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2 masks are left out (image exported opaque); JPX/JBIG2/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong." +evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_images_behind_another_filter_export_as_their_picture"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." [[feature]] id = "create.export-html" @@ -7408,8 +7408,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols"] -notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_images_are_left_as_they_are"] +notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. Not yet: inline images, soft masks and /Mask images, JBIG2 images themselves (left as they are: neither downsampled nor stored again), Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" From 78fc1e55689b4647216165bb1f7c439e35e875ab Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 17:13:38 +0100 Subject: [PATCH 035/562] M8: redaction clears the covered pixels of JBIG2 images instead of removing them Part of #283. A scan stored as JBIG2 (as the optimizer writes them) is decoded through Document::decoded_image_within, its /JBIG2Globals included, and its covered pixels cleared in a Flate copy that refers to no globals, as CCITT fax images are. Before, any JBIG2 image under a mark was removed whole, blanking the page. Still removed whole (fail-closed): a JBIG2 page of another size than the image, which decoded_image refuses (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown); globals that can't be read; damaged data; an image claiming more than one bit or one component, even where its rows take as many bytes; a page far past the image's raster, refused before it is decoded. Globals no other image uses leave the file with the original on the full rewrite (tested); globals another image still uses stay, and the README and parity note say they may hold the shapes of marks from under the area, not where they were. Co-Authored-By: Claude Opus 5.5 --- crates/redact/README.md | 16 ++-- crates/redact/src/image.rs | 33 ++++---- crates/redact/src/tests.rs | 148 +++++++++++++++++++++++++++++++++++ docs/architecture.md | 8 +- docs/pdf-spec-parity.md | 5 +- parity/acrobat-features.toml | 6 +- 6 files changed, 188 insertions(+), 28 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index 8383f0119f..af43e958d8 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -18,10 +18,15 @@ clear_marks(&mut doc, None)?; // remove marks without app more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared - (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last - filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference, - numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed - whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as + the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its + `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy + is Flate, and refers to no globals: when no other image uses them, they leave the file with the + original on the full rewrite; when one does, they stay as they are, and may hold the shapes of + marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are + removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer + draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more + than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes to more than three times their declared raster @@ -104,4 +109,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r Custom code sets aren't supported yet. Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information -sanitising (M8.4), re-encoding of DCT images (they are removed instead). +sanitising (M8.4), re-encoding of DCT images (they are removed instead), and storing a cleared black-and-white +image as CCITT Group 4 or JBIG2 again (it is Flate, larger; the optimizer makes it smaller). diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index f662849c12..6c7a6bf93b 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,10 +1,10 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). -//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes, -//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode -//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image -//! instead; that is fail-closed. +//! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on +//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the +//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the +//! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Document, Object, Stream}; @@ -53,18 +53,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - // A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the - // filters before it, so a scan stored as Group 4 is cleared like any other image. + // A CCITT fax or JBIG2 image (its last filter) decodes through `Document::decoded_image_within`, + // after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other + // image. let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); + let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice()); let readable = |f: &Object| f.as_name().is_some_and(supported); - let (ok, is_fax) = match s.dict.get(b"Filter") { - None => (true, false), - Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)), + let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") { + None => (true, false, false), + Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), Some(Object::Array(a)) => match a.split_last() { - Some((last, before)) if fax(last) => (before.iter().all(readable), true), - _ => (a.iter().all(readable), false), + Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)), + _ => (a.iter().all(readable), false, false), }, - _ => (false, false), + _ => (false, false, false), }; if !ok { return None; @@ -83,7 +85,8 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let (w, h) = (w as usize, h as usize); let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) }; - if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || (is_jbig2 && (ncomp, bpc) != (1, 1)) { return None; } // Every size comes from the file: multiply checked, so an overflowing product refuses the image @@ -105,7 +108,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)? }; // Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*` - // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels). + // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels), + // and refuses a JBIG2 page of another size than the image: the renderer draws the whole page + // into the image, so the rows cleared wouldn't be the ones a mark covers on screen. let mut data = doc.decoded_image_within(s, allowance).ok()?; if data.len() < bytes { return None; diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 125f00fc5c..08faef69a7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -736,6 +736,154 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() { assert_eq!((report.images_cleared, report.images_removed), (1, 0)); } +/// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it +/// needn't read, holding `secret`; its 11-byte header gives the length of the data after it. +fn jbig2_globals(secret: &[u8]) -> Vec { + let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0]; + g.extend_from_slice(&u32::try_from(4 + secret.len()).unwrap().to_be_bytes()); + g.extend_from_slice(&[0, 0, 0, 1]); + g.extend_from_slice(secret); + g +} + +/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy, +/// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by +/// reference, behind another filter with its globals at its own place, or as a mask, its decode +/// array inverting it or not. +#[test] +fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() { + // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of + // the top row is covered. + let white = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let painted = pdfcraft_filters::encode_jbig2_generic(&[0x00; 4], 16, 2).unwrap(); + let hex: Vec = white.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let globals = stream("", jbig2_globals(b"shared")); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1"; + for (dict, data, extra, cleared) in [ + (format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode"), white.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms 8 0 R"), + white.clone(), + vec![b"<< /JBIG2Globals 9 0 R >>".to_vec(), globals.clone()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), + hex, + vec![globals], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing. + (format!("{image} /ImageMask true /Filter /JBIG2Decode"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]), + // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0. + (format!("{image} /ImageMask true /Decode [1 0] /Filter /JBIG2Decode"), white, vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}"); + } +} + +/// A JBIG2 image is removed whole when its covered pixels can't be cleared for certain: a JBIG2 +/// page taller than the image (the renderer draws the whole page into the image, so the rows cleared +/// wouldn't be the ones shown) +/// or shorter, or of another width; globals that can't be read; data cut short; an image of more +/// than one bit or component, which JBIG2 doesn't code, even when its rows take as many bytes; and +/// a page far past the image's raster, which isn't decoded. +#[test] +fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() { + let white = |rows: u32| pdfcraft_filters::encode_jbig2_generic(&vec![0xFF; 2 * rows as usize], 16, rows).unwrap(); + // The page information (the first segment's data, after its 11-byte header) claiming 20,000 + // rows of 16. + let mut tall = white(2); + tall[15..19].copy_from_slice(&20_000u32.to_be_bytes()); + let image = |size: &str, more: &str| { + format!("/Type /XObject /Subtype /Image {size} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {more}") + }; + let size = "/Width 16 /Height 2"; + for (dict, data, extra) in [ + (image(size, ""), white(3), vec![]), + (image("/Width 16 /Height 3", ""), white(2), vec![]), + (image("/Width 15 /Height 2", ""), white(2), vec![]), + (image(size, "/DecodeParms << /JBIG2Globals 8 0 R >>"), white(2), vec![stream("", b"not JBIG2")]), + (image(size, ""), white(2)[..20].to_vec(), vec![]), + (image(size, "").replace("/BitsPerComponent 1", "/BitsPerComponent 8"), white(2), vec![]), + (image(size, "").replace("/DeviceGray", "/DeviceRGB"), white(2), vec![]), + // Two pixels of RGB at a bit take a byte a row, as JBIG2's rows do. + ( + image("/Width 2 /Height 2", "").replace("/DeviceGray", "/DeviceRGB"), + pdfcraft_filters::encode_jbig2_generic(&[0xFF; 2], 2, 2).unwrap(), + vec![], + ), + (image(size, ""), tall, vec![]), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}"); + } +} + +/// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is +/// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals, +/// which no other image uses, stay in the file. +#[test] +fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded"); + let dict = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>" + ); + // Half a point a pixel, its top row at y 120. + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, coded), stream("", jbig2_globals(b"LEAK_GLOBALS"))], + ); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0)); + let mut cleared = bits.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_no_secret(&doc, b"LEAK_GLOBALS"); + for (r, object) in saved.scan_objects() { + if let Object::Stream(s) = &*object { + assert_ne!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]), "the JBIG2 image stays as {r:?}"); + } + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. diff --git a/docs/architecture.md b/docs/architecture.md index 008cae903d..fce94c715f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,10 +71,10 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod ## File I/O - PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and - logged), all standard-security revisions. Image codecs (DCT, JPX, JBIG2) decode through the - bootstrap renderer; CCITT decodes through `pdfcraft-filters` (`decode_ccitt`, behind - `Document::decoded_image`) for export, redaction and the optimizer, and through the renderer - for display. Export ▸ Images decodes JPEG itself. + logged), all standard-security revisions. Image codecs (DCT, JPX) decode through the bootstrap + renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt`, `decode_jbig2`, + behind `Document::decoded_image`) for export and redaction, CCITT for the optimizer too, and + through the renderer for display. Export ▸ Images decodes JPEG itself. - PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when combining. - Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index b62ecbc171..aefc959698 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -2,7 +2,7 @@ > Integration update 2026-10-30: lossless JBIG2 generic and exact-copy symbol coding are included; no lossy substitution. Combined tests passed at bdf5620; the subsequent review fixes require fresh CI. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (§7.4: redaction decodes CCITT fax images, Group 3 on bytes aside; earlier, §7.4: CCITT decoding in `pdfcraft-filters`) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (§7.4: JBIG2 decoding in `pdfcraft-filters`, so redaction clears JBIG2 scans; earlier, §7.4: redaction decodes CCITT fax images, Group 3 on bytes aside) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -23,7 +23,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 |---|---|---|---|---| | §7.2–7.5 | Syntax, objects, file structure, xref tables and streams, object streams, hybrid files, incremental updates | full | **own**, tolerant, repairs damaged files | `crates/cos`; corpus 958 files; fuzzed nightly | | §7.4 | Stream filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors | full | **own** | `crates/filters` | -| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 and lossless JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; CCITT decoding in `pdfcraft-filters` (`decode_ccitt`, through `hayro-ccitt`) for export, redaction and the Optimizer, which encodes Group 4 (`optimize.mono-compression`); lossless JBIG2 generic-region or exact-copy symbol encoding and image export; no lossy substitution | +| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 and lossless JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; CCITT decoding in `pdfcraft-filters` (`decode_ccitt`, through `hayro-ccitt`) for export, redaction and the Optimizer, which encodes Group 4 (`optimize.mono-compression`); JBIG2 decoding there too (`decode_jbig2`, through `hayro-jbig2`) for export and redaction; lossless JBIG2 generic-region or exact-copy symbol encoding; no lossy substitution | | §7.5.8 | Linearization | R W | R only | `core.linearization` planned | | §7.6.4 | Standard security handler R2–R6 (RC4 40/128, AES-128, AES-256), crypt filters, `/EncryptMetadata`, SASLprep | full | **own**, read and write | `crates/crypt`; written files open in Acrobat DC since #836 (#774: Acrobat needs the catalog outside object streams) | | §7.6.5 | Public-key security handler | full | none | `core.open-public-key`, `protect.certificate-encryption` planned | @@ -132,6 +132,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | §7.4: JBIG2 decoding (`hayro-jbig2`) in `pdfcraft-filters`, which `Document::decoded_image` uses with the image's `/JBIG2Globals`: export reads JBIG2 behind other filters too, and redaction clears pixels in JBIG2 images instead of removing them whole (one whose page isn't its size still is) | | 2026-10-26 | minor | §7.4: redaction clears pixels in CCITT fax images through the CCITT decoder, instead of removing them whole (Group 4 and Group 3 off bytes; Group 3 on bytes is still removed whole, as the bootstrap renderer may draw it as other rows than the decoder reads) | | 2026-10-26 | minor | §7.4: CCITT decoding (`hayro-ccitt`) in `pdfcraft-filters`, for export and the Optimizer | | 2026-10-26 | minor | §7.4: CCITT Group 4 encoding (`fax`) in the Optimizer | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0ad21640cb..4878eac627 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -131,7 +131,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals"] -notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images and Copy Image. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" +notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image and redaction. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" [[feature]] id = "core.decompression-limits" @@ -5804,8 +5804,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax images (scans stored as Group 4) included; DCT/JPX/JBIG2 images, and Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), removed whole" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals"] +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate (larger than a Group 4 or JBIG2 scan) and refers to no globals; globals another image still uses stay, and may hold the shapes of marks under the area (not where they were)" [[feature]] id = "protect.redact-vectors" From c412093dc779a783568dec316db4718e9ef594c2 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 18:12:33 +0100 Subject: [PATCH 036/562] M11: an indexed image's palette says which of its samples is dark, so it is averaged too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5426 left indexed images at their resolution rather than guess which sample is dark. The palette says: dark_is_one now maps samples 0 and 1 through the decode array to palette entries (rounded and clipped to hival, ISO 32000-2 §8.9.5.2) and compares how light those entries are (`lightness`: the grey, or Rec. 601 luma over DeviceRGB). Only DeviceGray and DeviceRGB bases count, the table a string or a stream (within 4 KiB), directly or by reference: there the palette's values are the colour, and under /DefaultGray or /DefaultRGB no polarity is asked for. Other bases (CMYK, calibrated, ICC-based, a resource name), two entries alike, an entry the table doesn't hold or a hival past 255 still say nothing; separations and DeviceN still aren't averaged, their tint transforms unread. #5426's tone guard applies as to any image. #5426's indexed thin-rule test now checks what it was for: with the palette either way round and the decode array either way, the averaged image holds the averaged DeviceGray one's samples (flipped where its samples are) and renders the same. A unit test covers each base, both orders, an inverting decode array, the weights of luma, the table by reference and in a stream, and every refusal; the older polarity test's doc says what is known now. Co-Authored-By: Claude Opus 5.5 --- crates/optimize/README.md | 14 ++-- crates/optimize/src/images.rs | 107 +++++++++++++++++++++++++++--- crates/optimize/src/lib.rs | 2 +- crates/optimize/tests/optimize.rs | 17 +++-- parity/acrobat-features.toml | 4 +- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index af9b5385e0..b8346a626a 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -59,12 +59,14 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going downsampling it to JPEG. - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a - tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image - mask (a 0, or a 1 with an inverting decode array). Only images whose dark sample is known so - are averaged: `DeviceGray` (grey and RGB packed to one bit among them) and image masks, with no - decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or - `/DefaultRGB` is or may be in force. Not a separation, a DeviceN, calibrated or ICC grey, or an - indexed image, whose palette isn't read. Not one with a soft mask, whose own size would have to + tie, so a dark line half a new pixel wide stays: black in grey, the painted part of an image + mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two + palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and + RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB), with no decode array or one of two + different numbers, and not drawn or listed where `/DefaultGray` or `/DefaultRGB` is or may be + in force. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% one white), nor one with a square of four new pixels that each would, as a small screened diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 4c5cf54667..2929b98845 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -911,8 +911,12 @@ fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, whit Some(out) } -/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom -/// colour spaces stay at their original resolution rather than guessing which entry is dark. +/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array +/// (`[0 1]` when it has none; another must be two different numbers): black in grey is a 0, and so +/// is the painted part of an image mask. An indexed image's samples pick palette entries (its +/// decode array maps them to entries, rounded and clipped to the table, ISO 32000-2 §8.9.5.2), and +/// the palette says which is darker ([`lightness`]). `None` for anything else: separations and +/// DeviceN, whose tint transforms aren't read, other colour spaces, and two entries alike. fn dark_is_one(doc: &Document, d: &Dict) -> Option { let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite()); let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { @@ -928,10 +932,42 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { } match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), + Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let hival = a.get(2).and_then(|v| doc.resolve(v).as_int()).filter(|h| (0..=255).contains(h))?; + // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap. + let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize; + let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?); + (l0 != l1).then_some(l1 < l0) + } _ => None, } } +/// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`) +/// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only +/// those two bases count, where the palette's values are the colour: under `/DefaultGray` or +/// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base +/// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold. +fn lightness(doc: &Document, a: &[Object], i: usize) -> Option { + let n = match doc.resolve(a.get(1)?).as_name()? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + _ => return None, + }; + // At most 256 entries of three components: a stream decoding to more is no palette. + let table = match &*doc.resolve(a.get(3)?) { + Object::String(s) => s.bytes.clone(), + Object::Stream(s) => doc.with_direct_parms(s).decoded_within(4096).ok()?, + _ => return None, + }; + let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect(); + Some(match c.as_slice() { + [v] => v * 1000, + [r, g, b] => 299 * r + 587 * g + 114 * b, + _ => return None, + }) +} + /// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: /// each new pixel takes the sample that covers more of its area, and the dark one when the two tie /// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` @@ -1003,12 +1039,13 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. /// /// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, -/// if its dark sample is known ([`dark_is_one`]: grey or an image mask, not under `defaults`), -/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a -/// dither: most of its new pixels would cover both black and white, and taking the sample that -/// covers more would turn its tones solid (a 50% screen black, a 25% one white). So would a -/// square of four new pixels that each cover both, as a small screened picture on a white page -/// has (and ragged scanned text, which is then left at its resolution too). +/// if its dark sample is known ([`dark_is_one`]: grey, an image mask, or an indexed image whose +/// palette says which entry is darker, not under `defaults`), unless it has a soft mask, whose +/// own size would then have to follow, or is a halftone or a dither: most of its new pixels would +/// cover both black and white, and taking the sample that covers more would turn its tones solid +/// (a 50% screen black, a 25% one white). So would a square of four new pixels that each cover +/// both, as a small screened picture on a white page has (and ragged scanned text, which is then +/// left at its resolution too). /// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only /// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again /// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored @@ -1313,8 +1350,9 @@ mod downsample_tests { assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); } - /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round - /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + /// The dark sample: 0 in grey and of an image mask, the other way round with an inverting decode + /// array; none known for a separation, nor for an indexed image without a palette (see the next + /// test), nor with a decode array that isn't two numbers. #[test] fn the_dark_sample_follows_the_colour_space_and_decode_array() { let doc = Document::new_empty(); @@ -1338,6 +1376,7 @@ mod downsample_tests { (vec![mask, inverted.clone()], Some(true)), (vec![separation.clone()], None), (vec![separation, inverted.clone()], None), + // A palette that isn't there says nothing. (vec![indexed.clone()], None), (vec![indexed, inverted], None), (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None), @@ -1346,6 +1385,54 @@ mod downsample_tests { } } + /// An indexed image's dark sample is the one picking the darker palette entry, after its decode + /// array: over DeviceGray and DeviceRGB (by luma, each weight checked), with the table a string + /// or a stream, directly or by reference. Another base (CMYK, calibrated, an ICC profile, a + /// resource name), two entries alike, an entry the table doesn't hold, and a `hival` past 255 + /// say nothing. + #[test] + fn an_indexed_images_palette_says_which_sample_is_dark() { + let mut doc = Document::new_empty(); + let string = |bytes: &[u8]| Object::String(pdfcraft_cos::PdfString::literal(bytes.to_vec())); + let referenced = doc.add(string(&[255, 0])); + let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &[0, 0, 0, 255, 255, 255]))); + let cal = |name: &str| Object::Array(vec![Object::name(name), Object::Dict(Dict::new())]); + let indexed = |base: Object, hival: i64, table: Object| { + let mut d = Dict::new(); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), base, hival.into(), table])); + d + }; + let inverted = |mut d: Dict| { + d.set(b"Decode".to_vec(), Object::Array(vec![1.into(), 0.into()])); + d + }; + for (d, dark_one, what) in [ + (indexed(Object::name("DeviceGray"), 1, string(&[0, 255])), Some(false), "grey, black first"), + (indexed(Object::name("DeviceGray"), 1, string(&[255, 0])), Some(true), "grey, white first"), + (indexed(Object::name("DeviceGray"), 1, string(&[90, 89])), Some(true), "two greys a step apart"), + (inverted(indexed(Object::name("DeviceGray"), 1, string(&[255, 0]))), Some(false), "white first, inverted"), + (indexed(Object::name("DeviceGray"), 1, Object::Ref(referenced)), Some(true), "the table by reference"), + (indexed(Object::name("DeviceRGB"), 1, Object::Ref(stream)), Some(false), "RGB in a stream"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 255, 0, 0, 0, 255])), Some(true), "yellow, then blue"), + // Pure red (76,245) is lighter than pure blue (29,070), and darker than green (149,685). + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 0, 255])), Some(true), "red, then blue"), + (indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 255, 0])), Some(false), "red, then green"), + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 0, 255, 0])), Some(false), "blue, then green"), + // Blue (29,070) is lighter than grey 20 (20,000): the blue weight decides it. + (indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 20, 20, 20])), Some(true), "blue, then dark grey"), + (indexed(Object::name("DeviceCMYK"), 1, string(&[0, 0, 0, 0, 0, 0, 0, 255])), None, "CMYK"), + (indexed(cal("CalGray"), 1, string(&[200, 20])), None, "calibrated grey"), + (indexed(cal("ICCBased"), 1, string(&[0, 255])), None, "an ICC profile"), + (indexed(Object::name("CS0"), 1, string(&[0, 255])), None, "a resource name"), + (indexed(Object::name("DeviceGray"), 1, string(&[7, 7])), None, "two entries alike"), + (indexed(Object::name("DeviceGray"), 1, string(&[0])), None, "a table of one entry"), + (indexed(Object::name("DeviceGray"), 0, string(&[0, 255])), None, "hival 0: both samples pick entry 0"), + (indexed(Object::name("DeviceGray"), 256, string(&[0, 255])), None, "hival past 255"), + ] { + assert_eq!(dark_is_one(&doc, &d), dark_one, "{what}"); + } + } + #[test] fn what_it_cannot_resample_is_none() { assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 6e8988fd1b..a4a0a810e0 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -13,7 +13,7 @@ //! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, //! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images //! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a -//! target resolution when grey or image masks, halftones and dithers aside (see the README), and +//! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and //! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit //! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 8f55d90214..92dc2e2d86 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3445,10 +3445,12 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); } -/// Unknown palette polarity is never guessed. Both palette orders and Decode directions -/// retain the original thin rule and exact samples through a full save and render. +/// An indexed image's palette says which sample is dark, so a thin rule survives averaging as it does +/// in grey: with the palette either way round, and the decode array either way, the averaged image +/// holds the averaged DeviceGray one's samples (flipped where its samples are), and looks the same +/// through a full save and render. #[test] -fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { +fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { let (w, h) = (1200u32, 480u32); let stride = (w as usize).div_ceil(8); let mut gray_data = vec![0xFF; stride * h as usize]; @@ -3460,6 +3462,7 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); let gray_render = render(&gray); optimize(&mut gray, &Settings::default()).unwrap(); + let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap()); let gray_stream = stream(&gray, gray_image); let gray_samples = gray.decoded_image(&gray_stream).unwrap(); assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); @@ -3491,10 +3494,12 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray"); optimize(&mut doc, &Settings::default()).unwrap(); let s = stream(&doc, image); - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); - assert_eq!(doc.decoded_image(&s).unwrap(), data); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}"); + // The rows are 600 samples, whole bytes: flipping each byte flips each sample. + let expected: Vec = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect(); + assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark"); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}"); + assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}"); } } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4878eac627..4430e5fac4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7384,8 +7384,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey images and image masks (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations, DeviceN, calibrated or ICC grey, or indexed images (their dark sample isn't read), images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base, images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" From 39eb03a0b60905410bc90e810281680549aa7690 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 18:39:20 +0100 Subject: [PATCH 037/562] M11: Monochrome Images reads JBIG2 scans: averaged like any, and stored again as JBIG2 #5461 made Document::decoded_image read JBIG2, page exactly the image's size, and kept the optimizer's monochrome step off it until it could weigh JBIG2 again. Now one_bit reads JBIG2 last after any filters, within the raster's allowance, and mono treats it as it does any black-and-white image: - above the threshold it is averaged, and stored as the compression asks, the smallest of what that may store; under Retain it is stored as JBIG2 again, its kind of compression kept; - not resampled, it is stored again as the compression asks when that is smaller: a generic region becomes symbols with JBIG2 asked for, and JBIG2 behind ASCIIHex is stored again alone (as Group 4 where that is smaller); - a JBIG2 image stored again refers to no /JBIG2Globals; one whose page isn't its size isn't read, and stays. jbig2_images_are_left_as_they_are becomes jbig2_scans_are_averaged_and_stored_again: a JBIG2 scan at 600 ppi, alone, behind ASCIIHex and with globals, averages to the same samples as its Flate copy under each compression (Retain keeping JBIG2); at 300 ppi a generic region of rendered text stays under Group 4 (larger) and Retain and becomes symbols under JBIG2 (under half the size); behind ASCIIHex it is stored again alone, as Group 4 where the hex is spaced out; a taller page stays. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 2 +- crates/optimize/README.md | 24 +++--- crates/optimize/src/images.rs | 35 ++++---- crates/optimize/src/lib.rs | 2 +- crates/optimize/tests/optimize.rs | 127 +++++++++++++++++++++++------- docs/architecture.md | 4 +- parity/acrobat-features.toml | 10 +-- 7 files changed, 139 insertions(+), 65 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a816413a79..3809967350 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1442,7 +1442,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/optimize/README.md b/crates/optimize/README.md index b8346a626a..33b0eb50b6 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -31,7 +31,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. An image is replaced only if the result is smaller. Left alone by these colour and grey settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, - JBIG2 and CCITT (black-and-white images, CCITT among them, are below). A JPEG is read with + JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by @@ -40,7 +40,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above 450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component (grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones - or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further + or with CCITT fax or JBIG2 after any of those (decoded by `Document::decoded_image_within`, no further than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's @@ -80,21 +80,25 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder recovers it, a bad row picked up again at the next code, as it reports no damage there; without - them, damage stops the decoding and the image is left alone. With Retain, a resampled image - keeps its kind of compression (CCITT, or Flate) and nothing else changes; an image of more bits - is then left to the grey or colour settings. Every change is kept only when it is smaller as - stored. + them, damage stops the decoding and the image is left alone. JBIG2, alone or inside another + filter, is stored again as the compression asks when that is smaller (as JBIG2 symbols where + its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact + repeats and often comes out larger, and then stays as it is. A JBIG2 image stored again refers to no + `/JBIG2Globals`, and one whose page isn't its size (PdfCraft's + renderer draws the whole page into the image) is left alone. With Retain, a resampled image + keeps its kind of compression (CCITT, JBIG2, or Flate) and nothing else changes; an image of + more bits is then left to the grey or colour settings. Every change is kept only when it is + smaller as stored. - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit - art Flate can be smaller. Not yet: JBIG2 images themselves (left as they are, neither - downsampled nor stored again, though `Document::decoded_image` reads them), inline images, an image's own `/SMask` or `/Mask` + art Flate can be smaller. Not yet: inline images, an image's own `/SMask` or `/Mask` image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 samples. - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when - smaller. No lossy symbol substitution is used. Export reads embedded images and bounded - shared globals. + smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 + images, with bounded shared globals. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 2929b98845..39cdc06a30 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -771,7 +771,7 @@ type Pixel = fn(&[u8]) -> Option; /// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit /// image. These count: /// - one bit per pixel in one colour component, or an image mask, stored with no filter, with -/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further +/// general-purpose ones, or with CCITT fax or JBIG2 last ([`Document::decoded_image_within`], no further /// than its rows can need); /// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): /// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array @@ -802,9 +802,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve let len = stride.checked_mul(hu)?; let max = match s.filters().last() { Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, - // `decoded_image_within` reads JBIG2 too, but not for this yet: a resampled JBIG2 image - // would come out Flate under Retain, and nothing weighs JBIG2 again against it. - Some((pdfcraft_filters::Filter::Jbig2, _)) => return None, + // A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit. _ => allowance(len, h)?, }; let mut data = doc.decoded_image_within(s, max).ok()?; @@ -1046,12 +1044,13 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// (a 50% screen black, a 25% one white). So would a square of four new pixels that each cover /// both, as a small screened picture on a white page has (and ragged scanned text, which is then /// left at its resolution too). -/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only -/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again -/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored -/// again like any other. With `Retain`, a resampled image keeps its kind of compression -/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the -/// grey or colour settings. +/// - **Stored** as CCITT Group 4 with `CcittG4`, or JBIG2 with `Jbig2`, or as 1-bit Flate when that +/// is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image +/// that is only stored again becomes Group 4 or JBIG2, or stays. Group 4 alone is only decoded +/// to be resampled; Group 3, Group 4 inside another filter, and JBIG2 are stored again like any +/// other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or +/// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or +/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`. /// /// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for /// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default @@ -1063,10 +1062,12 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet let fax = doc.fax_params(s); // Its kind of compression, which Retain keeps, behind any other filters. let ccitt = fax.is_some(); - // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind - // ASCII85Decode, say, can be smaller stored again as Group 4 alone. - let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1; - if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample { + let jbig2 = matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))); + // Group 4 alone is only worth decoding to resample it. Group 3, Group 4 behind ASCII85Decode, + // say, and JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored + // again alone. + let only_to_resample = retain || (fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4); + if only_to_resample && !downsample { return None; } let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?; @@ -1098,7 +1099,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet Some(out) => (out, nw, nh), None => (data, w, h), }; - if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled { + if only_to_resample && !resampled { return None; } let mut dict = packed.unwrap_or_else(|| d.clone()); @@ -1121,7 +1122,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { None }; - let jbig = if settings.compression == MonoCompression::Jbig2 { + let jbig = if settings.compression == MonoCompression::Jbig2 || (retain && jbig2) { pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| { let mut dict = dict.clone(); dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); @@ -1130,7 +1131,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { None }; - let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + let flate = ((resampled || packed_from_more) && !(retain && (ccitt || jbig2))).then(|| Stream::flate(dict, &data)); // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index a4a0a810e0..4c8845c82d 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -10,7 +10,7 @@ //! A new image replaces the old one only if it is smaller. The colour and grey settings leave //! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays //! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker -//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, +//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks, CCITT fax and JBIG2, //! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images //! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a //! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 92dc2e2d86..b32e8bde4a 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2044,12 +2044,16 @@ fn rendered_text_becomes_jbig2_symbols() { assert!(render(&doc) == before, "the page looks the same"); } -/// A JBIG2 image is left as it is above 450 ppi, whatever the compression asked for, and so is one -/// behind another filter: `Document::decoded_image` reads JBIG2, but the monochrome step doesn't -/// take it yet (a resampled one would come out Flate under Retain). The same picture stored with -/// Flate is averaged down under each, so it is the JBIG2 that stops it. +/// A JBIG2 scan goes through Monochrome Images like any black-and-white image. Above 450 ppi it +/// is averaged as its Flate copy is, to the same samples, and stored as each compression asks: +/// Group 4 or JBIG2, or Flate when that is smaller, or under Retain JBIG2 again; alone or behind another +/// filter, and with `/JBIG2Globals`, which the new image doesn't refer to. Below 450 ppi it is +/// stored again only when that is smaller: a generic region of text stays under Group 4, which +/// is larger, and under Retain, and becomes symbols with JBIG2 asked for; behind ASCIIHex it is +/// stored again alone, as Group 4 where the hex is spaced out. A JBIG2 page taller than the image +/// is left alone. #[test] -fn jbig2_images_are_left_as_they_are() { +fn jbig2_scans_are_averaged_and_stored_again() { // Black squares 16 samples a side on even rows and columns: at 600 ppi, halving leaves no new // pixel covering both samples, so nothing keeps the picture at its resolution. let (w, h) = (1200u32, 480u32); @@ -2062,38 +2066,103 @@ fn jbig2_images_are_left_as_they_are() { } } } - let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap(); - let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let hex = |coded: &[u8]| -> Vec { coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() }; + // An image of `coded` behind `filters`, with globals a decoder skips when `globals`, drawn at + // `width` × `height` pt. + let jbig2_page = |coded: &[u8], filters: &[&str], globals: bool, (iw, ih): (u32, u32), (width, height): (f64, f64)| { + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(iw.into())); + d.set(b"Height".to_vec(), Object::Int(ih.into())); + for (k, v) in &grey { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect())); + if globals { + let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + let entries = filters.iter().map(|f| if *f == "JBIG2Decode" { Object::Dict(parms.clone()) } else { Object::Null }).collect(); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + } + let data = if filters.len() > 1 { hex(coded) } else { coded.to_vec() }; + let im = doc.add(Object::Stream(Stream::from_raw(d, data))); + page(&mut doc, &[("Im", im)], &format!("q {width} 0 0 {height} 20 600 cm /Im Do Q")); + (doc, im) + }; + let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap(); + // 144 × 57.6 pt is 600 ppi. + let at_600 = (144.0, 57.6); for compression in [MonoCompression::CcittG4, MonoCompression::Jbig2, MonoCompression::Retain] { let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; - // 144 × 57.6 pt is 600 ppi. - let content = "q 144 0 0 57.6 20 600 cm /Im Do Q"; let mut doc = Document::new_empty(); let im = mono_image(&mut doc, w, h, &squares, None, &grey); - page(&mut doc, &[("Im", im)], content); - let r = optimize(&mut doc, &settings).unwrap(); - assert_eq!(r.images_resampled, 1, "Flate, {compression:?}: {r:?}"); - for (filters, data) in - [(vec![Object::name("JBIG2Decode")], &coded), (vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")], &hex)] - { - let mut doc = Document::new_empty(); - let mut d = Dict::new(); - d.set(b"Subtype".to_vec(), Object::name("Image")); - d.set(b"Width".to_vec(), Object::Int(w.into())); - d.set(b"Height".to_vec(), Object::Int(h.into())); - for (k, v) in &grey { - d.set(k.as_bytes().to_vec(), v.clone()); - } - d.set(b"Filter".to_vec(), Object::Array(filters.clone())); - let im = doc.add(Object::Stream(Stream::from_raw(d, data.clone()))); - page(&mut doc, &[("Im", im)], content); - let before = stream(&doc, im); + page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}"); + let averaged = doc.decoded_image(&stream(&doc, im)).unwrap(); + for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] { + let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600); let r = optimize(&mut doc, &settings).unwrap(); - assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{filters:?}, {compression:?}: {r:?}"); - assert_eq!(stream(&doc, im), before, "{filters:?}, {compression:?}"); + let s = stream(&doc, im); + let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}"); + assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}"); + assert_eq!(doc.decoded_image(&s).unwrap(), averaged, "{what}"); + assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}"); + let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec); + // The smallest of what each compression may store; under Retain, JBIG2 again. + let allowed: &[&[u8]] = match compression { + MonoCompression::CcittG4 => &[b"CCITTFaxDecode", b"FlateDecode"], + MonoCompression::Jbig2 => &[b"JBIG2Decode", b"FlateDecode"], + MonoCompression::Retain => &[b"JBIG2Decode"], + }; + assert!(filter.as_deref().is_some_and(|f| allowed.contains(&f)), "{what}: {:?}", filter.map(String::from_utf8)); } } + + // At 300 ppi, below 450: rendered text, whose letters repeat, coded as one generic region. + let (tw, th) = (600u32, 480u32); + let text = rendered_text(tw, th); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, tw, th).unwrap(); + let at_300 = (144.0, 115.2); + for compression in [MonoCompression::CcittG4, MonoCompression::Retain] { + let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() }; + let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300); + let before = stream(&doc, im); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, im), before, "JBIG2 below 450 ppi, {compression:?}"); + } + let jbig2_asked = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300); + let r = optimize(&mut doc, &jbig2_asked).unwrap(); + let s = stream(&doc, im); + assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}"); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len()); + assert_eq!(doc.decoded_image(&s).unwrap(), text); + // Behind ASCIIHex it is stored again alone, being smaller so. + let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); + optimize(&mut doc, &jbig2_asked).unwrap(); + let s = stream(&doc, im); + assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..]))); + assert_eq!(doc.decoded_image(&s).unwrap(), text); + // So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex + // allows, so that the stream is many times the code. + let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); + let mut padded = stream(&doc, im); + padded.raw = generic.iter().flat_map(|b| format!("{b:02x}{}", " ".repeat(30)).into_bytes()).chain(*b">").collect::>().into(); + doc.set(im, Object::Stream(padded)); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, im); + assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone"); + assert_eq!(doc.decoded_image(&s).unwrap(), text); + + // A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't + // read, and stays as it is. + let (mut doc, im) = jbig2_page(&coded, &["JBIG2Decode"], false, (w, h - 8), at_600); + let before = stream(&doc, im); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, im), before, "a taller page"); } /// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the diff --git a/docs/architecture.md b/docs/architecture.md index fce94c715f..b5beb009b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,8 +73,8 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod - PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and logged), all standard-security revisions. Image codecs (DCT, JPX) decode through the bootstrap renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt`, `decode_jbig2`, - behind `Document::decoded_image`) for export and redaction, CCITT for the optimizer too, and - through the renderer for display. Export ▸ Images decodes JPEG itself. + behind `Document::decoded_image`) for export, redaction and the optimizer, and through the + renderer for display. Export ▸ Images decodes JPEG itself. - PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when combining. - Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4430e5fac4..c9864f0caf 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -131,7 +131,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals"] -notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image and redaction. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" +notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image, redaction and the optimizer. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" [[feature]] id = "core.decompression-limits" @@ -7384,8 +7384,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base, images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, JBIG2, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base, images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" @@ -7408,8 +7408,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_images_are_left_as_they_are"] -notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. Not yet: inline images, soft masks and /Mask images, JBIG2 images themselves (left as they are: neither downsampled nor stored again), Group 4 for rows wider than 32,767 samples." +evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again"] +notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" From 7f9554f29b704a4f011673504e069c715d65fb60 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 18:36:52 +0100 Subject: [PATCH 038/562] M8: a cleared black-and-white scan is stored as Group 4, or JBIG2 again, when smaller Redaction wrote every cleared copy as Flate, so a redacted Group 4 or JBIG2 scan page grew several times over until the optimizer ran. A cleared one-bit image of one component is now stored as the smallest of Flate, CCITT Group 4 and, when it was JBIG2, JBIG2 (PDF 1.4, which such a file already is), as stored, dictionary included: a tiny image can lose by Group 4's parameters. Both are coded from the cleared samples, so nothing of the original's code is carried over; rows Group 4 or JBIG2 can't code (too wide) leave Flate. Images of more bits stay Flate. Tests: a cleared one-bit Flate scan of ragged word blocks (where Group 4 is smaller than Flate) comes out Group 4 and an eight-bit image Flate, each decoding to the cleared samples (the whole buffer); a tiny image whose Group 4 code is shorter but whose stream isn't, and rows wider than Group 4 codes, stay Flate and clear; the redacted JBIG2 scan of #5461's test comes out JBIG2, smaller than Flate, with no globals and no stream holding the original code. The tests' image helpers decode through Document::decoded_image, as image codecs need. Co-Authored-By: Claude Opus 5.5 --- crates/redact/Cargo.toml | 3 +- crates/redact/README.md | 6 +- crates/redact/src/image.rs | 41 +++++++++++- crates/redact/src/tests.rs | 117 +++++++++++++++++++++++++++++++++-- parity/acrobat-features.toml | 4 +- 5 files changed, 157 insertions(+), 14 deletions(-) diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 8d1515d8a4..fe21d13b32 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -17,8 +17,9 @@ pdfcraft-forms = { workspace = true } pdfcraft-edit = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-xfa = { workspace = true } +# CCITT Group 4 and JBIG2 encoding for cleared one-bit images. +pdfcraft-filters = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index af43e958d8..e52c1d8b98 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -21,7 +21,8 @@ clear_marks(&mut doc, None)?; // remove marks without app (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy - is Flate, and refers to no globals: when no other image uses them, they leave the file with the + is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2 + when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to no globals: when no other image uses them, they leave the file with the original on the full rewrite; when one does, they stay as they are, and may hold the shapes of marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer @@ -109,5 +110,4 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r Custom code sets aren't supported yet. Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information -sanitising (M8.4), re-encoding of DCT images (they are removed instead), and storing a cleared black-and-white -image as CCITT Group 4 or JBIG2 again (it is Flate, larger; the optimizer makes it smaller). +sanitising (M8.4), re-encoding of DCT images (they are removed instead). diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 6c7a6bf93b..e9606c5e30 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,13 +1,14 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is -//! written as a new Flate image; the original object is untouched (other pages may use it). +//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it +//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it). //! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on //! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the //! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the //! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; -use pdfcraft_cos::{Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed). const MAX_PIXELS: u64 = 64 << 20; @@ -165,5 +166,39 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] dict.remove(b"Length"); dict.remove(b"DecodeParms"); dict.remove(b"Alternates"); - Some(Some(Stream::flate(dict, &data))) + Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2))) +} + +/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit +/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT +/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already +/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's +/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's +/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out. +fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream { + let flate = Stream::flate(dict.clone(), data); + let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate }; + let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }); + let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }); + [g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best }) +} + +/// The bytes stream `s` takes in a file: its data and its dictionary as written. +fn stored_len(s: &Stream) -> usize { + let mut dict = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict); + dict.len().saturating_add(s.raw.len()) } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 08faef69a7..2e76bb0bac 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -519,13 +519,19 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document { ]) } -/// The samples of the image a page's (possibly inherited) resources name `name`. +/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through +/// its image codec too (`Document::decoded_image`). fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { + named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok()) +} + +/// The image a page's (possibly inherited) resources name `name`. +fn named_stream(doc: &Document, page: usize, name: &[u8]) -> Option { let p = pdfcraft_model::pages(doc).swap_remove(page); let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; match &*doc.get(xo.get(name)?.as_ref()?) { - Object::Stream(s) => s.decoded().ok(), + Object::Stream(s) => Some(s.clone()), _ => None, } } @@ -833,7 +839,8 @@ fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() { /// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is /// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals, -/// which no other image uses, stay in the file. +/// which no other image uses, stay in the file. The cleared copy is coded afresh as JBIG2, smaller +/// than Flate, and refers to no globals. #[test] fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { const LETTERS: [[&str; 7]; 3] = [ @@ -854,6 +861,7 @@ fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { } } let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let original = coded.clone(); assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded"); let dict = format!( "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>" @@ -877,13 +885,111 @@ fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared)); assert_no_secret(&doc, b"LEAK_GLOBALS"); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(!copy.dict.contains(b"DecodeParms"), "the copy refers to no globals"); + let flate = pdfcraft_filters::encode_flate(&named_image(&saved, 0, b"PCRedacted1").unwrap()); + assert!(copy.raw.len() < flate.len(), "JBIG2 {} bytes, Flate {}", copy.raw.len(), flate.len()); for (r, object) in saved.scan_objects() { if let Object::Stream(s) = &*object { - assert_ne!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]), "the JBIG2 image stays as {r:?}"); + assert_ne!(&s.raw[..], &original[..], "the original JBIG2 code stays as {r:?}"); } } } +/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when +/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it +/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples. +#[test] +fn a_cleared_black_and_white_scan_is_stored_as_group_4() { + // 800 × 200: lines of word-sized dark blocks 14 rows tall, their edges ragged by a sample from + // row to row, as a scan's are; Group 4 codes such rows from the row above. + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut bits = vec![0xFFu8; stride * h]; + for top in (3..h - 14).step_by(20) { + let mut x = 4; + while x + 70 < w { + let len = 20 + (next() % 40) as usize; + for y in top..top + 14 { + let (left, right) = (x + (next() % 2) as usize, x + len - (next() % 2) as usize); + for xx in left..right { + bits[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += len + 6 + (next() % 6) as usize; + } + } + let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap(); + assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here"); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + for (dict, data, filter) in [ + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&bits), + &b"CCITTFaxDecode"[..], + ), + ( + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"), + pdfcraft_filters::encode_flate(&grey), + &b"FlateDecode"[..], + ), + ] { + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + // The left quarter of the top half. + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap(); + assert_eq!(copy.dict.name(b"Filter"), Some(filter), "{dict}"); + // Covered: rows 0 to 99 (y 150 down to 125, a quarter point a row), samples 0 to 199. + let mut expected = if filter == b"FlateDecode" { grey.clone() } else { bits.clone() }; + for y in 0..100 { + for x in 0..200 { + if filter == b"FlateDecode" { + expected[y * w + x] = 0; + } else { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap(), expected, "{dict}"); + } +} + +/// A cleared one-bit image is stored as the smallest stream, its dictionary counted: a tiny one, +/// whose Group 4 code is shorter than Flate's but whose `/DecodeParms` aren't, stays Flate. Rows +/// wider than Group 4 codes (`MAX_G4_COLUMNS`) stay Flate too, and still clear. +#[test] +fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() { + let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1; + for (w, h) in [(3usize, 2usize), (wide, 2)] { + let stride = w.div_ceil(8); + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page( + b"q 200 0 0 20 0 100 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(&dict, pdfcraft_filters::encode_flate(&vec![0xFF; stride * h]))], + ); + // The top row's left half. + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{w} × {h}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "{w} × {h}"); + let samples = named_image(&saved, 0, b"PCRedacted1").unwrap(); + let sample = |x: usize| samples.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + assert!(!sample(0) && sample(w - 1), "{w} × {h}: the top row's first sample is cleared and its last kept"); + assert!(samples.get(stride..).is_some_and(|row| row.iter().all(|b| *b == 0xFF)), "{w} × {h}: the bottom row is kept"); + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -2525,7 +2631,8 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) { pdfcraft_cos::serialize(&object, &mut bytes); assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); if let Object::Stream(s) = &*object { - let data = s.decoded().unwrap(); + // Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document. + let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap(); assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); } } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4878eac627..de1d9d9e38 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5804,8 +5804,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate (larger than a Group 4 or JBIG2 scan) and refers to no globals; globals another image still uses stay, and may hold the shapes of marks under the area (not where they were)" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4"] +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; globals another image still uses stay, and may hold the shapes of marks under the area (not where they were)" [[feature]] id = "protect.redact-vectors" From bb4a8accdd8df8cc8f4658a3e6fa3a68bde5106b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 14:28:05 -0400 Subject: [PATCH 039/562] M12: an XFA check box without a width is as wide as its box and caption A field with no `w` is growable in width (XFA 3.3 Ch.8, growable objects): it takes its content's width, clamped to minW/maxW. The layouter gave every non-button field without a width all the room left in its container, so a positioned radio group whose choices set only minW stretched the first choice to the group's right edge. Its box (drawn at the field's right end) then sat on the last choice's box, and the group showed one circle where it had two (#5442's Yes/No groups). A check button without a width is now its box and widget margins plus its caption beside it (the caption's reserve, else its text), or the wider of the two when the caption is above or below, within what is available and clamped by minW/maxW. Other fields keep taking the available width. Test: a_radio_choice_without_a_width_is_as_wide_as_its_content, two choices with minW and no w in a positioned exclGroup; with the change reverted the Yes box lands at 105.2pt, on top of the No box. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/layout/measure.rs | 19 ++++++++++++++++++- crates/xfa/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 2fffb3dfa0..6532f6511e 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -51,7 +51,8 @@ impl Layouter<'_> { let som = ctx.som.clone(); // A container without a width is as wide as its content (a radio group beside its // question, say), and so is a text label or a button (as wide as its caption), within - // what is available; any other field without one takes what is available. + // what is available, and so is a check box (its box and caption); any other field without + // one takes what is available. let w = match c.w { Some(w) => w, None => match node { @@ -65,6 +66,7 @@ impl Layouter<'_> { .as_ref() .and_then(|cap| self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para))) .map_or(avail_w, |t| (t + c.margin.horizontal() + f.ui_margin.horizontal()).min(avail_w)), + Node::Field(f) if f.ui == Ui::CheckButton => (self.check_width(f, &ctx) + c.margin.horizontal()).min(avail_w), Node::Field(_) => avail_w, }, }; @@ -149,6 +151,21 @@ impl Layouter<'_> { } } + /// How wide check box `f` is without a width of its own (XFA 3.3 Ch.8, growable fields): its + /// box and widget margins, plus its caption beside it (its reserve, else its text), or the + /// wider of the two when the caption is above or below. + fn check_width(&self, f: &Field, ctx: &Ctx) -> f64 { + let check = f.check_size.unwrap_or(10.0) + f.ui_margin.horizontal(); + let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else { + return check; + }; + let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0); + match cap.placement { + Placement::Top | Placement::Bottom => check.max(text()), + _ => check + cap.reserve.unwrap_or_else(text), + } + } + /// How wide text `v` is unwrapped, in `font` and `para`, with a hair to spare so that laying /// it out at that width keeps it on its lines; `None` for what isn't text, or text that draws /// nothing. diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index e424be5441..ff7c43fdbc 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -2997,6 +2997,29 @@ fn a_radio_button_sits_where_a_check_box_would() { } } +/// A check box without a width is as wide as its box and caption, at least its minW, not the rest +/// of its group: two choices side by side in a positioned radio group keep their own boxes. +#[test] +fn a_radio_choice_without_a_width_is_as_wide_as_its_content() { + let choice = |name: &str, x: &str, item: &str| { + format!( + r#"{name}{item}"# + ) + }; + let body = format!( + r#"{}{}"#, + choice("Yes", "0mm", "Y"), + choice("No", "20.308mm", "N"), + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let laid = widgets(&form.pages[0]); + let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}")); + let mm = 72.0 / 25.4; + // Each box sits at the right end of its own 20.32mm-wide choice. + assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes")); + assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No")); +} + /// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than /// its field shrinking to fit; and a field without a height is as tall as that caption drawn in /// all the box leaves, not at some narrower width. From 56ebc5f9264001d4d8d135c1a2293d4411a4530c Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 14:40:19 -0400 Subject: [PATCH 040/562] M12: pin a check box's content width and flowed packing Review follow-up. The radio-choice test only reached the minW clamp, so a wrong content formula would still pass. A check box with a reserved caption and no width or minW now must be exactly reserve + box, both positioned (its right-aligned box ends the field) and flowed (two pack side by side in an lr-tb row). Each assertion fails with the measure.rs change reverted: the box lands at 530pt, and the second flowed check box drops to the next row. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/xfa/src/layout/measure.rs | 1 + crates/xfa/src/tests.rs | 25 ++++++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index 6532f6511e..e9405e0518 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -159,6 +159,7 @@ impl Layouter<'_> { let Some(cap) = f.caption.as_ref().filter(|c| c.presence.occupies() && c.placement != Placement::Inline) else { return check; }; + // The 2pt matches `caption_reserve`'s room for a caption without a reserve. let text = || self.natural_width(&cap.value, &ctx.font.apply(&cap.font), &ctx.para.apply(&cap.para)).map_or(0.0, |t| t + 2.0); match cap.placement { Placement::Top | Placement::Bottom => check.max(text()), diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index ff7c43fdbc..d382bbe644 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3015,11 +3015,34 @@ fn a_radio_choice_without_a_width_is_as_wide_as_its_content() { let laid = widgets(&form.pages[0]); let x = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect.x - 36.0).unwrap_or_else(|| panic!("no radio button {name}")); let mm = 72.0 / 25.4; - // Each box sits at the right end of its own 20.32mm-wide choice. + // A negative reserve (as Designer writes) sizes the caption to its text, so content is + // narrower than minW and each box sits at the right end of its own 20.32mm-wide choice. assert!((x("Yes") - (20.32 * mm - 10.0)).abs() < 0.01, "Yes box at {} pt", x("Yes")); assert!((x("No") - ((20.308 + 20.32) * mm - 10.0)).abs() < 0.01, "No box at {} pt", x("No")); } +/// Without a width or minW, a check box is exactly its caption's reserve plus its box, whether +/// placed (its right-aligned box ends there) or flowed (two fit side by side in an lr-tb row). +#[test] +fn a_check_box_without_a_width_is_its_caption_and_box() { + let check = |name: &str, at: &str| { + format!( + r#"{name}10"# + ) + }; + let box_at = |body: String, name: &str| { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + widgets(&form.pages[0]).iter().find(|w| w.name == name).map(|w| (w.rect.x - 36.0, w.rect.y - 36.0)).unwrap() + }; + let placed = format!(r#"{}"#, check("placed", r#"x="0in" y="0in""#)); + let (x, _) = box_at(placed, "placed"); + assert!((x - 36.0).abs() < 0.01, "the box ends the 46pt field, after the 36pt caption: {x}"); + + let flowed = format!(r#"{}{}"#, check("first", ""), check("second", "")); + let (x, y) = box_at(flowed, "second"); + assert!((x - 82.0).abs() < 0.01 && y.abs() < 0.01, "the second check box follows the first on its row: ({x}, {y})"); +} + /// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than /// its field shrinking to fit; and a field without a height is as tall as that caption drawn in /// all the box leaves, not at some narrower width. From 9ca051ac063ae708a2a6fbc480ca610dcf054840 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 18:07:38 -0400 Subject: [PATCH 041/562] fix(ui): list X.PDF in file pickers on Linux and the BSDs (#5481) rfd 0.17 gives each filter extension to the XDG portal, GTK and Zenity as the glob `*.`, which those match case-sensitively, so a "PDF" filter of `pdf` hid `Report.PDF`. `AddFilterAnyCase::add_filter_any_case` passes each letter as a bracket class there (`pdf` -> `[pP][dD][fF]`) and the extension unchanged on Windows, macOS and the web, which already ignore case. Every picker filter in ui-egui now goes through it; the imports used only by native pickers are gated off wasm. Fixes #5481 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/ui-egui/src/app/files.rs | 13 ++++-- crates/ui-egui/src/dialogs/marks_ui.rs | 4 +- crates/ui-egui/src/editing/save.rs | 4 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/platform.rs | 1 + crates/ui-egui/src/platform/file_filter.rs | 50 ++++++++++++++++++++++ crates/ui-egui/src/tools/content_ui.rs | 4 +- crates/ui-egui/src/tools/fill_sign.rs | 3 +- crates/ui-egui/src/tools/measure_ui.rs | 4 +- crates/ui-egui/src/tools/sign_ui.rs | 6 ++- crates/ui-egui/src/tools/sign_ui/dialog.rs | 4 +- crates/ui-egui/src/tools/stamps_ui.rs | 4 +- crates/ui-egui/src/workflows/actions_ui.rs | 4 +- crates/ui-egui/src/workflows/create_ui.rs | 6 ++- crates/ui-egui/src/workflows/files.rs | 41 +++++++++--------- crates/ui-egui/src/workflows/print_ui.rs | 4 +- 16 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 crates/ui-egui/src/platform/file_filter.rs diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c68dc5619..c24e9d9037 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,5 @@ use super::*; +use crate::file_filter::AddFilterAnyCase as _; /// Whether `a` and `b` name the same file: equal, or equal once made absolute against the /// current directory (links are not resolved, as in `last_session`). @@ -607,7 +608,9 @@ impl PdfCraftApp { /// The File ▸ Open picker: PDFs, and images and text that become PDFs. #[cfg(not(target_arch = "wasm32"))] pub(super) fn open_picker() -> rfd::AsyncFileDialog { - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE) + rfd::AsyncFileDialog::new() + .add_filter_any_case("PDF", &["pdf"]) + .add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE) } pub fn open_dialog(&mut self) { @@ -615,7 +618,9 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] self.pick( pickers::PickFor::Open, - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), + rfd::AsyncFileDialog::new() + .add_filter_any_case("PDF", &["pdf"]) + .add_filter_any_case(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), true, ); // Browsers pick files asynchronously; the bytes arrive through `inbox`. @@ -625,8 +630,8 @@ impl PdfCraftApp { let ctx = self.ctx.clone(); wasm_bindgen_futures::spawn_local(async move { if let Some(h) = rfd::AsyncFileDialog::new() - .add_filter("PDF", &["pdf"]) - .add_filter(tl!("Images and text"), &create_ui::CONVERTIBLE) + .add_filter_any_case("PDF", &["pdf"]) + .add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE) .pick_file() .await { diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 085c581119..46dd1eb3a5 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -3,6 +3,8 @@ //! page the range selects, with the marks laid out on it. "Update" opens the same dialog in //! replace mode. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::collections::{HashMap, HashSet}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; @@ -777,7 +779,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M #[cfg(not(target_arch = "wasm32"))] if browse { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]); + .add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]); let epoch = app.dialog_epoch(); app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| { // Only into the dialog that asked: not one closed meanwhile, or opened again since. diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index f685247c9d..b6c04dd9e0 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -1,4 +1,6 @@ use super::{PdfCraftApp, StagingName, staging_suffixes}; +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; /// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file /// on disk is another version than the document came from (#2166). @@ -58,7 +60,7 @@ impl PdfCraftApp { (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, _ => { let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name); let dialog = in_document_folder(dialog, doc_path.as_deref()); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 222bb988e4..ef04581e2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,6 +119,7 @@ pub use persist::units; #[cfg(not(target_arch = "wasm32"))] pub use persist::window_state; use platform::drag_pointer; +use platform::file_filter; #[cfg(not(target_arch = "wasm32"))] pub use platform::file_watch; #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/src/platform.rs b/crates/ui-egui/src/platform.rs index 129f622f52..47196409f9 100644 --- a/crates/ui-egui/src/platform.rs +++ b/crates/ui-egui/src/platform.rs @@ -1,4 +1,5 @@ pub(super) mod drag_pointer; +pub(super) mod file_filter; #[cfg(not(target_arch = "wasm32"))] pub mod file_watch; #[cfg(target_os = "macos")] diff --git a/crates/ui-egui/src/platform/file_filter.rs b/crates/ui-egui/src/platform/file_filter.rs new file mode 100644 index 0000000000..47e11bae72 --- /dev/null +++ b/crates/ui-egui/src/platform/file_filter.rs @@ -0,0 +1,50 @@ +//! Picker filters that list a file whatever the letter case of its extension. +//! +//! On Linux and the BSDs, rfd gives each extension to the XDG portal, GTK or Zenity as the glob +//! `*.`, and those match it case-sensitively: a "PDF" filter of `pdf` hid `Report.PDF`. +//! Windows, macOS and the browser already ignore case, so there the extension passes unchanged. + +/// Whether the platform's pickers match filter globs case-sensitively. +const CASE_SENSITIVE_GLOBS: bool = cfg!(all(unix, not(target_os = "macos"))); + +/// [`rfd::AsyncFileDialog::add_filter`], with each extension matched in any letter case. +pub(crate) trait AddFilterAnyCase { + #[must_use] + fn add_filter_any_case(self, name: impl Into, extensions: &[impl AsRef]) -> Self; +} + +impl AddFilterAnyCase for rfd::AsyncFileDialog { + fn add_filter_any_case(self, name: impl Into, extensions: &[impl AsRef]) -> Self { + let globs: Vec = extensions.iter().map(|e| any_case(e.as_ref(), CASE_SENSITIVE_GLOBS)).collect(); + self.add_filter(name, &globs) + } +} + +/// `ext` as a glob fragment matching any letter case: `pdf` becomes `[pP][dD][fF]`. +fn any_case(ext: &str, case_sensitive: bool) -> String { + if !case_sensitive { + return ext.to_owned(); + } + ext.chars() + .map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::any_case; + + #[test] + fn a_case_sensitive_picker_gets_a_glob_matching_every_case() { + assert_eq!(any_case("pdf", true), "[pP][dD][fF]"); + assert_eq!(any_case("PDF", true), "[pP][dD][fF]"); + assert_eq!(any_case("jp2", true), "[jJ][pP]2"); + assert_eq!(any_case("*", true), "*"); + } + + #[test] + fn a_case_blind_picker_gets_the_extension_unchanged() { + assert_eq!(any_case("pdf", false), "pdf"); + assert_eq!(any_case("*", false), "*"); + } +} diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index e28f20e232..993c6f953b 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -3,6 +3,8 @@ //! (images keep their proportions), double-click text to retype it, format it from the panel, //! Delete to remove it. Each change is one undoable engine edit. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; @@ -849,7 +851,7 @@ impl crate::PdfCraftApp { Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title(title); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, read); diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..0555655660 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -3,6 +3,7 @@ //! signature. Everything is an annotation (typewriter text, PdfCraft-drawn stamps, ink), so it //! can be moved, deleted and undone like any comment. +use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2}; use pdfcraft_engine::signature_scan::{DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, SCAN_MAX_SIDE, ScanError}; use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, SignatureImage, SignaturePhoto, Style, Turn}; @@ -780,7 +781,7 @@ impl crate::PdfCraftApp { let epoch = self.dialog_epoch(); let initials = self.signature_draft.initials; let scan = self.signature_draft.scan_mode; - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!(if scan { "Photo" } else { "Image" }), &["png", "jpg", "jpeg"]); #[cfg(not(target_arch = "wasm32"))] self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| { if app.dialog_epoch() != epoch diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..76fa91cdf4 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -1,4 +1,6 @@ //! Measure objects: page-space clicks, live readings and persistent drawing scales. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets}; use egui::{Color32, Pos2, Stroke}; use pdfcraft_engine::{ @@ -613,7 +615,7 @@ fn export_csv(app: &mut PdfCraftApp) { match app.export_dir_override.as_ref().map(|d| std::path::PathBuf::from(d).join("measurements.csv")) { Some(path) => write(app, path), None => { - let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter("CSV", &["csv"]); + let dialog = rfd::AsyncFileDialog::new().set_file_name("measurements.csv").add_filter_any_case("CSV", &["csv"]); app.ask_one(crate::pickers::Ask::Save(dialog), None, write); } } diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index b00d07279a..08ea0b15a3 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -2,6 +2,8 @@ //! click an empty signature field), the Sign with a Digital ID / Configure New Digital ID / //! Sign as dialogs, the Signatures panel, and the signature message bar. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::path::PathBuf; use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; @@ -501,7 +503,7 @@ impl PdfCraftApp { Some(p) => sign_and_save(self, PathBuf::from(p)), #[cfg(not(target_arch = "wasm32"))] None => { - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf")); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf")); let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); // The signature's page, rectangle and field refer to the document as it is now: // the pick is dropped (with a notice) if it is edited or switched away from @@ -533,7 +535,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn pick_sign_graphic_image(&mut self) { let epoch = self.dialog_epoch(); - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Image"), &["png", "jpg", "jpeg"]); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Image"), &["png", "jpg", "jpeg"]); self.ask_one(crate::pickers::Ask::File(dialog), None, move |app, path| { if app.dialog_epoch() != epoch || app.dialog != Some(crate::Dialog::Sign) { return; diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index feb4fdd207..e627f9a9f8 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -2,6 +2,8 @@ use super::{ Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic, SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; /// Draw the signing dialogs; returns `true` when the dialog should close. pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { @@ -233,7 +235,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { // The picker answers on a later frame, into the draft if the same dialog is still open. #[cfg(not(target_arch = "wasm32"))] if browse { - let dialog = rfd::AsyncFileDialog::new().add_filter(tl!("Digital ID"), &["p12", "pfx"]); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case(tl!("Digital ID"), &["p12", "pfx"]); let epoch = app.dialog_epoch(); app.ask_one(crate::pickers::Ask::File(dialog), None, move |app, p| { // Not one closed meanwhile, or opened again since. diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..3db8d23ad5 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -1,6 +1,8 @@ //! Custom stamps (Add a stamp ▸ Custom stamps ▸ Create): a PDF page or an image saved in the //! user's stamp library under a category and a name, then placed like any stamp. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::sync::Arc; use egui::{Align, Layout}; @@ -145,7 +147,7 @@ impl PdfCraftApp { Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter_any_case(tl!("PDF or image"), &["pdf", "png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title(tl!("Select a file for the stamp")); self.ask_one(crate::pickers::Ask::File(dialog), None, read); } diff --git a/crates/ui-egui/src/workflows/actions_ui.rs b/crates/ui-egui/src/workflows/actions_ui.rs index 5ef299102f..313d55c5f2 100644 --- a/crates/ui-egui/src/workflows/actions_ui.rs +++ b/crates/ui-egui/src/workflows/actions_ui.rs @@ -1,6 +1,8 @@ //! Action Wizard: the actions (PdfCraft's and your own), running one over files in the //! background, and creating or editing an action's steps. Your actions are remembered. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; @@ -186,7 +188,7 @@ impl PdfCraftApp { match self.action_files_override.clone() { Some(p) => run(self, p.into_iter().map(std::path::PathBuf::from).collect()), None => { - let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter("PDF", &["pdf"]); + let dialog = rfd::AsyncFileDialog::new().set_title(title).add_filter_any_case("PDF", &["pdf"]); self.ask(crate::pickers::Ask::Files(dialog), None, run); } } diff --git a/crates/ui-egui/src/workflows/create_ui.rs b/crates/ui-egui/src/workflows/create_ui.rs index 6dceb50657..177ccf6140 100644 --- a/crates/ui-egui/src/workflows/create_ui.rs +++ b/crates/ui-egui/src/workflows/create_ui.rs @@ -1,6 +1,8 @@ //! Create a PDF (blank, from images, from text) and Reduce File Size (execution plan M10.2, //! M11.1). Opening an image or a text file converts it to a new, unsaved PDF, as Acrobat does. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use std::sync::Arc; use crate::PdfCraftApp; @@ -154,7 +156,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) + .add_filter_any_case(tl!("Images"), &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx"]) .set_title(tl!("Choose images")); self.ask(crate::pickers::Ask::Files(dialog), None, |app, files| { let mut images = Vec::new(); @@ -285,7 +287,7 @@ impl PdfCraftApp { match self.save_override.clone() { Some(p) => write(self, p), None => { - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(&name); let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned())); } diff --git a/crates/ui-egui/src/workflows/files.rs b/crates/ui-egui/src/workflows/files.rs index a9313a4714..b33dd32cd2 100644 --- a/crates/ui-egui/src/workflows/files.rs +++ b/crates/ui-egui/src/workflows/files.rs @@ -3,6 +3,7 @@ //! Files are picked asynchronously and used on a later frame: desktop builds through `pickers` //! (a blocking picker crashes the app on macOS), browsers through `requests`. +use crate::file_filter::AddFilterAnyCase as _; use std::sync::Arc; use pdfcraft_engine::{DocId, Edit, SplitBy}; @@ -61,16 +62,16 @@ pub(crate) fn installed_font_files(ctx: &egui::Context) -> Option rfd::AsyncFileDialog { let dialog = rfd::AsyncFileDialog::new(); if matches!(purpose, FilePurpose::TextFont { .. }) { - return dialog.add_filter("TrueType / OpenType font", &["ttf", "otf"]); + return dialog.add_filter_any_case("TrueType / OpenType font", &["ttf", "otf"]); } if purpose == FilePurpose::RedactWords { - return dialog.add_filter(tl!("Text"), &["txt"]); + return dialog.add_filter_any_case(tl!("Text"), &["txt"]); } if !matches!(purpose, FilePurpose::CreateMultiple | FilePurpose::CreateMultipleSeparate | FilePurpose::InsertPages | FilePurpose::Combine) { - return dialog.add_filter("PDF", &["pdf"]); + return dialog.add_filter_any_case("PDF", &["pdf"]); } let all: Vec<&str> = std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).collect(); - dialog.add_filter(tl!("PDF, images and text"), &all).add_filter("PDF", &["pdf"]) + dialog.add_filter_any_case(tl!("PDF, images and text"), &all).add_filter_any_case("PDF", &["pdf"]) } /// The Replace Pages dialog: the chosen file and the ranges (1-based, inclusive). @@ -898,7 +899,7 @@ impl PdfCraftApp { Some(_) => {} None => { let dialog = rfd::AsyncFileDialog::new() - .add_filter(tl!("Comment and form data").to_string(), &extensions) + .add_filter_any_case(tl!("Comment and form data").to_string(), &extensions) .set_title(tl!("Import data").to_string()); let target = self.active_ids().map(|(_, id)| id); self.ask_one(crate::pickers::Ask::File(dialog), target, import); @@ -939,7 +940,7 @@ impl PdfCraftApp { None => { let dialog = rfd::AsyncFileDialog::new() .set_title(tl!("Export").to_string()) - .add_filter(ext.to_uppercase(), &[ext]) + .add_filter_any_case(ext.to_uppercase(), &[ext]) .set_file_name(format!("{stem}.{ext}")); let dialog = crate::editing::in_document_folder(dialog, folder.as_deref()); self.ask_one(crate::pickers::Ask::Save(dialog), None, write); @@ -959,7 +960,7 @@ impl PdfCraftApp { { let dialog = rfd::AsyncFileDialog::new() .set_title(tl!("Select data files to merge").to_string()) - .add_filter(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]); + .add_filter_any_case(tl!("Form data and PDF forms").to_string(), &["fdf", "xfdf", "pdf"]); self.ask(crate::pickers::Ask::Files(dialog), None, |app, paths| { let mut files = Vec::new(); for p in paths { @@ -1004,7 +1005,7 @@ impl PdfCraftApp { None => { let dialog = rfd::AsyncFileDialog::new() .set_title(tl!("Save the spreadsheet").to_string()) - .add_filter("CSV", &["csv"]) + .add_filter_any_case("CSV", &["csv"]) .set_file_name("report.csv"); self.ask_one(crate::pickers::Ask::Save(dialog), None, write); } @@ -1047,22 +1048,22 @@ impl PdfCraftApp { let title = if comments { tl!("Export comments") } else { tl!("Export form data") }; let d = rfd::AsyncFileDialog::new().set_title(title).set_file_name(format!("{stem}.xfdf")); let d = if comments { - d.add_filter("XFDF", &["xfdf"]).add_filter("FDF", &["fdf"]) + d.add_filter_any_case("XFDF", &["xfdf"]).add_filter_any_case("FDF", &["fdf"]) } else if xfa { // An XFA form's data, as Acrobat exports it, first. d.set_file_name(format!("{stem}.xml")) - .add_filter("XML", &["xml"]) - .add_filter("XDP", &["xdp"]) - .add_filter("XFDF", &["xfdf"]) - .add_filter("FDF", &["fdf"]) - .add_filter("CSV", &["csv"]) - .add_filter(tl!("Text").to_string(), &["txt"]) + .add_filter_any_case("XML", &["xml"]) + .add_filter_any_case("XDP", &["xdp"]) + .add_filter_any_case("XFDF", &["xfdf"]) + .add_filter_any_case("FDF", &["fdf"]) + .add_filter_any_case("CSV", &["csv"]) + .add_filter_any_case(tl!("Text").to_string(), &["txt"]) } else { - d.add_filter("XFDF", &["xfdf"]) - .add_filter("FDF", &["fdf"]) - .add_filter("XML", &["xml"]) - .add_filter("CSV", &["csv"]) - .add_filter(tl!("Text").to_string(), &["txt"]) + d.add_filter_any_case("XFDF", &["xfdf"]) + .add_filter_any_case("FDF", &["fdf"]) + .add_filter_any_case("XML", &["xml"]) + .add_filter_any_case("CSV", &["csv"]) + .add_filter_any_case(tl!("Text").to_string(), &["txt"]) }; let d = crate::editing::in_document_folder(d, folder.as_deref()); self.ask_one(crate::pickers::Ask::Save(d), None, write); diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 9145256297..7ea7a18602 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -3,6 +3,8 @@ //! (Size, Poster, Multiple, Booklet); orientation; comments & forms; and a live preview of the //! sheets. Printing sends the print-ready PDF to the system spooler; "Save as PDF" writes it. +#[cfg(not(target_arch = "wasm32"))] +use crate::file_filter::AddFilterAnyCase as _; use egui::{Color32, Pos2, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool}; @@ -551,7 +553,7 @@ impl PdfCraftApp { Some(p) => write(self, p.into()), None => { let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF"); - let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")); + let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")); self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, path| { write(app, path); }) From 85c10051487751a2b1616f7290e92f0d2e590364 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 19:45:23 +0100 Subject: [PATCH 042/562] fix(render): a JBIG2 page's segments are bounded in what they hold and refer to (hayro-jbig2 patches 5 to 9, hayro-syntax 29) Each JBIG2 bitmap was bounded on its own, but a few kilobytes could still exhaust memory: nothing bounded what a page's segments hold for later ones, or how far one segment could multiply them. Measured on black-box streams, peak memory before and after: - intermediate regions, 16 in 774 bytes: 514 MiB, now 98 MiB; - an 8 MiB symbol exported 1,200 times, a 2,989-byte PDF: killed at 8 GiB, now 79 MB; - dictionaries of zero-width symbols: 781 MB, now 72 MB; - dictionaries retaining their coding contexts, 1.1 MB: 2.89 GB, now 71 MB; - 65,535 references to one dictionary, 78 KB: about 17 GB, now 6 MB; - a halftone grid of 16,384 cells a side, 98 bytes: 1.1 GB, now 2.4 MB; - a pattern dictionary of 65,535 patterns, 48 bytes: 539 MB, now 15 MB, and it still draws; - a custom Huffman table behind nested Flate, a 1,297-byte PDF: killed at 8 GiB, now 278 MB, which is mostly the decoded stream; - 11-byte segments behind nested Flate in a stream and its globals, a 4,765-byte PDF: 5 GB, now 602 MB, which is mostly the two decoded streams; - an Indexed image whose page header claims 2^20 a side, a 743-byte PDF: aborted asking for 137 GB, now 23 MB; - a chain of 4,000 /JBIG2Globals streams that are JBIG2 themselves, on a 2 MiB stack: overflowed it and aborted, now 24 MB. So PageState counts what it stores: regions, pattern and symbol dictionaries and Huffman tables, with bitmap headers, retained contexts and a charge per item, up to 64 MiB, two square bitmaps of 16,384 a side. A dictionary's new and exported symbols count against what is left as they are made, not once it is stored. A segment may refer to at most 2^20 symbols, checked before any are collected. A halftone grid has at most 2^25 cells. 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. 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. A page past the bounds on a bitmap is refused when the stream is opened, since callers size their output from its header before the page bitmap is made. And in hayro-syntax, a /JBIG2Globals stream that is itself JBIG2 isn't decoded: globals are raw segments, so it reads as globals that can't be decoded. An image past a bound isn't drawn, as with the other caps, and the rest of the page is. This bounds memory, not time: decoding work has no total yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 496 ++++++++++++++++++++++ vendor/README.md | 4 +- vendor/hayro-jbig2/src/bitmap.rs | 35 +- vendor/hayro-jbig2/src/decode/halftone.rs | 4 +- vendor/hayro-jbig2/src/decode/pattern.rs | 24 +- vendor/hayro-jbig2/src/decode/symbol.rs | 63 ++- vendor/hayro-jbig2/src/error.rs | 10 +- vendor/hayro-jbig2/src/file.rs | 37 +- vendor/hayro-jbig2/src/gray_scale.rs | 4 +- vendor/hayro-jbig2/src/huffman_table.rs | 19 + vendor/hayro-jbig2/src/lib.rs | 162 +++++-- vendor/hayro-syntax/src/filter/jbig2.rs | 5 + 12 files changed, 805 insertions(+), 58 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 11266f057e..4bf29f9157 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3527,6 +3527,502 @@ fn jbig2_regions_of_billions_of_pixels_are_refused() { assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } +/// One embedded JBIG2 segment (ISO 14492 §7.2) of page 1. More than four references take the long +/// form, whose retention flags are left clear. +fn jbig2_segment(out: &mut Vec, number: u32, kind: u8, refs: &[u8], data: &[u8]) { + out.extend_from_slice(&number.to_be_bytes()); + out.push(kind); + if refs.len() <= 4 { + out.push(u8::try_from(refs.len()).unwrap() << 5); + } else { + out.extend_from_slice(&(0xE000_0000u32 | u32::try_from(refs.len()).unwrap()).to_be_bytes()); + out.extend(std::iter::repeat_n(0u8, (refs.len() + 1).div_ceil(8))); + } + out.extend_from_slice(refs); + out.push(1); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(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 { + 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(&[0; 8]); // resolution + page.extend_from_slice(&[4, 0, 0]); // flags (default pixel black), striping + let mut stream = Vec::new(); + jbig2_segment(&mut stream, 0, 48, &[], &page); + stream +} + +/// A region's information field, at the page's corner. +fn jbig2_region_info(width: u32, height: u32) -> Vec { + let mut region = Vec::new(); + region.extend_from_slice(&width.to_be_bytes()); + region.extend_from_slice(&height.to_be_bytes()); + region.extend_from_slice(&[0; 9]); // x, y, combination operator + region +} + +/// Whether a 16 × 2 JBIG2 image decodes: drawn at 5..25 both ways, it draws black, and refused it +/// leaves the page white. The red square beside it shows the rest of the page drew either way. +fn jbig2_image_draws(stream: &[u8]) -> bool { + jbig2_image_draws_in(stream, "/DeviceGray") +} + +/// [`jbig2_image_draws`], for an image in colour space `space`. +fn jbig2_image_draws_in(stream: &[u8], space: &str) -> bool { + jbig2_image_draws_with(stream, space, "", b"") +} + +/// [`jbig2_image_draws_in`], with entries for the image's dictionary and more objects, from 6 up. +fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[u8]) -> bool { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q 1 0 0 rg 0 0 4 4 re f"; + 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 16 /Height 2 /BitsPerComponent 1 /ColorSpace {space} {entries} /Filter /JBIG2Decode /Length {} >> stream\n", stream.len()) + .as_bytes(), + ); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); + // Inside the image: top-down rows 15..35. + match &page.rgba[((25 * 40 + 15) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither the image nor the page: {other:?}"), + } +} + +/// Bits written from the top of each byte, as JBIG2's Huffman-coded data is. +#[derive(Default)] +struct Bits { + bytes: Vec, + used: u32, +} + +impl Bits { + fn put(&mut self, value: u32, count: u32) { + for i in (0..count).rev() { + if self.used.is_multiple_of(8) { + self.bytes.push(0); + } + if (value >> i) & 1 != 0 { + *self.bytes.last_mut().unwrap() |= 0x80 >> (self.used % 8); + } + self.used += 1; + } + } + + fn align(&mut self) { + self.used = self.used.div_ceil(8) * 8; + } + + /// `value` in table B.1 (ISO 14492 Annex B). + fn table_b1(&mut self, value: u32) { + match value { + 0..=15 => { + self.put(0, 1); + self.put(value, 4); + } + 16..=271 => { + self.put(0b10, 2); + self.put(value - 16, 8); + } + 272..=65807 => { + self.put(0b110, 3); + self.put(value - 272, 16); + } + _ => { + self.put(0b111, 3); + self.put(value - 65808, 32); + } + } + } +} + +/// A Huffman-coded symbol dictionary of `symbols` symbols of width 0 and height 1, exported or not: +/// each holds no pixels, and costs a bitmap's header all the same. +fn zero_width_jbig2_dictionary(symbols: u32, exported: bool) -> Vec { + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&(if exported { symbols } else { 0 }).to_be_bytes()); + dictionary.extend_from_slice(&symbols.to_be_bytes()); // new + let mut bits = Bits::default(); + bits.put(0, 1); // height class delta 1 (B.4) + for _ in 0..symbols { + bits.put(0, 1); // width delta 0 (B.2) + } + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + if exported { + bits.table_b1(0); // the first export run isn't exported + } + bits.table_b1(symbols); + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// A Huffman-coded symbol dictionary of `symbols` white symbols of width 1 and `height` rows, none +/// exported, for `height` from 76 up. +fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { + let mut bits = Bits::default(); + bits.put(0b11111, 5); // height class delta, the upper line of table B.4 + bits.put(height - 76, 32); + bits.put(0b10, 2); // width delta 1 (B.2) + for _ in 1..symbols { + bits.put(0, 1); // width delta 0 (B.2) + } + 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); + } + 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 { + let mut region = jbig2_region_info(width, height); + region.push(0x08); // arithmetic coding, template 0, typical prediction + region.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels + // Every row typical, the first white: pdfcraft-filters' coding of a white 16,384 square. + region.extend_from_slice(&[0xb6, 0xc2, 0x1f, 0xff, 0x7f, 0xff, 0xac]); + region +} + +/// A pattern dictionary of `patterns` patterns of width 1 and `height` rows, with nothing coded. +fn jbig2_pattern_dictionary(height: u8, patterns: u32) -> Vec { + let mut dictionary = vec![0, 1, height]; + dictionary.extend_from_slice(&(patterns - 1).to_be_bytes()); + dictionary +} + +/// A 1 × 1 halftone region over a grid of `width` × `height` cells, all at the region's corner, +/// coded by MMR or arithmetically. +fn jbig2_halftone(width: u32, height: u32, mmr: bool) -> Vec { + let mut halftone = jbig2_region_info(1, 1); + halftone.push(u8::from(mmr)); // template 0 + halftone.extend_from_slice(&width.to_be_bytes()); + halftone.extend_from_slice(&height.to_be_bytes()); + halftone.extend_from_slice(&[0; 12]); // grid offset and vector + halftone +} + +/// Each JBIG2 segment's bitmaps are bounded, but nothing bounded what a page's segments hold for +/// later ones: a stream of a few dozen bytes an intermediate region (16,384 samples a side, every +/// row typical) held 33 MB each, so a few kilobytes could exhaust memory. Vendored hayro-jbig2 +/// patch: what regions, dictionaries and tables hold is at most 64 MiB (`MAX_RETAINED_WORDS`), two +/// such squares, which are exactly the bound. +#[test] +fn jbig2_regions_held_past_a_pages_bound_are_refused() { + let stream = |regions: u32| { + let mut stream = black_jbig2_page(); + for number in 1..=regions { + jbig2_segment(&mut stream, number, 36, &[], &jbig2_white_region(16384, 16384)); + } + stream + }; + assert!(jbig2_image_draws(&stream(2)), "two such squares fit"); + assert!(!jbig2_image_draws(&stream(3)), "a third doesn't"); +} + +/// A symbol of width 0 holds no pixels, and still costs a bitmap's header: 5 KB of dictionaries of +/// them held 781 MB, with the bound counting pixels only. +#[test] +fn jbig2_symbols_without_pixels_count_towards_a_pages_bound() { + let stream = |dictionaries: u32| { + let mut stream = black_jbig2_page(); + let dictionary = zero_width_jbig2_dictionary(32767, true); + for number in 1..=dictionaries { + jbig2_segment(&mut stream, number, 0, &[], &dictionary); + } + stream + }; + assert!(jbig2_image_draws(&stream(1)), "one dictionary fits"); + assert!(!jbig2_image_draws(&stream(44)), "44 × 32,767 headers are past the bound"); +} + +/// A symbol dictionary may retain its coding contexts for a later one, 73,728 bytes each time, and +/// 1.1 MB of dictionaries with no symbols, 103 KB behind Flate, held 2.89 GB. +#[test] +fn jbig2_retained_contexts_count_towards_a_pages_bound() { + let stream = |dictionaries: u32| { + let mut dictionary = vec![0x02, 0x00]; // arithmetic coding, contexts retained + dictionary.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels + dictionary.extend_from_slice(&[0; 8]); // exported, new + let mut stream = black_jbig2_page(); + for number in 1..=dictionaries { + jbig2_segment(&mut stream, number, 0, &[], &dictionary); + } + stream + }; + assert!(jbig2_image_draws(&stream(1)), "one dictionary fits"); + assert!(!jbig2_image_draws(&stream(1000)), "a thousand don't"); +} + +/// Huffman tables are held for later segments too, each taking some 586 bytes however small it +/// is, so 200,000 of them are past the bound, and they were held to it at 4 times that. +#[test] +fn jbig2_huffman_tables_count_towards_a_pages_bound() { + let stream = |tables: u32| { + // One-bit prefix and range lengths, values 0 to 1: a table line, then the lower range. + let mut table = vec![0]; + table.extend_from_slice(&0i32.to_be_bytes()); + table.extend_from_slice(&1i32.to_be_bytes()); + table.push(0b1010_0000); + let mut stream = black_jbig2_page(); + for number in 1..=tables { + jbig2_segment(&mut stream, number, 53, &[], &table); + } + stream + }; + assert!(jbig2_image_draws(&stream(1)), "one table fits"); + assert!(!jbig2_image_draws(&stream(200_000)), "200,000 don't"); +} + +/// New symbols count as they are decoded, whatever the dictionary exports: rows take a word at +/// least, so symbols of one column held 270 MiB within the bound on a dictionary's pixels, and +/// exporting none of them they were never counted at all. +#[test] +fn jbig2_new_symbols_count_towards_a_pages_bound() { + let stream = |height: u32| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &narrow_jbig2_dictionary(65534, height)); + stream + }; + assert!(jbig2_image_draws(&stream(100)), "65,534 symbols of 100 rows fit"); + assert!(!jbig2_image_draws(&stream(300)), "of 300 rows they don't"); +} + +/// New symbols are held to what the page has left, headers included, not to the whole bound. These +/// two regions leave it 512 words, and the dictionary after them exports nothing, so only that +/// check can refuse it. +#[test] +fn jbig2_new_symbols_are_held_to_what_a_page_has_left() { + let stream = |dictionary: &[u8]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 36, &[], &jbig2_white_region(16384, 16384)); + jbig2_segment(&mut stream, 2, 36, &[], &jbig2_white_region(16384, 16383)); + jbig2_segment(&mut stream, 3, 0, &[], dictionary); + stream + }; + assert!(jbig2_image_draws(&stream(&narrow_jbig2_dictionary(1, 100))), "100 rows fit"); + assert!(!jbig2_image_draws(&stream(&narrow_jbig2_dictionary(1, 1000))), "1,000 rows don't"); + assert!(jbig2_image_draws(&stream(&zero_width_jbig2_dictionary(10, false))), "10 headers fit"); + assert!(!jbig2_image_draws(&stream(&zero_width_jbig2_dictionary(100, false))), "100 headers don't"); +} + +/// Pattern dictionaries count towards the bound, with no shifted copies to do it for them: at 44 +/// words a pattern, 6 of these are past it. +#[test] +fn jbig2_pattern_dictionaries_count_towards_a_pages_bound() { + let stream = |dictionaries: u32| { + let mut stream = black_jbig2_page(); + for number in 1..=dictionaries { + jbig2_segment(&mut stream, number, 16, &[], &jbig2_pattern_dictionary(32, 65535)); + } + stream + }; + assert!(jbig2_image_draws(&stream(5)), "5 fit"); + assert!(!jbig2_image_draws(&stream(6)), "6 don't"); +} + +/// Shifted copies are only a speed-up, so they are never what gets an image refused: each of these +/// tables is within its own limit, and the fourth won't fit what the page has left, so it is left +/// out, where counting it refused the image. +#[test] +fn jbig2_shifted_patterns_never_refuse_an_image() { + let mut stream = black_jbig2_page(); + for number in 1..=4 { + jbig2_segment(&mut stream, number, 16, &[], &jbig2_pattern_dictionary(32, 2048)); + } + assert!(jbig2_image_draws(&stream)); +} + +/// Ordinary globals still reach the decoder, with a filter on them or without: here they hold the +/// page information, so the image draws only if they are read. +#[test] +fn jbig2_globals_are_read() { + let globals = black_jbig2_page(); + let hex: String = globals.iter().map(|b| format!("{b:02x}")).collect(); + let mut stream = Vec::new(); + jbig2_segment(&mut stream, 1, 62, &[], &[]); // an extension, which decoders skip + for (filter, data) in [("", globals.clone()), ("/Filter /ASCIIHexDecode", hex.into_bytes())] { + let mut objects = format!("6 0 obj << {filter} /Length {} >> stream\n", data.len()).into_bytes(); + objects.extend_from_slice(&data); + objects.extend_from_slice(b"\nendstream endobj\n"); + assert!(jbig2_image_draws_with(&stream, "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects), "{filter}"); + } +} + +/// Globals are raw segments, but a `/JBIG2Globals` stream could itself be JBIG2, naming the next +/// such stream, and each read decoded the next: 4,000 of them, here on a test thread's 2 MiB stack, +/// overflowed it, which aborts the process. Vendored hayro-syntax patch: such a stream reads as +/// globals that can't be decoded, so the image decodes without them. +#[test] +fn jbig2_globals_that_are_jbig2_themselves_are_not_decoded() { + let links = 4000; + let mut objects = Vec::new(); + for number in 0..links { + let link = 6 + number; + let next = link + 1; + let parms = if number + 1 < links { format!("/DecodeParms << /JBIG2Globals {next} 0 R >>") } else { String::new() }; + objects.extend_from_slice(format!("{link} 0 obj << /Filter /JBIG2Decode {parms} /Length 0 >> stream\n\nendstream endobj\n").as_bytes()); + } + assert!(jbig2_image_draws_with(&black_jbig2_page(), "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects)); +} + +/// An image's page header can claim any size, and the Indexed path reserves its output from it +/// before the page bitmap is sized: a 743-byte PDF asked for 137 GB, which aborts the process. +/// Vendored hayro-jbig2 patch: a page past the bounds is refused when the stream is opened. +#[test] +fn jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them() { + let indexed = "[/Indexed /DeviceGray 1 <00FF>]"; + assert!(jbig2_image_draws_in(&black_jbig2_page(), indexed), "a page within the bounds"); + let mut page = Vec::new(); + page.extend_from_slice(&(1u32 << 20).to_be_bytes()); + page.extend_from_slice(&(1u32 << 20).to_be_bytes()); + page.extend_from_slice(&[0; 11]); // resolution, flags, striping + let mut stream = Vec::new(); + jbig2_segment(&mut stream, 0, 48, &[], &page); + assert!(!jbig2_image_draws_in(&stream, indexed), "the page draws without it"); +} + +/// One custom Huffman table could grow without bound as it was read, before anything counted it: +/// a line takes 2 bits of input and 8 bytes here, so 250 MiB behind nested Flate, in a 1,297-byte +/// PDF, was killed at 8 GiB. Vendored hayro-jbig2 patch: at most 2^16 lines. +#[test] +fn jbig2_huffman_tables_of_too_many_lines_are_refused() { + let stream = |lines: i32| { + // One-bit prefix and range lengths, all zero: a line per value. + let mut table = vec![0]; + table.extend_from_slice(&0i32.to_be_bytes()); + table.extend_from_slice(&lines.to_be_bytes()); + table.resize(table.len() + usize::try_from(lines).unwrap().div_ceil(4) + 1, 0); + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 53, &[], &table); + stream + }; + assert!(jbig2_image_draws(&stream(1 << 16)), "2^16 lines are allowed"); + assert!(!jbig2_image_draws(&stream((1 << 16) + 1)), "one more isn't"); +} + +/// A stream's segments are all parsed and sorted before the first is decoded: 11 bytes of input took +/// 64 here, so behind nested Flate a 4,765-byte PDF reached 5 GB, and failed only afterwards. +/// Vendored hayro-jbig2 patch: at most 2^20 segments. +#[test] +fn jbig2_streams_of_too_many_segments_are_refused() { + let stream = |segments: u32| { + let mut stream = black_jbig2_page(); + for number in 1..segments { + jbig2_segment(&mut stream, number, 62, &[], &[]); // an extension, which decoders skip + } + stream + }; + assert!(jbig2_image_draws(&stream(1 << 20)), "2^20 segments are allowed"); + assert!(!jbig2_image_draws(&stream((1 << 20) + 1)), "one more isn't"); +} + +/// References take 4 bytes each for 1 of input, a stream's total as much as a segment's. +/// Vendored hayro-jbig2 patch: at most 2^22 in a stream, which is 64 × 65,535 + 64. +#[test] +fn jbig2_streams_of_too_many_references_are_refused() { + let stream = |last: usize| { + let mut stream = black_jbig2_page(); + for number in 1..=64 { + jbig2_segment(&mut stream, number, 62, &vec![0u8; 65535], &[]); + } + jbig2_segment(&mut stream, 65, 62, &vec![0u8; last], &[]); + stream + }; + assert!(jbig2_image_draws(&stream(64)), "2^22 references are allowed"); + assert!(!jbig2_image_draws(&stream(65)), "one more isn't"); +} + +/// One segment may refer to the same dictionary again and again, and collecting its symbols took a +/// pointer each: 65,535 references to a dictionary of 32,767 symbols, in a 78 KB stream, asked for +/// 17 GB. Vendored hayro-jbig2 patch: at most 2^20 symbols per segment, and 32 × 32,767 + 32 is +/// exactly that. All three segment kinds that take symbols are held to it: the text regions, whether +/// immediate or intermediate, and a dictionary that refers to others. +#[test] +fn jbig2_segments_referring_to_too_many_symbols_are_refused() { + let mut text = jbig2_region_info(1, 1); + text.extend_from_slice(&[0, 0]); // arithmetic coding, no refinement + text.extend_from_slice(&[0; 4]); // no instances + // A dictionary of no symbols of its own, exporting none of those it refers to. + let dictionary = |symbols: u32| { + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&[0; 8]); // exported, new + let mut bits = Bits::default(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + dictionary.extend_from_slice(&bits.bytes); + dictionary + }; + let stream = |kind: u8, small: usize, data: &[u8]| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 0, &[], &zero_width_jbig2_dictionary(32767, true)); + jbig2_segment(&mut stream, 2, 0, &[], &zero_width_jbig2_dictionary(32, true)); + let mut refs = vec![1u8; 32]; + refs.extend(std::iter::repeat_n(2u8, small)); + jbig2_segment(&mut stream, 3, kind, &refs, data); + stream + }; + let limit = 1 << 20; + for (what, kind, allowed, past) in [ + ("immediate text region", 6, text.clone(), text.clone()), + ("intermediate text region", 4, text.clone(), text.clone()), + ("symbol dictionary", 0, dictionary(limit), dictionary(limit + 32)), + ] { + assert!(jbig2_image_draws(&stream(kind, 1, &allowed)), "{what}: 2^20 symbols are allowed"); + assert!(!jbig2_image_draws(&stream(kind, 2, &past)), "{what}: 32 more aren't"); + } +} + +/// A halftone grid takes a 4-byte value per cell while it decodes, so the bound of 2^28 cells let a +/// 98-byte segment take 1 GiB. Vendored hayro-jbig2 patch: at most 2^25 cells, however it's coded. +#[test] +fn jbig2_halftone_grids_past_their_bound_are_refused() { + let stream = |width: u32, height: u32, mmr: bool| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 16, &[], &jbig2_pattern_dictionary(1, 2)); + jbig2_segment(&mut stream, 2, 22, &[1], &jbig2_halftone(width, height, mmr)); + stream + }; + assert!(jbig2_image_draws(&stream(8192, 4096, false)), "2^25 cells are allowed"); + assert!(!jbig2_image_draws(&stream(8193, 4096, false)), "one more column isn't"); + assert!(!jbig2_image_draws(&stream(8193, 4096, true)), "nor when it is MMR-coded"); +} + +/// A pattern dictionary's shifted copies only speed up drawing, and 48 bytes could ask for 539 MB +/// of them, past what a page may hold. Vendored hayro-jbig2 patch: past 16 MiB they are left out, +/// so such a dictionary is drawn the slow way rather than refused. +#[test] +fn jbig2_pattern_dictionaries_too_large_to_shift_still_draw() { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 16, &[], &jbig2_pattern_dictionary(32, 65535)); + jbig2_segment(&mut stream, 2, 22, &[1], &jbig2_halftone(1, 1, false)); + assert!(jbig2_image_draws(&stream)); +} + /// From `cargo xtask fuzz`: one changed byte in a symbol dictionary's arithmetic-coded data /// hung the renderer. Two of its loops can stop advancing: an export run of length 0, and a /// height class whose first width is already the end-of-class marker. Past the end of its diff --git a/vendor/README.md b/vendor/README.md index 84b2546bf3..88c39bee44 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,9 +41,9 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | +| 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 | (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` | | 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) | (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` | +| 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 | (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` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 35ed975615..e960ab59d8 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -19,6 +19,11 @@ pub(crate) const MAX_DIMENSION: u32 = u16::MAX as u32; /// pixels, decoded one by one. pub(crate) const MAX_PIXELS: u64 = 1 << 28; +/// PdfCraft patch: the most cells a halftone grid may have, which still gives the largest page +/// (see [`MAX_PIXELS`]) cells of 3 × 3, unrotated. Each cell takes a 4-byte value while the grid decodes, so +/// the pixel bound alone let a 98-byte segment take 1 GiB; this one leaves 128 MiB. +pub(crate) const MAX_GRID_CELLS: u64 = 1 << 25; + /// The underlying word type for packed pixel storage. /// Change this to `u32` to switch back to 32-bit words. pub(crate) type Word = u32; @@ -26,6 +31,23 @@ pub(crate) type Word = u32; pub(crate) const WORD_BITS: u32 = Word::BITS; pub(crate) const WORD_SHIFT: u32 = WORD_BITS - 1; +/// PdfCraft patch: the words a [`Bitmap`] takes before its pixels. A symbol of width 0 has no +/// pixels and still costs this: 5 KB of zero-width dictionaries held 781 MB. +pub(crate) const BITMAP_WORDS: usize = size_of::() / size_of::(); + +/// PdfCraft patch: refuse a page past the bounds on a bitmap (see `MAX_PIXELS`). A stream is held +/// to this when it is opened as well as when its page bitmap is sized: a caller sizes its own +/// 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()); + } + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(OverflowError::BitmapDimension.into()); + } + Ok(()) +} + /// A decoded bitmap with position information. /// /// "Pixels decoded by the MMR decoder having the value 'black' shall be treated @@ -51,6 +73,11 @@ pub(crate) struct Bitmap { } impl Bitmap { + /// PdfCraft patch: the words this bitmap holds, its header included. + pub(crate) fn words(&self) -> usize { + self.data.len().saturating_add(BITMAP_WORDS) + } + /// Create a new bitmap filled with white pixels (false). /// /// The bitmap is positioned at (0, 0). @@ -93,13 +120,7 @@ impl Bitmap { height: u32, default_pixel: bool, ) -> Result<()> { - if width > MAX_DIMENSION || height > MAX_DIMENSION { - return Err(OverflowError::BitmapDimension.into()); - } - // PdfCraft patch: see `MAX_PIXELS` (the page bitmap is sized here). - if u64::from(width) * u64::from(height) > MAX_PIXELS { - return Err(OverflowError::BitmapDimension.into()); - } + check_page_size(width, height)?; let stride = width.div_ceil(WORD_BITS); let default_word: Word = if default_pixel { !0 } else { 0 }; diff --git a/vendor/hayro-jbig2/src/decode/halftone.rs b/vendor/hayro-jbig2/src/decode/halftone.rs index eb5c1c21e8..6ea7e4be34 100644 --- a/vendor/hayro-jbig2/src/decode/halftone.rs +++ b/vendor/hayro-jbig2/src/decode/halftone.rs @@ -97,8 +97,8 @@ pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result MAX_DIMENSION || grid_height > MAX_DIMENSION { bail!(OverflowError::GridDimension); } - // PdfCraft patch: the product is bounded too (see `bitmap::MAX_PIXELS`). - if u64::from(grid_width) * u64::from(grid_height) > crate::bitmap::MAX_PIXELS { + // PdfCraft patch: the product is bounded too (see `bitmap::MAX_GRID_CELLS`). + if u64::from(grid_width) * u64::from(grid_height) > crate::bitmap::MAX_GRID_CELLS { bail!(OverflowError::GridDimension); } diff --git a/vendor/hayro-jbig2/src/decode/pattern.rs b/vendor/hayro-jbig2/src/decode/pattern.rs index 2c15d70b8f..7507fa551f 100644 --- a/vendor/hayro-jbig2/src/decode/pattern.rs +++ b/vendor/hayro-jbig2/src/decode/pattern.rs @@ -8,10 +8,15 @@ use crate::error::{OverflowError, ParseError, Result}; use crate::reader::Reader; use alloc::vec::Vec; +/// PdfCraft patch: the most words of shifted copies a dictionary keeps, 16 MiB, where 256 patterns +/// of 32 rows take 2 MiB. A 48-byte dictionary of 65,535 such patterns asked for 539 MB. +const MAX_SHIFTED_WORDS: u64 = 1 << 22; + /// Decode a pattern dictionary segment (7.4.4.2, 6.7). pub(crate) fn decode( header: &PatternDictionaryHeader<'_>, scratch: &mut ScratchBuffers, + room: u64, ) -> Result { let pattern_width = header.pattern_width as u32; let pattern_height = header.pattern_height as u32; @@ -133,7 +138,19 @@ pub(crate) fn decode( // for all possible 32-bit alignments, which later on allows us to // very easily blit it into the backdrop, significantly speeding up // the whole process. - let shifted_patterns = if pattern_width <= WORD_BITS && pattern_height <= WORD_BITS { + // + // PdfCraft patch: only when the table is small, and fits what the page may still hold + // (`room`), so that a speed-up never gets an image refused. It can be left out: + // `shifted_pattern` then returns `None` and the patterns are combined the slow way. + let shifted_words = (patterns.len() as u64) + .saturating_mul(32 * 2) + .saturating_mul(u64::from(pattern_height)); + let pattern_words = patterns.iter().map(|p| p.words() as u64).fold(0, u64::saturating_add); + let shifted_patterns = if pattern_width <= WORD_BITS + && pattern_height <= WORD_BITS + && shifted_words <= MAX_SHIFTED_WORDS + && shifted_words.saturating_add(pattern_words) <= room + { let h = pattern_height as usize; let mut data = Vec::with_capacity(patterns.len() * 32 * h * 2); for pattern in &patterns { @@ -180,6 +197,11 @@ impl PatternDictionary { let start = (pattern_idx * 32 + bit_offset as usize) * len; self.shifted_patterns.get(start..start + len) } + + /// PdfCraft patch: the words the dictionary holds, its patterns and their shifted copies. + pub(crate) fn words(&self) -> usize { + self.patterns.iter().map(Bitmap::words).fold(self.shifted_patterns.len(), usize::saturating_add) + } } /// Parsed pattern dictionary segment header (7.4.4.1). diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index f1aa8a6639..082856c530 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -4,7 +4,7 @@ use alloc::vec; use alloc::vec::Vec; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, WORD_BITS}; +use crate::bitmap::{BITMAP_WORDS, Bitmap, WORD_BITS, Word}; use crate::decode::generic::{decode_bitmap_mmr, parse_adaptive_template_pixels}; use crate::decode::text::{ DecodeContext, ReferenceCorner, TextRegionContexts, TextRegionFlags, TextRegionHeader, @@ -45,6 +45,7 @@ pub(crate) fn decode( referred_tables: &[HuffmanTable], standard_tables: &StandardHuffmanTables, input_contexts: Option<&RetainedContexts>, + room: u64, ) -> Result { let num_new_symbols = header.num_new_symbols; @@ -58,6 +59,8 @@ pub(crate) fn decode( total_width: 0, height_class_height: 0, pixels: 0, + new_words: 0, + room, header, standard_tables, }; @@ -130,6 +133,13 @@ pub(crate) fn decode( if ctx.pixels > MAX_DICTIONARY_PIXELS { bail!(OverflowError::BitmapDimension); } + // PdfCraft patch: see `room`. Rows take a word at least, so symbols of one column + // held 270 MiB within the pixel bound, however few the dictionary exported. + let rows = u64::from(symbol_width.div_ceil(WORD_BITS)) * u64::from(ctx.height_class_height); + ctx.new_words = ctx.new_words.saturating_add(rows).saturating_add(BITMAP_WORDS as u64); + if ctx.new_words > ctx.room { + bail!(OverflowError::RetainedBitmaps); + } match (ctx.header.flags.use_huffman, ctx.header.flags.use_refagg) { (false, false) => { @@ -202,6 +212,18 @@ pub(crate) struct SymbolDictionary { pub(crate) retained_contexts: Option, } +impl SymbolDictionary { + /// PdfCraft patch: the words the dictionary holds, its retained contexts included. Those are + /// a byte each, and 1.1 MB of dictionaries that retain them, 103 KB behind Flate, held 2.89 GB. + pub(crate) fn words(&self) -> usize { + let contexts = self.retained_contexts.as_ref().map_or(0, |contexts| { + let bytes = contexts.generic_region.len().saturating_add(contexts.refinement_region.len()); + bytes.saturating_mul(size_of::()) / size_of::() + }); + self.exported_symbols.iter().map(Bitmap::words).fold(contexts, usize::saturating_add) + } +} + /// Decode a symbol bitmap using refinement/aggregate coding (6.5.8.2). fn decode_refinement_aggregation_bitmap( ctx: &mut SymbolDecodeContext<'_>, @@ -463,6 +485,12 @@ struct SymbolDecodeContext<'a> { symbols_decoded_count: u32, total_width: u32, height_class_height: u32, + /// PdfCraft patch: the words the page may still hold, which neither the new symbols nor the + /// exported ones may pass. They are counted as they are made: counted only once the + /// dictionary was stored, 8 MiB symbols exported again and again killed the process. + room: u64, + /// PdfCraft patch: the words of the new symbols so far (see `room`). + new_words: u64, /// PdfCraft patch: symbol pixels decoded so far (see `MAX_DICTIONARY_PIXELS`). pixels: u64, } @@ -708,6 +736,7 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { u32::try_from(value).map_err(|_| HuffmanError::InvalidCode.into()) }; let mut exported = Vec::with_capacity(ctx.header.num_exported_symbols as usize); + let mut exported_words: u64 = 0; let mut index: u32 = 0; let mut should_export = false; // PdfCraft patch: see `EXTRA_EMPTY_STEPS`. @@ -730,9 +759,13 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { let symbol = ctx .symbols .get(symbol_idx as usize) - .ok_or(SymbolError::OutOfRange)? - .clone(); - exported.push(symbol); + .ok_or(SymbolError::OutOfRange)?; + // PdfCraft patch: see `room`. + exported_words = exported_words.saturating_add(symbol.words() as u64); + if exported_words > ctx.room { + bail!(OverflowError::RetainedBitmaps); + } + exported.push(symbol.clone()); } } @@ -864,3 +897,25 @@ pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result write!(f, "overflow in reference offset computation"), Self::Index => write!(f, "overflow in index computation"), Self::GridDimension => write!(f, "overflow in grid dimension computation"), - Self::SymbolCount => write!(f, "a symbol dictionary has too many symbols"), + Self::SymbolCount => write!(f, "too many symbols in a dictionary or referred to"), + Self::RetainedBitmaps => write!(f, "the segments retain too many bitmaps"), + Self::SegmentCount => write!(f, "too many segments or references in a stream"), } } } diff --git a/vendor/hayro-jbig2/src/file.rs b/vendor/hayro-jbig2/src/file.rs index 1440a22efa..5b8bbe983b 100644 --- a/vendor/hayro-jbig2/src/file.rs +++ b/vendor/hayro-jbig2/src/file.rs @@ -5,12 +5,39 @@ use alloc::vec::Vec; -use crate::error::{FormatError, ParseError, Result, bail}; +use crate::error::{FormatError, OverflowError, ParseError, Result, bail}; use crate::reader::Reader; use crate::segment::{ Segment, SegmentType, parse_segment, parse_segment_data, parse_segment_header, }; +/// PdfCraft patch: the most segments a stream, with its globals, may have. They are all parsed and +/// sorted before the first is decoded, at about 64 bytes for 11 of input, so behind nested Flate a +/// 4,765-byte PDF reached 5 GB and failed only afterwards. +const MAX_SEGMENTS: usize = 1 << 20; + +/// PdfCraft patch: the most references those segments may make, 4 bytes each for 1 of input. +const MAX_REFERENCES: usize = 1 << 22; + +/// PdfCraft patch: the segments of a stream so far (see [`MAX_SEGMENTS`], [`MAX_REFERENCES`]). +#[derive(Default)] +pub(crate) struct SegmentCount { + segments: usize, + references: usize, +} + +impl SegmentCount { + /// Count one more segment, making `references` references. + fn add(&mut self, references: usize) -> Result<()> { + self.segments = self.segments.saturating_add(1); + self.references = self.references.saturating_add(references); + if self.segments > MAX_SEGMENTS || self.references > MAX_REFERENCES { + bail!(OverflowError::SegmentCount); + } + Ok(()) + } +} + /// "There are two standalone file organizations possible for a JBIG2 bitstream." /// (Annex D) #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -140,7 +167,9 @@ fn parse_segments<'a>( let mut segments = Vec::new(); match organization { - FileOrganization::Sequential => parse_segments_sequential(reader, &mut segments)?, + FileOrganization::Sequential => { + parse_segments_sequential(reader, &mut segments, &mut SegmentCount::default())?; + } FileOrganization::RandomAccess => parse_segments_random_access(reader, &mut segments)?, } @@ -155,6 +184,7 @@ fn parse_segments<'a>( pub(crate) fn parse_segments_sequential<'a>( reader: &mut Reader<'a>, segments: &mut Vec>, + count: &mut SegmentCount, ) -> Result<()> { loop { if reader.at_end() { @@ -162,6 +192,7 @@ pub(crate) fn parse_segments_sequential<'a>( } let segment = parse_segment(reader)?; + count.add(segment.header.referred_to_segments.len())?; // "If a file contains an end of file segment, it must be the last segment." // (7.4.11) @@ -187,6 +218,7 @@ fn parse_segments_random_access<'a>( segments: &mut Vec>, ) -> Result<()> { let mut headers = Vec::new(); + let mut count = SegmentCount::default(); loop { if reader.at_end() { @@ -194,6 +226,7 @@ fn parse_segments_random_access<'a>( } let header = parse_segment_header(reader)?; + count.add(header.referred_to_segments.len())?; // "If a file contains an end of file segment, it must be the last segment." // (7.4.11) diff --git a/vendor/hayro-jbig2/src/gray_scale.rs b/vendor/hayro-jbig2/src/gray_scale.rs index 2836ec77a7..4aa4229a5b 100644 --- a/vendor/hayro-jbig2/src/gray_scale.rs +++ b/vendor/hayro-jbig2/src/gray_scale.rs @@ -83,8 +83,8 @@ fn decode_arithmetic( if width > MAX_DIMENSION || height > MAX_DIMENSION { bail!(OverflowError::BitmapDimension); } - // PdfCraft patch: the product is bounded too (see `bitmap::MAX_PIXELS`). - if u64::from(width) * u64::from(height) > crate::bitmap::MAX_PIXELS { + // 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); } diff --git a/vendor/hayro-jbig2/src/huffman_table.rs b/vendor/hayro-jbig2/src/huffman_table.rs index 2183c8eea0..91df78b4cd 100644 --- a/vendor/hayro-jbig2/src/huffman_table.rs +++ b/vendor/hayro-jbig2/src/huffman_table.rs @@ -14,11 +14,27 @@ include!("huffman_tables_generated.rs"); /// Maximum number of nodes in an inline Huffman table. const INLINE_TABLE_SIZE: usize = 43; +/// PdfCraft patch: the most lines a custom table may have. A line takes 2 bits of input at least +/// and 8 bytes here, so a table reading 250 MiB behind nested Flate, a 1,297-byte PDF, was killed +/// at 8 GiB before it could be counted. +const MAX_TABLE_LINES: usize = 1 << 16; + /// A queryable Huffman table. #[derive(Debug, Clone)] pub(crate) struct HuffmanTable(Rc); impl HuffmanTable { + /// PdfCraft patch: the words this table holds. Every table takes the room of the inline + /// variant, however few nodes it has, and a dynamic one its nodes besides. + pub(crate) fn words(&self) -> usize { + let nodes = match &*self.0 { + InnerHuffmanTable::Inline { .. } => 0, + InnerHuffmanTable::Dynamic { nodes } => nodes.len(), + }; + let bytes = nodes.saturating_mul(size_of::()); + bytes.saturating_add(size_of::()) / size_of::() + } + /// Create a new inline Huffman table from a fixed-size node array. fn from_inline(nodes: [HuffmanNode; INLINE_TABLE_SIZE]) -> Self { Self(Rc::new(InnerHuffmanTable::Inline { nodes })) @@ -219,6 +235,9 @@ impl HuffmanTable { // 5) "Decode each table line as follows:" // d) "If CURRANGELOW ≥ HTHIGH then proceed to step 6." while current_range_low < maximum_value { + if lines.len() >= MAX_TABLE_LINES { + bail!(HuffmanError::InvalidCode); + } // a) "Read HTPS bits. Set PREFLEN[NTEMP] to the value decoded." let prefix_length = reader .read_bits(prefix_length_bits) diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index ad77a6dd17..1604c3ed7a 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -75,7 +75,7 @@ pub use error::{ SegmentError, SymbolError, TemplateError, }; -use crate::file::parse_segments_sequential; +use crate::file::{SegmentCount, parse_segments_sequential}; use bitmap::Bitmap; use decode::CombinationOperator; use decode::generic; @@ -120,13 +120,14 @@ impl<'a> Image<'a> { /// Annex D.3. pub fn new_embedded(data: &'a [u8], globals: Option<&'a [u8]>) -> Result { let mut segments = Vec::new(); + let mut count = SegmentCount::default(); if let Some(globals_data) = globals { let mut reader = Reader::new(globals_data); - parse_segments_sequential(&mut reader, &mut segments)?; + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; }; let mut reader = Reader::new(data); - parse_segments_sequential(&mut reader, &mut segments)?; + parse_segments_sequential(&mut reader, &mut segments, &mut count)?; segments.sort_by_key(|seg| seg.header.segment_number); @@ -162,6 +163,7 @@ impl<'a> Image<'a> { if page_info.width == 0 || height == 0 { bail!(FormatError::EmptyPage); } + bitmap::check_page_size(page_info.width, height)?; Ok(Self { segments, @@ -311,24 +313,18 @@ fn decode_segments( // Intermediate segments cannot have unknown length. let header = generic::parse(&mut reader, false)?; let region = generic::decode(&header, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::PatternDictionary => { let header = pattern::parse(&mut reader)?; - let dictionary = pattern::decode(&header, scratch_buffers)?; - page_state.store_pattern_dictionary(seg.header.segment_number, dictionary); + let dictionary = pattern::decode(&header, scratch_buffers, page_state.retained_room())?; + page_state.store_pattern_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::SymbolDictionary => { // "1) Concatenate all the input symbol dictionaries to form SDINSYMS." // (6.5.5, step 1) // Collect references to avoid cloning; symbols are only cloned if re-exported. - let input_symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let input_symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -354,18 +350,13 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, retained_contexts, + page_state.retained_room(), )?; - page_state.store_symbol_dictionary(seg.header.segment_number, dictionary); + page_state.store_symbol_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::ImmediateTextRegion | SegmentType::ImmediateLosslessTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). - let symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; // Collect Huffman tables from referred table segments. // "These user-supplied Huffman decoding tables may be supplied either @@ -412,13 +403,7 @@ fn decode_segments( } SegmentType::IntermediateTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). - let symbols: Vec<&Bitmap> = seg - .header - .referred_to_segments - .iter() - .filter_map(|&num| page_state.get_symbol_dictionary(num)) - .flat_map(|dict| dict.exported_symbols.iter()) - .collect(); + let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -437,7 +422,7 @@ fn decode_segments( &page_state.standard_tables, scratch_buffers, )?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateHalftoneRegion | SegmentType::ImmediateLosslessHalftoneRegion => { let pattern_dict = seg @@ -476,7 +461,7 @@ fn decode_segments( let header = halftone::parse(&mut reader)?; let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::IntermediateGenericRefinementRegion => { // Same logic as immediate refinement, but store result instead of combining. @@ -489,7 +474,7 @@ fn decode_segments( let header = generic_refinement::parse(&mut reader)?; let region = generic_refinement::decode(&header, reference, scratch_buffers)?; - page_state.store_region(seg.header.segment_number, region.bitmap); + page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion | SegmentType::ImmediateLosslessGenericRefinementRegion => { @@ -532,7 +517,7 @@ fn decode_segments( // "This segment contains data which defines one or more user-supplied // Huffman coding tables." (7.4.13) let table = HuffmanTable::read_custom(&mut reader)?; - page_state.store_huffman_table(seg.header.segment_number, table); + page_state.store_huffman_table(seg.header.segment_number, table)?; } SegmentType::EndOfPage | SegmentType::EndOfFile => { break; @@ -545,6 +530,23 @@ fn decode_segments( Ok(()) } +/// PdfCraft patch: the words a stored item costs in its list, at least, apart from what it holds. +const ENTRY_WORDS: u64 = 32; + +/// PdfCraft patch: the most words a page's segments may hold for later ones, 64 MiB: two square +/// bitmaps of 16,384 a side (`bitmap::MAX_PIXELS`). Each stored item is bounded on its own, but without a +/// bound on all of them a stream of a few dozen bytes a segment held 33 MB each (a 16,384-sample +/// square region of typical rows), and a few kilobytes could exhaust memory. A segment being +/// decoded takes more for a while, within its own bounds: the page bitmap isn't counted, nor is +/// what a region or a grid uses until it is stored or freed. +const MAX_RETAINED_WORDS: u64 = + 2 * (bitmap::MAX_PIXELS / bitmap::WORD_BITS as u64 + bitmap::BITMAP_WORDS as u64 + ENTRY_WORDS); + +/// PdfCraft patch: the most symbols one segment may refer to, 2^20, far beyond the 65,535 a +/// dictionary may export. References may repeat, so each names the same symbols again: without +/// this, 65,535 references to one dictionary in a 78 KB stream collected 17 GB of pointers. +const MAX_REFERRED_SYMBOLS: u64 = 1 << 20; + /// Page-level decoding state for a JBIG2 page. #[derive(Default)] pub(crate) struct PageState { @@ -561,6 +563,9 @@ pub(crate) struct PageState { /// Decoded Huffman tables from table segments, stored as (`segment_number`, table) pairs. /// "Tables – see 7.4.13." (type 53) pub(crate) huffman_tables: Vec<(u32, HuffmanTable)>, + /// PdfCraft patch: the words the regions, dictionaries and tables above hold, at most + /// [`MAX_RETAINED_WORDS`]. + pub(crate) retained_words: u64, /// Standard Huffman tables (`TABLE_A` through `TABLE_O`). pub(crate) standard_tables: StandardHuffmanTables, } @@ -573,6 +578,7 @@ impl PageState { self.pattern_dictionaries.clear(); self.symbol_dictionaries.clear(); self.huffman_tables.clear(); + self.retained_words = 0; // Standard tables are lazily built and reused across images. } @@ -610,9 +616,26 @@ impl PageState { } } + /// PdfCraft patch: the words the page may still hold for later segments. + fn retained_room(&self) -> u64 { + MAX_RETAINED_WORDS.saturating_sub(self.retained_words) + } + + /// PdfCraft patch: count a stored item holding `words` for later segments, refusing past + /// [`MAX_RETAINED_WORDS`]. + fn retain(&mut self, words: usize) -> Result<()> { + self.retained_words = self.retained_words.saturating_add(words as u64).saturating_add(ENTRY_WORDS); + if self.retained_words > MAX_RETAINED_WORDS { + bail!(OverflowError::RetainedBitmaps); + } + Ok(()) + } + /// Store a decoded region for later reference. - fn store_region(&mut self, segment_number: u32, region: Bitmap) { + fn store_region(&mut self, segment_number: u32, region: Bitmap) -> Result<()> { + self.retain(region.words())?; self.referred_segments.push((segment_number, region)); + Ok(()) } /// Look up a referred segment by number. @@ -624,8 +647,10 @@ impl PageState { } /// Store a decoded pattern dictionary for later reference. - fn store_pattern_dictionary(&mut self, segment_number: u32, dictionary: PatternDictionary) { + fn store_pattern_dictionary(&mut self, segment_number: u32, dictionary: PatternDictionary) -> Result<()> { + self.retain(dictionary.words())?; self.pattern_dictionaries.push((segment_number, dictionary)); + Ok(()) } /// Look up a pattern dictionary by segment number. @@ -637,8 +662,23 @@ impl PageState { } /// Store a decoded symbol dictionary for later reference. - fn store_symbol_dictionary(&mut self, segment_number: u32, dictionary: SymbolDictionary) { + fn store_symbol_dictionary(&mut self, segment_number: u32, dictionary: SymbolDictionary) -> Result<()> { + self.retain(dictionary.words())?; self.symbol_dictionaries.push((segment_number, dictionary)); + Ok(()) + } + + /// PdfCraft patch: the symbols of the dictionaries in `segments`, in order, refusing past + /// [`MAX_REFERRED_SYMBOLS`] before anything is collected. + fn referred_symbols(&self, segments: &[u32]) -> Result> { + let dictionaries = || segments.iter().filter_map(|&num| self.get_symbol_dictionary(num)); + let count = dictionaries() + .map(|dictionary| dictionary.exported_symbols.len() as u64) + .fold(0, u64::saturating_add); + if count > MAX_REFERRED_SYMBOLS { + bail!(OverflowError::SymbolCount); + } + Ok(dictionaries().flat_map(|dictionary| dictionary.exported_symbols.iter()).collect()) } /// Look up a symbol dictionary by segment number. @@ -650,8 +690,10 @@ impl PageState { } /// Store a decoded Huffman table for later reference. - fn store_huffman_table(&mut self, segment_number: u32, table: HuffmanTable) { + fn store_huffman_table(&mut self, segment_number: u32, table: HuffmanTable) -> Result<()> { + self.retain(table.words())?; self.huffman_tables.push((segment_number, table)); + Ok(()) } /// Look up a Huffman table by segment number. @@ -690,3 +732,51 @@ fn init_page( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A page past the bounds is refused when the stream is opened, since callers size their + /// output from it before decoding starts. 16,384 squared is exactly the bound on pixels. + #[test] + fn a_page_past_the_bounds_is_refused_when_opened() { + let stream = |width: u32, height: u32| { + let mut stream = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + stream.extend_from_slice(&width.to_be_bytes()); + stream.extend_from_slice(&height.to_be_bytes()); + stream.extend_from_slice(&[0; 11]); // resolution, flags, striping + stream + }; + assert!(Image::new_embedded(&stream(16384, 16384), None).is_ok()); + assert!(Image::new_embedded(&stream(16385, 16384), None).is_err()); + assert!(Image::new_embedded(&stream(65536, 1), None).is_err(), "too wide, though few pixels"); + assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); + } + + /// A stream and its globals are one stream of segments, so they have one count. + #[test] + fn the_segments_of_a_stream_and_its_globals_count_together() { + let segment: &[u8] = &[0, 0, 0, 0, 62, 0, 1, 0, 0, 0, 0]; // an empty extension + let half = segment.repeat(1 << 19); + let more = segment.repeat((1 << 19) + 1); + let refused = |data: &[u8]| { + let error = Image::new_embedded(data, Some(&half)).err().map(|e| e.to_string()); + error.as_deref() == Some("too many segments or references in a stream") + }; + assert!(!refused(&half), "2^20 segments are allowed"); + assert!(refused(&more), "one more isn't"); + } + + /// A dictionary being decoded has what the page may still hold, not all of it: refused only + /// once it is stored, its copies would already have been made. + #[test] + fn the_room_left_shrinks_as_segments_are_stored() { + let mut page = PageState::default(); + assert_eq!(page.retained_room(), MAX_RETAINED_WORDS); + page.retain(1000).unwrap(); + assert_eq!(page.retained_room(), MAX_RETAINED_WORDS - 1000 - ENTRY_WORDS); + page.retained_words = MAX_RETAINED_WORDS; + assert_eq!(page.retained_room(), 0); + } +} diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index d87253f536..6f1bfb0768 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -1,4 +1,5 @@ use crate::bit_reader::BitWriter; +use crate::filter::Filter; use crate::object::Dict; use crate::object::Stream; use crate::object::dict::keys::JBIG2_GLOBALS; @@ -17,8 +18,12 @@ pub(crate) fn decode( params: &Dict<'_>, image_params: &ImageDecodeParams, ) -> Option> { + // PdfCraft patch: globals are raw segments, so JBIG2 inside them means nothing, and decoding + // it reads its own globals in turn: a chain of 16,000 such streams overflowed the stack, which + // aborts the process. Such a stream reads as globals that can't be decoded. let globals = params .get::>(JBIG2_GLOBALS) + .filter(|g| !g.filters().iter().any(|f| matches!(f, Filter::Jbig2Decode))) .and_then(|g| g.decoded().ok()); let image = hayro_jbig2::Image::new_embedded(data, globals.as_deref()).ok()?; From ce12a1278876f14792d0fea578773353af6c1c5f Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:36:37 +0200 Subject: [PATCH 043/562] Exports keep a wrapped paragraph as one paragraph Reading splits a line wherever its style changes, and export joined those blocks along one line only (#4158). A sentence with a bold name in one line and an italic name in the next came out of Word, HTML, RTF and PowerPoint as one paragraph per line. A paragraph whose last line reaches the right edge of the page's text now runs on into the line just below it, when that line starts at the same left edge in the same size. Headings, list items, a line that ends a sentence, an indented line and a line after paragraph space keep their break. A slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. The new export and automation tests fail without the change. --- crates/automation/tests/it/automation.rs | 71 ++++++++++++ crates/export/src/flow.rs | 135 +++++++++++++++++++++++ crates/export/src/layout.rs | 28 ++++- crates/export/src/lib.rs | 11 +- crates/export/src/pptx.rs | 3 +- crates/export/src/tests.rs | 135 +++++++++++++++++++++++ parity/acrobat-features.toml | 15 +-- 7 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 crates/export/src/flow.rs diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..4d3aa44f01 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7614,6 +7614,77 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; assert_eq!(paragraphs.len(), 2, "{xml}"); } +/// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 +/// and so on, each in its built-in encoding. +fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { + let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + objs.extend(fonts.iter().map(|f| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{f} >>"))); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// The text of each paragraph that holds any in `word/document.xml` of the .docx at `path`. +fn word_paragraph_texts(path: &Path) -> Vec { + let zip = std::fs::read(path).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + xml.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect::() + }) + .filter(|text| !text.is_empty()) + .collect() +} + +/// #4158: a sentence that wraps onto the next lines after a bold name and a slanted one reaches +/// Word as one paragraph, though reading splits each of its lines where the style changes. +#[test] +fn exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph() { + let dir = workdir("wrapped-sentence-word"); + let content = "BT /F1 10 Tf 72 700 Td (The parties to this lease are the owner, ) Tj /F2 10 Tf (Harbour Lane Properties) Tj \ +/F1 10 Tf (, of 4 Quay Street, and the) Tj ET \ +BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch Studio) Tj \ +/F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ +BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ +BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &["Helvetica", "Helvetica-Bold", "Helvetica-Oblique"])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs new file mode 100644 index 0000000000..e064772864 --- /dev/null +++ b/crates/export/src/flow.rs @@ -0,0 +1,135 @@ +//! Text that runs on from one line to the next. +//! +//! The reading blocks keep style boundaries (#4158), so a sentence with a bold name or an italic +//! word in it reaches the exporter as several blocks, and `layout::paragraphs` joins only the ones +//! that run on along one line. A sentence that wraps onto the next line after +//! such a run came out as one paragraph per line. [`join_wrapped`] joins a paragraph whose last +//! line reaches the right edge of the page's text with the one that starts just below it at the +//! same left edge, in the same size: the text wrapped there. Headings, list items, a line that +//! ends a sentence and a line with paragraph space after it stay apart. + +use super::layout::Run; + +/// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets +/// Word writes into its PDFs (U+F0B7, U+F0A7). +const BULLETS: [char; 11] = ['•', '◦', '▪', '‣', '⁃', '●', '○', '■', '□', '\u{F0B7}', '\u{F0A7}']; + +/// Dashes and a middle dot: list markers at the start of a line, punctuation inside one. +const DASHES: [char; 4] = ['-', '\u{2013}', '\u{2014}', '·']; + +/// A run taller than this many times its font size is more than one line: a block of several +/// rows ends where its widest row does, not where its last one does. +const ONE_LINE: f64 = 1.6; + +/// How far (as a multiple of the font size) the space between two lines may be from none, and +/// they still follow each other within a paragraph: more is paragraph spacing. +const LINE_GAP: f64 = 0.5; + +/// How close to the right edge of the page's text a full line ends: within this many times the +/// font size, or this fraction of the text's width, whichever is more (a ragged line ends short +/// of the edge by up to a long word). +const FULL_LINE: (f64, f64) = (3.0, 0.12); + +/// How far (points) the first line of a paragraph may start from the left edge of the line above +/// it and still be at the same left edge. +const SAME_LEFT: f64 = 2.0; + +/// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half +/// the shorter one, as `layout::continued_from` has it. +fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { + a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) +} + +/// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed +/// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", +/// "i.e." and "2026. Year" are text. +pub(crate) fn list_item(text: &str) -> bool { + let t = text.trim_start(); + let Some(first) = t.chars().next() else { return false }; + if BULLETS.contains(&first) || DASHES.contains(&first) || first == '*' { + return true; + } + let digits = t.chars().take_while(char::is_ascii_digit).count(); + // ASCII digits are one byte each and `first` is one whole character, so both cuts fall on + // character boundaries. + let (rest, marks): (&str, &[char]) = if (1..=3).contains(&digits) { + (t.get(digits..).unwrap_or_default(), &['.', ')']) + } else if digits == 0 && first.is_alphabetic() { + (t.get(first.len_utf8()..).unwrap_or_default(), &[')']) + } else { + return false; + }; + let mut after = rest.chars(); + after.next().is_some_and(|c| marks.contains(&c)) && after.next().is_none_or(char::is_whitespace) +} + +/// Whether a line's text ends a sentence (closing quotes and brackets aside). Text set without +/// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left +/// apart from the next rather than joined to it wrongly. +fn ends_sentence(text: &str) -> bool { + text.trim_end().trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']']).ends_with(['.', '!', '?', ':', ';', '…']) +} + +/// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto +/// the next line joined to it. `level` is a paragraph's heading level (0 for body text). +pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Run<'a>]) -> u8) -> Vec>> { + // The left and right edges of the page's text: where a full line starts and ends. + let (left, right) = paras + .iter() + .flatten() + .map(|r| r.block.rect) + .filter(|r| r[0].is_finite() && r[2].is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x[0]), r.max(x[2]))); + if right <= left { + return paras; + } + // Top to bottom, then left to right, by each paragraph's first run. + paras.sort_by(|a, b| { + let top_left = |p: &[Run]| p.first().map_or([0.0; 2], |r| [r.block.rect[3], r.block.rect[0]]); + let (a, b) = (top_left(a), top_left(b)); + b[0].total_cmp(&a[0]).then(a[1].total_cmp(&b[1])) + }); + // Each paragraph with its heading level. A joined paragraph is body text: only body text joins. + let mut out: Vec<(Vec>, u8)> = Vec::with_capacity(paras.len()); + for para in paras { + let lvl = level(¶); + match out.last_mut() { + Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { + // The line break was a word space, unless the line ends in a hyphen; the hyphen + // stays, as it may belong to the word ("well-known"). + if let Some(end) = prev.last_mut() + && !end.text.ends_with(char::is_whitespace) + && !end.text.ends_with('-') + { + end.text.push(' '); + } + prev.extend(para); + } + _ => out.push((para, lvl)), + } + } + out.into_iter().map(|(para, _)| para).collect() +} + +/// Whether the body text paragraph `prev` ends in a full line whose text runs on in `next`, the +/// body text paragraph just below it. `[left, right]` are the edges of the page's text. +fn wraps_into(prev: &[Run], next: &[Run], [left, right]: [f64; 2]) -> bool { + let (Some(last), Some(first)) = (prev.last(), next.first()) else { return false }; + let (a, b) = (last.block, first.block); + let size = a.size; + let alike = size.is_finite() && size > 0.0 && (a.size - b.size).abs() <= 0.5 && a.rotation.abs() <= 0.1 && b.rotation.abs() <= 0.1; + if !alike || a.rect[3] - a.rect[1] > ONE_LINE * size || list_item(&first.text) || ends_sentence(&last.text) { + return false; + } + // The last line: the runs at the end of `prev` that share the last run's line. + let (line_left, line_right) = prev + .iter() + .rev() + .take_while(|r| on_one_line(r.block.rect, a.rect)) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(l, r), x| (l.min(x.block.rect[0]), r.max(x.block.rect[2]))); + let full = line_right >= right - (FULL_LINE.0 * size).max(FULL_LINE.1 * (right - left)); + let same_left = (b.rect[0] - line_left).abs() <= SAME_LEFT; + // Just below: no paragraph space between the two lines. + let gap = a.rect[1] - b.rect[3]; + full && same_left && (-LINE_GAP * size..=LINE_GAP * size).contains(&gap) +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index d1d074ac61..84c59f82bb 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -138,9 +138,20 @@ const INLINE_GAP: f64 = 0.5; /// page laid out so that many end at the same place stays quick. const CONTINUATION_PROBE: usize = 64; +/// How far (as a fraction of the font size) the block before may end past where a block starts +/// and still be the one it continues: a slanted italic's box leans into the next word by about a +/// quarter of its size. +const OVERHANG: f64 = 0.3; + +/// [`OVERHANG`] in points for a block of `size`: at least a point (rounding), at most six. +fn overhang(size: f64) -> f64 { + if size.is_finite() { (OVERHANG * size).clamp(1.0, 6.0) } else { 1.0 } +} + /// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. +/// [`INLINE_GAP`] later, or up to [`overhang`] earlier, as a slanted italic leans into the word +/// after it), so it is the same line of text running on (a link inside a sentence, a +/// superscript), not a cell of its own. fn continuations(blocks: &[Block]) -> Vec { continued_from(blocks).into_iter().map(|from| from.is_some()).collect() } @@ -159,7 +170,7 @@ fn continued_from(blocks: &[Block]) -> Vec> { .get(k..) .unwrap_or_default() .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) @@ -234,6 +245,12 @@ pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec(blocks: &'a [Block], skip: &[bool], body: f64) -> Vec>> { + super::flow::join_wrapped(paragraphs(blocks, skip), |runs| para_level(runs, body)) +} + /// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold /// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run /// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. @@ -450,8 +467,9 @@ pub(crate) fn items(pages: &[Page]) -> Vec> { } let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - // A paragraph sits where its first run does: its other runs follow along the same line. - let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + // A paragraph sits where its first run does: its other runs follow along its line and the + // lines it wraps onto. + let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..d1c4753aa8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,9 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text, and a block that -//! continues another on its line (an italic word, a link) is a run of that block's paragraph. -//! Word keeps pages with +//! document: headings are the paragraphs set larger than the body text; a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph, and +//! a paragraph that wraps onto the next line runs on there. Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -20,6 +20,7 @@ mod as_docx; mod as_html; mod as_markdown; mod as_rtf; +mod flow; mod layout; pub use as_docx::docx; @@ -34,10 +35,12 @@ pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; +#[cfg(test)] +use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 49765000d3..ff1d1bad7d 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -125,6 +125,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut global_media: Vec<(String, &Image)> = Vec::new(); let mut slides: Vec<(String, String)> = Vec::new(); // (slide_xml, slide_rels) + let body = crate::layout::body_size(pages); for p in pages { let (tables, consumed) = crate::tables(&p.blocks); @@ -139,7 +140,7 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); - for runs in crate::paragraphs(&p.blocks, &skip) { + for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..11ad393206 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -841,3 +841,138 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); } + +/// A line of 10 pt text from `x0` to `x1` whose box starts at `y0`: 12 pt tall, so lines 14 pt +/// apart leave 2 pt between their boxes. +fn line(t: &str, x0: f64, x1: f64, y0: f64) -> Block { + Block { size: 10.0, ..block(t, x0, y0, x1, y0 + 12.0) } +} + +/// The text of each flowing paragraph Word gets for a page of `blocks`. +fn word_texts(blocks: Vec) -> Vec { + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let xml = part(&docx(&[p], "Lines"), "word/document.xml"); + word_paragraphs(&xml).into_iter().map(|runs| runs.into_iter().map(|(t, _)| t).collect()).collect() +} + +/// A sentence over three lines, as the reading blocks give it: split at each style change, a +/// bold name in the first line and a slanted name in the second whose box leans 2.5 pt into the +/// comma after it (#4158). The next paragraph follows after paragraph space. +fn wrapped_sentence() -> Vec { + vec![ + line("The parties to this lease are the owner,", 72.0, 262.0, 700.0), + Block { bold: true, ..line("Harbour Lane Properties", 265.0, 400.0, 700.0) }, + line(", of 4 Quay Street, and the", 400.5, 540.0, 700.0), + line("tenant named in the schedule,", 72.0, 215.0, 686.0), + Block { italic: true, ..line("Blue Finch Studio", 218.0, 312.0, 686.0) }, + line(", who agree as follows to rent the upper", 309.5, 538.0, 686.0), + line("rooms for the whole of the term.", 72.0, 230.0, 672.0), + line("Rent is paid monthly in advance.", 72.0, 230.0, 640.0), + ] +} + +/// #4158: a sentence that wraps onto the next line after a change of style is one paragraph in +/// every format, its lines joined by a word space. +#[test] +fn a_sentence_that_wraps_after_a_style_change_is_one_paragraph() { + let sentence = "The parties to this lease are the owner, Harbour Lane Properties, of 4 Quay Street, and the tenant named in the \ +schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for the whole of the term."; + assert_eq!(word_texts(wrapped_sentence()), [sentence, "Rent is paid monthly in advance."]); + let p = Page { width: 612.0, height: 792.0, blocks: wrapped_sentence(), ..Page::default() }; + let h = html(std::slice::from_ref(&p), "Lease"); + assert_eq!(h.matches("

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

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

" + ), + "{h}" + ); + let r = rtf(std::slice::from_ref(&p)); + assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. + let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); + let slide: Vec = + s.split("").skip(1).map(|p| p.split("").skip(1).filter_map(|t| t.split_once("").map(|(t, _)| t)).collect()).collect(); + assert!(slide.iter().any(|p| p == sentence), "{s}"); +} + +/// A slanted word's box leans into the word after it by about a quarter of its size, so that +/// word still runs on from it; text that starts further back is drawn over the text before it, +/// not after it. +#[test] +fn a_slanted_word_that_leans_into_the_next_stays_in_its_line() { + let leaning = vec![ + line("named in the schedule,", 72.0, 180.0, 700.0), + Block { italic: true, ..line("Blue Finch Studio", 183.0, 276.0, 700.0) }, + line(", who agree", 273.5, 330.0, 700.0), + ]; + assert_eq!(word_texts(leaning), ["named in the schedule, Blue Finch Studio, who agree"]); + let over = vec![line("A label", 72.0, 120.0, 700.0), line("over another", 115.0, 180.0, 700.0)]; + assert_eq!(word_texts(over), ["A label", "over another"]); +} + +/// A line runs on into the next only when it reaches the right edge of the text and the next +/// starts just below at the same left edge, in the same size. A line that ends a sentence or ends +/// short, and a next line that is a heading, a list item, indented or after paragraph space, keep +/// their paragraph break. Each case is a page of its own. +#[test] +fn a_paragraph_breaks_where_its_line_does_not_run_on() { + let full = "The tenant keeps the rooms, the stairs and the shared yard clean and in good repair"; + let next = "throughout the term of the lease"; + let first = |t: &str| line(t, 72.0, 540.0, 700.0); + let second = |t: &str| line(t, 72.0, 240.0, 686.0); + assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); + assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + let heading = |b: Block| Block { bold: true, ..b }; + for (why, blocks) in [ + ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), + ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), + ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), + ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), + ("a larger size", vec![first(full), Block { size: 12.0, ..second(next) }]), + ("a numbered item", vec![first(full), second("2. The tenant pays the rent")]), + ("a lettered item", vec![first(full), second("а) поддръжка на сървърите")]), + ("a bulleted item", vec![first(full), second("• the stairs")]), + ("a heading below", vec![first(full), heading(second("Rent and deposit"))]), + ("a heading above", vec![heading(first("Obligations of the tenant")), second(next)]), + ] { + assert_eq!(word_texts(blocks).len(), 2, "{why}"); + } +} + +#[test] +fn list_items_are_told_from_text() { + for item in ["• item", "\u{F0B7} item", "- item", "\u{2013} item", "* item", "1. Term", "12) Rent", "3.", "a) rooms", "а) стаи"] { + assert!(list_item(item), "{item}"); + } + for text in ["1.5 kg of", "2026. Year", "i.e. this", "т.е. така", "38 rooms", "Rent 12)", "1234) many", ""] { + assert!(!list_item(text), "{text}"); + } +} + +/// Wrapped lines join on hostile boxes and sizes without a panic, and never lose a line's text. +#[test] +fn joining_lines_survives_hostile_boxes() { + let mut blocks = Vec::new(); + for (i, v) in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e300].into_iter().enumerate() { + let y = 700.0 - 14.0 * i as f64; + blocks.push(Block { size: v, ..line(&format!("size {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [v, y, 540.0, y + 12.0], ..line(&format!("left {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rect: [72.0, v, v, y + 12.0], ..line(&format!("box {i}"), 72.0, 540.0, y) }); + blocks.push(Block { rotation: v, ..line(&format!("turn {i}"), 72.0, 540.0, y) }); + } + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let outputs = [ + html(std::slice::from_ref(&p), "Hostile"), + rtf(std::slice::from_ref(&p)), + part(&pptx(std::slice::from_ref(&p), "Hostile"), "ppt/slides/slide1.xml"), + word_texts(p.blocks.clone()).concat(), + ]; + for output in outputs { + for b in &p.blocks { + assert!(output.contains(&b.text), "{} was lost", b.text); + } + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1404247cd7..cffbff4176 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7076,8 +7076,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). Not yet: a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; page breaks. Images are left out." [[feature]] @@ -7140,8 +7140,9 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", + "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7200,8 +7201,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From b98b20633cfab25a675c17fd1414d74aaf7e5248 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:49:19 +0200 Subject: [PATCH 044/562] Exports keep a bulleted list as paragraphs, not a table Word and LibreOffice often draw a list's bullet in a symbol font, a tab stop before the item's text, so each bullet is a block of its own. Table detection read the bullets and the items as two columns, and every export wrote the list as a borderless two-column table. Each bullet is now paired with the text that starts within three times its size after it on its line. The text runs on from its bullet, and bullets stay out of table detection, so Word gets one paragraph per item with the bullet first. The Symbol and Wingdings bullets Word writes (U+F0B7, U+F0A7) count. A dash or a middle dot counts only when nothing starts left of it on its line, so a dash that stands for "none" in a table row stays a cell. The new export and automation tests fail without the change. --- crates/automation/tests/it/automation.rs | 50 +++++++++++-- crates/export/src/flow.rs | 75 +++++++++++++++++++- crates/export/src/layout.rs | 25 +++++-- crates/export/src/tests.rs | 89 ++++++++++++++++++++++++ parity/acrobat-features.toml | 15 ++-- 5 files changed, 237 insertions(+), 17 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 4d3aa44f01..674d8e7666 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -7615,8 +7615,9 @@ BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; } /// A one-page PDF (612 x 792 pt) drawing `content`, with the standard fonts `fonts` as /F1, /F2 -/// and so on, each in its built-in encoding. -fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { +/// and so on, each in its built-in encoding. A font given `bfchar` pairs (" ") reads +/// its codes through that ToUnicode map, as a symbol font Word embeds does. +fn standard_fonts_pdf(content: &str, fonts: &[(&str, Option<&str>)]) -> Vec { let names: String = (1..=fonts.len()).map(|i| format!("/F{i} {} 0 R ", i + 4)).collect(); let mut objs = vec![ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), @@ -7624,7 +7625,19 @@ fn standard_fonts_pdf(content: &str, fonts: &[&str]) -> Vec { format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << {names}>> >> >>"), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), ]; - objs.extend(fonts.iter().map(|f| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{f} >>"))); + let mut maps = Vec::new(); + for (font, map) in fonts { + let to_unicode = map.map_or(String::new(), |map| { + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> endcodespacerange \ +1 beginbfchar {map} endbfchar endcmap CMapName currentdict /CMap defineresource pop end end" + ); + maps.push(format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len())); + format!(" /ToUnicode {} 0 R", fonts.len() + 4 + maps.len()) + }); + objs.push(format!("<< /Type /Font /Subtype /Type1 /BaseFont /{font}{to_unicode} >>")); + } + objs.extend(maps); let mut pdf = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -7676,7 +7689,8 @@ BT /F1 10 Tf 72 686 Td (tenant named in the schedule, ) Tj /F3 10 Tf (Blue Finch /F1 10 Tf (, who agree as follows to rent the upper) Tj ET \ BT /F1 10 Tf 72 672 Td (rooms for the whole of the term.) Tj ET \ BT /F1 10 Tf 72 640 Td (Rent is paid monthly in advance.) Tj ET"; - std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &["Helvetica", "Helvetica-Bold", "Helvetica-Oblique"])).unwrap(); + let fonts = [("Helvetica", None), ("Helvetica-Bold", None), ("Helvetica-Oblique", None)]; + std::fs::write(dir.join("lease.pdf"), standard_fonts_pdf(content, &fonts)).unwrap(); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "lease.pdf" }))["doc"].as_u64().unwrap(); ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "lease.docx" })); @@ -7685,6 +7699,34 @@ schedule, Blue Finch Studio, who agree as follows to rent the upper rooms for th assert_eq!(word_paragraph_texts(&dir.join("lease.docx")), [sentence, "Rent is paid monthly in advance."]); } +/// A bulleted list drawn as Word draws one, each bullet in the Symbol font (read as U+F0B7, as +/// Word maps it) a tab stop before its item's text, reaches Word as one paragraph per item and +/// not as a two-column table. +#[test] +fn exporting_to_word_keeps_a_bulleted_list_as_paragraphs() { + let dir = workdir("bulleted-list-word"); + let mut content = String::from("BT /F1 10 Tf 72 700 Td (The tenant looks after:) Tj ET\n"); + for (n, item) in ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].iter().enumerate() { + let y = 686 - 14 * n; + content.push_str(&format!("BT /F2 10 Tf 90 {y} Td (\\267) Tj ET BT /F1 10 Tf 108 {y} Td ({item}) Tj ET\n")); + } + content.push_str("BT /F1 10 Tf 72 630 Td (Rent is paid monthly in advance.) Tj ET"); + std::fs::write(dir.join("list.pdf"), standard_fonts_pdf(&content, &[("Helvetica", None), ("Symbol", Some(" "))])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "list.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "list.docx" })); + assert_eq!( + word_paragraph_texts(&dir.join("list.docx")), + [ + "The tenant looks after:", + "\u{F0B7} the stairs and the landing;", + "\u{F0B7} the shared yard and its gate;", + "\u{F0B7} the bins, every week.", + "Rent is paid monthly in advance." + ] + ); +} + /// Hundreds of calculations run on opening and again after a change through the tools. The /// time limits are raised so that a busy machine does not stop them (the engine's tests check /// that the limits stop them). diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs index e064772864..49ef73e38d 100644 --- a/crates/export/src/flow.rs +++ b/crates/export/src/flow.rs @@ -7,8 +7,13 @@ //! line reaches the right edge of the page's text with the one that starts just below it at the //! same left edge, in the same size: the text wrapped there. Headings, list items, a line that //! ends a sentence and a line with paragraph space after it stay apart. +//! +//! A list item's bullet is often a block of its own: drawn in a symbol font, a tab stop before +//! the item's text. Table detection read such a list as a two-column table, bullets in one +//! column and the items in the other. [`bullet_items`] pairs each bullet with its text, so the +//! text runs on from its bullet and the bullets stay out of table detection. -use super::layout::Run; +use super::layout::{Block, Run}; /// Bullets that mark a list item wherever they stand, including the Symbol and Wingdings bullets /// Word writes into its PDFs (U+F0B7, U+F0A7). @@ -34,12 +39,80 @@ const FULL_LINE: (f64, f64) = (3.0, 0.12); /// it and still be at the same left edge. const SAME_LEFT: f64 = 2.0; +/// How far after its bullet (as a multiple of the bullet's size) a list item's text may start: a +/// tab stop away. +const BULLET_GAP: f64 = 3.0; + +/// How many blocks [`bullet_items`] may look at on one page, so that a page of many bullets stays +/// quick; past it, the bullets left are not taken for bullets. +const BULLET_BUDGET: usize = 1_000_000; + +/// Takes a step from `budget`: whether there was one left. +fn step(budget: &mut usize) -> bool { + budget.checked_sub(1).map(|left| *budget = left).is_some() +} + /// Whether two boxes ([x0, y0, x1, y1]) share a line: they overlap vertically by more than half /// the shorter one, as `layout::continued_from` has it. fn on_one_line(a: [f64; 4], b: [f64; 4]) -> bool { a[3].min(b[3]) - a[1].max(b[1]) > 0.5 * (a[3] - a[1]).min(b[3] - b[1]) } +/// Whether a block is a bullet drawn on its own: one bullet, dash or middle dot, no wider than one +/// and a half times its size. +fn is_bullet(b: &Block) -> bool { + let mut chars = b.text.trim().chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return false }; + (BULLETS.contains(&c) || DASHES.contains(&c)) && b.rect[2] - b.rect[0] <= 1.5 * b.size +} + +/// Whether no other block on `blocks[i]`'s line starts left of it. Each block looked at takes a +/// step from `budget`; when the budget runs out the answer is no. +fn opens_line(i: usize, blocks: &[Block], budget: &mut usize) -> bool { + let Some(a) = blocks.get(i) else { return false }; + for (j, b) in blocks.iter().enumerate() { + if !step(budget) { + return false; + } + if j != i && b.rect[0] < a.rect[0] && on_one_line(a.rect, b.rect) { + return false; + } + } + true +} + +/// The list items of a page whose bullet is a block of its own, as (bullet, text): the text is the +/// block that starts nearest after the bullet on its line, within [`BULLET_GAP`] times its size. +/// A dash or a middle dot is a bullet only when it opens its line; inside a line it is +/// punctuation, or a table cell's "none". The items' texts of a list all start at one tab stop, +/// so the search looks through every block that starts there, within [`BULLET_BUDGET`]. +pub(crate) fn bullet_items(blocks: &[Block]) -> Vec<(usize, usize)> { + let mut by_start: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[0], i)).collect(); + by_start.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut budget = BULLET_BUDGET; + let mut out = Vec::new(); + for (ai, a) in blocks.iter().enumerate().filter(|(_, b)| is_bullet(b)) { + // The text may start a point inside the bullet's box, as a run that continues another can. + let k = by_start.partition_point(|(x, _)| *x < a.rect[2] - 1.0); + let text = by_start + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(x, _)| *x <= a.rect[2] + BULLET_GAP * a.size) + .take_while(|_| step(&mut budget)) + .filter(|(_, bi)| *bi != ai) + .filter_map(|(_, bi)| blocks.get(*bi).map(|b| (*bi, b))) + .find(|(_, b)| on_one_line(a.rect, b.rect) && (a.rotation - b.rotation).abs() <= 0.1 && !is_bullet(b)) + .map(|(bi, _)| bi); + let Some(text) = text else { continue }; + let dash = a.text.trim().chars().next().is_some_and(|c| DASHES.contains(&c)); + if !dash || opens_line(ai, blocks, &mut budget) { + out.push((ai, text)); + } + } + out +} + /// Whether `text` opens like a list item: a bullet, a dash or `*`; one to three digits followed /// by `.` or `)`; or one letter followed by `)` ("a)", "а)"); then a space or nothing. "1.5 kg", /// "i.e." and "2026. Year" are text. diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 84c59f82bb..133862b1d9 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -157,11 +157,12 @@ fn continuations(blocks: &[Block]) -> Vec { } /// Per block, the block it continues on the same line (see [`continuations`]): of those it could -/// continue, the one that ends nearest to where it starts. +/// continue, the one that ends nearest to where it starts. A list item's text continues its +/// bullet, a tab stop before it (`flow::bullet_items`). fn continued_from(blocks: &[Block]) -> Vec> { let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks + let mut from: Vec> = blocks .iter() .enumerate() .map(|(bi, b)| { @@ -177,7 +178,13 @@ fn continued_from(blocks: &[Block]) -> Vec> { .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) - .collect() + .collect(); + for (bullet, text) in super::flow::bullet_items(blocks) { + if let Some(slot @ None) = from.get_mut(text) { + *slot = Some(bullet); + } + } + from } /// A gap between two runs wider than this (as a fraction of the font size) is a word space the @@ -270,8 +277,16 @@ fn is_cell_like(b: &Block) -> bool { /// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { let mut consumed = vec![false; blocks.len()]; + // A list's bullets are not cells: beside their items' text they read as a two-column table. + let mut bullet = vec![false; blocks.len()]; + for (i, _) in super::flow::bullet_items(blocks) { + if let Some(b) = bullet.get_mut(i) { + *b = true; + } + } + let cell_like = |i: usize, b: &Block| is_cell_like(b) && !bullet.get(i).copied().unwrap_or(false); // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, b)| cell_like(*i, b)).map(|(i, b)| (b.rect[0], i)).collect(); xs.sort_by(|a, b| a.0.total_cmp(&b.0)); let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) for (x, i) in xs { @@ -307,7 +322,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); for &bi in &order { let b = &blocks[bi]; - if !is_cell_like(b) { + if !cell_like(bi, b) { continue; } let Some(ci) = column_at(b.rect[0]) else { continue }; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 11ad393206..dc99894d1b 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -976,3 +976,92 @@ fn joining_lines_survives_hostile_boxes() { } } } + +/// A bulleted list as Word and LibreOffice draw one: each bullet a block of its own in a symbol +/// font, a tab stop before its item's text, between a lead-in line and the next paragraph. +fn bulleted_list(bullet: &str) -> Vec { + let mut blocks = vec![line("The tenant looks after:", 72.0, 180.0, 700.0)]; + for (n, (text, end)) in + [("the stairs and the landing;", 230.0), ("the shared yard and its gate;", 240.0), ("the bins, every week.", 200.0)].into_iter().enumerate() + { + let y = 686.0 - 14.0 * n as f64; + blocks.push(line(bullet, 90.0, 94.6, y)); + blocks.push(line(text, 108.0, end, y)); + } + blocks.push(line("Rent is paid monthly in advance.", 72.0, 230.0, 630.0)); + blocks +} + +/// A bulleted list whose bullets are blocks of their own is one paragraph per item, each item's +/// text after its bullet, in every format: not a two-column table of bullets and text. The +/// Symbol and Wingdings bullets Word writes count, and so does a dash that opens its line. +#[test] +fn a_bulleted_list_is_paragraphs_not_a_table() { + for bullet in ["•", "\u{F0B7}", "\u{F0A7}", "\u{2013}"] { + let p = Page { width: 612.0, height: 792.0, blocks: bulleted_list(bullet), ..Page::default() }; + let (found, _) = tables(&p.blocks); + assert!(found.is_empty(), "{bullet}: {found:?}"); + let items = ["the stairs and the landing;", "the shared yard and its gate;", "the bins, every week."].map(|t| format!("{bullet} {t}")); + let mut expected = vec!["The tenant looks after:".to_string()]; + expected.extend(items.iter().cloned()); + expected.push("Rent is paid monthly in advance.".into()); + assert_eq!(word_texts(p.blocks.clone()), expected, "{bullet}"); + let h = html(std::slice::from_ref(&p), "List"); + assert!(!h.contains("
") && items.iter().all(|i| h.contains(&format!("

{i}

"))), "{bullet}: {h}"); + assert!(!rtf(std::slice::from_ref(&p)).contains("\\trowd"), "{bullet}"); + let s = part(&pptx(std::slice::from_ref(&p), "List"), "ppt/slides/slide1.xml"); + assert!(!s.contains(""), "{bullet}: {s}"); + } +} + +/// A dash in a table row stands for "none": it is a cell like the others, not a bullet, so the +/// table keeps all its rows. +#[test] +fn a_dash_in_a_table_row_stays_a_cell() { + let mut blocks = Vec::new(); + for (n, (name, first, second)) in [("Apples", "\u{2013}", "5"), ("Pears", "3", "4"), ("Plums", "2", "6")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line(name, 72.0, 110.0, y)); + blocks.push(line(first, 200.0, 205.0, y)); + blocks.push(line(second, 225.0, 230.0, y)); + } + let (found, consumed) = tables(&blocks); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].rows.len(), 3, "{found:?}"); + assert!(consumed.iter().all(|used| *used), "{consumed:?}"); +} + +/// A bullet is never a table cell, even when its items carry a second column such as an amount: +/// every item keeps its bullet in front of its text. +#[test] +fn a_bullet_is_never_a_table_cell() { + let mut blocks = Vec::new(); + for (n, (item, amount)) in [("apples", "5 kg"), ("pears", "4 kg"), ("plums", "2 kg")].into_iter().enumerate() { + let y = 700.0 - 14.0 * n as f64; + blocks.push(line("•", 90.0, 94.6, y)); + blocks.push(line(item, 108.0, 140.0, y)); + blocks.push(line(amount, 300.0, 325.0, y)); + } + let (found, _) = tables(&blocks); + assert!(found.iter().flat_map(|t| &t.rows).flatten().all(|cell| cell.text != "•"), "{found:?}"); + let paragraphs = word_texts(blocks); + for item in ["• apples", "• pears", "• plums"] { + assert!(paragraphs.iter().any(|p| p.starts_with(item)), "{item}: {paragraphs:?}"); + } +} + +/// Every item of a long list keeps its bullet, though all the items' texts start at one tab stop. +#[test] +fn a_long_bulleted_list_keeps_every_bullet_with_its_item() { + let mut blocks = Vec::new(); + for n in 0..150 { + let y = 2200.0 - 14.0 * f64::from(n); + blocks.push(line("\u{F0B7}", 90.0, 94.6, y)); + blocks.push(line(&format!("item {n};"), 108.0, 160.0, y)); + } + let (found, _) = tables(&blocks); + assert!(found.is_empty(), "{found:?}"); + let paragraphs = word_texts(blocks); + assert_eq!(paragraphs.len(), 150, "{paragraphs:?}"); + assert!(paragraphs.iter().enumerate().all(|(n, p)| *p == format!("\u{F0B7} item {n};")), "{paragraphs:?}"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cffbff4176..a349c91fd8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7076,8 +7076,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). Not yet: a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." +evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_slanted_word_that_leans_into_the_next_stays_in_its_line", "crates/export/src/tests.rs::a_paragraph_breaks_where_its_line_does_not_run_on", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_wrapped_sentence_in_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", "crates/export/src/tests.rs::a_bullet_is_never_a_table_cell", "crates/export/src/tests.rs::a_long_bulleted_list_keeps_every_bullet_with_its_item", "crates/export/src/tests.rs::a_dash_in_a_table_row_stays_a_cell", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_a_bulleted_list_as_paragraphs"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158); a slanted run whose box leans into the next word by up to 0.3 of its size still runs on into it. A paragraph whose last line reaches the right edge of the page's text runs on into the line just below it that starts at the same left edge in the same size, so a sentence the reading blocks split at a style change stays one paragraph across its lines; headings, list items, a line that ends a sentence, an indented line and one after paragraph space keep their break (#4158). A list whose bullets are blocks of their own (a bullet in a symbol font a tab stop before the item's text, as Word writes it, U+F0B7 and U+F0A7 included) is one paragraph per item, the bullet first, and not a borderless two-column table: a bullet is never a table cell, and a dash or middle dot counts as a bullet only when nothing starts left of it on its line. Not yet: Word list numbering (an item is a plain paragraph that starts with its bullet), a bullet drawn in the standard Symbol font without a ToUnicode map (it never reaches export, though text extraction reads it), a paragraph whose rows the reading blocks already grouped past a style change (the exporter does not see where such a block's last row ends), a wrapped line that ends in a full stop or colon, a first-line indent, lines with more than half their font size of space between them (as 1.5 or double line spacing leaves), text in columns, exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7088,8 +7088,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; page breaks. Images are left out." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::rtf_escapes_and_sizes", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), and a paragraph that wraps onto the next line after a style change runs on there, with the same rules and limits as Word export; a list whose bullets are blocks of their own is a paragraph per item, not a table, as in Word export; page breaks. Images are left out." [[feature]] @@ -7141,8 +7141,9 @@ evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", + "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table", ] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text (a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table), images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7201,8 +7202,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::a_sentence_that_wraps_after_a_style_change_is_one_paragraph", "crates/export/src/tests.rs::a_bulleted_list_is_paragraphs_not_a_table"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour; a paragraph that wraps onto the next line after a style change stays one paragraph, with the same rules and limits as Word export, #4158; a list whose bullets are blocks of their own is a paragraph per item, not a table, though not yet a
    list), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" From 78b8dfbaefd0c45765df33eea80d0a22e7c65717 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:51:26 +0200 Subject: [PATCH 045/562] Exports join wrapped Chinese and Japanese lines without a space Chinese and Japanese text wraps between characters, so the word space put at each joined line break showed up inside the text. No space is added now when both sides of the break are Chinese or Japanese characters, their punctuation or fullwidth forms; Korean, which spaces its words, still gets one. A line ending in a full-width full stop, exclamation or question mark (also inside closing corner brackets) now ends its paragraph like one ending in a full stop. The new test lines fail without the change. The wrapped sentence test now checks the Markdown export too, which goes through the same joining since it was added. --- crates/export/src/flow.rs | 27 +++++++++++++++++++++++++-- crates/export/src/tests.rs | 14 ++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/export/src/flow.rs b/crates/export/src/flow.rs index 49ef73e38d..4349dd558e 100644 --- a/crates/export/src/flow.rs +++ b/crates/export/src/flow.rs @@ -140,7 +140,25 @@ pub(crate) fn list_item(text: &str) -> bool { /// paragraph spacing gives no other sign that a paragraph ends there, so such a line is left /// apart from the next rather than joined to it wrongly. fn ends_sentence(text: &str) -> bool { - text.trim_end().trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']']).ends_with(['.', '!', '?', ':', ';', '…']) + text.trim_end() + .trim_end_matches(['"', '\'', '”', '’', '“', '»', ')', ']', '」', '』']) + .ends_with(['.', '!', '?', ':', ';', '…', '。', '!', '?']) +} + +/// Whether `c` is of the writing that puts no space between words: Chinese and Japanese +/// characters, their punctuation and fullwidth forms. A line that wraps between two of them +/// joins without a word space. +fn unspaced(c: char) -> bool { + matches!( + c, + '\u{3000}'..='\u{30FF}' // CJK punctuation, Hiragana, Katakana + | '\u{31F0}'..='\u{31FF}' // Katakana extension + | '\u{3400}'..='\u{4DBF}' // CJK extension A + | '\u{4E00}'..='\u{9FFF}' // CJK unified ideographs + | '\u{F900}'..='\u{FAFF}' // CJK compatibility ideographs + | '\u{FF01}'..='\u{FF9F}' // fullwidth forms, halfwidth Katakana + | '\u{20000}'..='\u{3FFFF}' // CJK extension B onwards + ) } /// `paras` (a page's paragraphs, in any order) top to bottom, with each paragraph that wraps onto @@ -169,10 +187,15 @@ pub(crate) fn join_wrapped<'a>(mut paras: Vec>>, level: impl Fn(&[Ru match out.last_mut() { Some((prev, 0)) if lvl == 0 && wraps_into(prev, ¶, [left, right]) => { // The line break was a word space, unless the line ends in a hyphen; the hyphen - // stays, as it may belong to the word ("well-known"). + // stays, as it may belong to the word ("well-known"). Chinese and Japanese text + // wraps between characters, with no space. + let unspaced_break = |end: &str| { + end.chars().next_back().is_some_and(unspaced) && para.first().and_then(|r| r.text.chars().next()).is_some_and(unspaced) + }; if let Some(end) = prev.last_mut() && !end.text.ends_with(char::is_whitespace) && !end.text.ends_with('-') + && !unspaced_break(&end.text) { end.text.push(' '); } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index dc99894d1b..5af44d3c65 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -890,6 +890,9 @@ named in the schedule, Blue Finch Studio, who agree as follows to rent ); let r = rtf(std::slice::from_ref(&p)); assert_eq!(r.matches("{\\pard").count(), 2, "{r}"); + let md = markdown(std::slice::from_ref(&p)); + assert_eq!(md.trim().split("\n\n").count(), 2, "{md}"); + assert!(md.contains("of 4 Quay Street, and the tenant named in the schedule, "), "{md}"); // On a slide the last of the paragraphs set largest is the title; the sentence is one paragraph. let s = part(&pptx(std::slice::from_ref(&p), "Lease"), "ppt/slides/slide1.xml"); let slide: Vec = @@ -924,10 +927,21 @@ fn a_paragraph_breaks_where_its_line_does_not_run_on() { let second = |t: &str| line(t, 72.0, 240.0, 686.0); assert_eq!(word_texts(vec![first(full), second(next)]), [format!("{full} {next}")]); assert_eq!(word_texts(vec![first("Изпълнителят поддържа сървърите и мрежата на"), second("възложителя в работно състояние")]).len(), 1); + // Chinese and Japanese wrap between characters: their lines join without a space. Korean + // spaces its words, so its lines join with one. + let zh = ("本合同自双方签字之日起生效,有效期", "为一年,期满前一个月双方协商续签事宜"); + assert_eq!(word_texts(vec![first(zh.0), second(zh.1)]), [format!("{}{}", zh.0, zh.1)]); + assert_eq!( + word_texts(vec![first("この契約は双方が署名した日から"), second("一年間有効とする")]), + ["この契約は双方が署名した日から一年間有効とする"] + ); + assert_eq!(word_texts(vec![first("임차인은 방과 계단을 깨끗하게"), second("유지합니다")]), ["임차인은 방과 계단을 깨끗하게 유지합니다"]); let heading = |b: Block| Block { bold: true, ..b }; for (why, blocks) in [ ("a sentence ends", vec![first(&format!("{full}.")), second(next)]), ("a sentence ends in quotes", vec![first("Страните се договориха „за срок от една година.“"), second(next)]), + ("a Chinese sentence ends", vec![first("本合同自双方签字之日起生效。"), second(zh.1)]), + ("a Japanese sentence ends in quotes", vec![first("「この契約は一年間有効ですか?」"), second("双方が署名した日から")]), ("the line ends short", vec![line(next, 72.0, 240.0, 700.0), line(full, 72.0, 540.0, 686.0)]), ("paragraph space", vec![first(full), line(next, 72.0, 240.0, 676.0)]), ("an indent", vec![first(full), line(next, 90.0, 258.0, 686.0)]), From 5480dbe163ea5327bbd738ce0c679b69a9f57c7d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 30 Oct 2026 19:29:27 +0100 Subject: [PATCH 046/562] M2.6: shared JBIG2 globals are read once a document; Copy Image is tested on JBIG2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From CodeRabbit's review of the stack: - Every JBIG2 image read its /JBIG2Globals afresh, within 32 MiB each but with no bound across an export or a redaction. A globals stream of the file, not edited since it was read and with its /DecodeParms direct, now has its filters undone once a document and is kept for the images that share it (in every clone, which share the file), up to 64 MiB in all. An edited one, one added in memory, or one with /DecodeParms by reference (another object, which can change under it) is read each time as before. This saves the filters only: the decoder reads the segments afresh for every image, so a real symbol dictionary still costs its decoding once an image. - view.copy-image dropped "JBIG2 inside other filters" from its missing list on the strength of Export's test; Copy Image now has its own: a JBIG2 picture behind ASCIIHexDecode is found under the pointer and copied as its pixels and as a PNG, and one whose page information is in its globals is read through them. A JBIG2 image's /Width and /Height are read as given directly; one by reference is an error rather than an image of no rows. A /JBIG2Globals reference to no object still reads as no globals: it is a reference to null (ISO 32000-2 §7.3.10), which an entry may hold for being absent (§7.3.7), and the renderer draws the page so. An image that needed those globals then mostly fails to decode; in a probe of twelve pictures that decoded with them, eleven were refused and one decoded to other pixels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- Cargo.lock | 1 + crates/cos/README.md | 4 +- crates/cos/src/document.rs | 3 + crates/cos/src/document/objects.rs | 57 ++++++++--- crates/cos/src/document/open.rs | 2 + crates/cos/src/document/tests.rs | 153 +++++++++++++++++++++++++++++ crates/engine/Cargo.toml | 2 + crates/engine/src/tests.rs | 99 +++++++++++++++++++ parity/acrobat-features.toml | 4 +- 9 files changed, 310 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf3be727f4..410b347b5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4395,6 +4395,7 @@ dependencies = [ "pdfcraft-create", "pdfcraft-edit", "pdfcraft-export", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-geom", diff --git a/crates/cos/README.md b/crates/cos/README.md index 8362012225..1e2551036f 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -91,5 +91,7 @@ recognisable in a diff and byte-stable when re-serialized. - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. - Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding - (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB); + (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB, + their filters undone once a document for the images that share them, up to 64 MiB kept, while + their segments are decoded for every image); DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c0bd656536..082d1910d1 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -49,6 +49,9 @@ pub struct Document { repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, + /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan + /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). + jbig2_globals: Arc>>>>, stream_limit: usize, overlay: BTreeMap, next_num: u32, diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index bec4a90717..39a4d9a6da 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -5,6 +5,10 @@ use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, }; +/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see +/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`]. +const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS; + impl Document { pub fn bytes(&self) -> &Arc> { &self.data @@ -301,13 +305,13 @@ impl Document { }; // `/Width` and `/Height` are read as given directly, as every caller reads them first: one // given by reference has the image refused there, before it gets here. - let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()); if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() { - let Some(columns) = s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()) else { - return Err(CosError::Filter("a JBIG2 image's /Width isn't a size".into())); + let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else { + return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into())); }; let globals = self.jbig2_globals(s)?; - return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref(), columns, height, max).map_err(filter); + return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter); } let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and @@ -317,24 +321,53 @@ impl Document { return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); } let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); - let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); + let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0)); let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?; pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter) } /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) - /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one. Globals that can't be read - /// are an error, rather than the image decoded without them. - fn jbig2_globals(&self, s: &Stream) -> Result>, CosError> { - let globals = self.last_filter_parms(s).get(b"JBIG2Globals").map(|g| self.resolve(g)); - let Some(Object::Stream(g)) = globals.as_deref() else { return Ok(None) }; - self.with_direct_parms(g).decoded_within(MAX_JBIG2_GLOBALS).map(Some).map_err(|e| { + /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is + /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read + /// without them, as the renderer draws it, so an image that needed them mostly fails to decode + /// and can decode to other pixels. A stream that is there and can't be read is an error, rather + /// than the image decoded without it. + /// + /// The images of a scan share one globals stream, so a stream of the file, not edited since + /// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they + /// are another object, which can change while this one doesn't), has its filters undone once a + /// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That + /// saves the filters only: the segments it holds are decoded again for every image. + fn jbig2_globals(&self, s: &Stream) -> Result>>, CosError> { + let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) }; + let held = match entry { + Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num), + _ => None, + }; + if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) { + return Ok(Some(hit)); + } + let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) }; + let readable = self.with_direct_parms(g); + let held = held.filter(|_| matches!(readable, Cow::Borrowed(_))); + let mut data = readable.decoded_within(MAX_JBIG2_GLOBALS).map_err(|e| { let detail = match e { CosError::Filter(detail) => detail, e => e.to_string(), }; CosError::Filter(format!("the JBIG2 globals can't be read: {detail}")) - }) + })?; + // What is kept counts by its length, so that's all it may hold on to. + data.shrink_to_fit(); + let data = Arc::new(data); + // A poisoned lock only means nothing is kept. + if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) { + let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add); + if total <= JBIG2_GLOBALS_HELD { + kept.insert(num, data.clone()); + } + } + Ok(Some(data)) } pub fn try_get(&self, num: u32) -> Result, CosError> { diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index 57d697ed66..395424b771 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -19,6 +19,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + jbig2_globals: Arc::default(), stream_limit: crate::object::MAX_DECODED, overlay: BTreeMap::new(), next_num: 1, @@ -102,6 +103,7 @@ impl Document { repair_log: Arc::new(Vec::new()), cache: Arc::default(), objstms: Arc::default(), + jbig2_globals: Arc::default(), stream_limit: stream_limit.min(crate::object::MAX_DECODED), overlay: BTreeMap::new(), next_num: 1, diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 7110fa6b72..cca4b87326 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -173,6 +173,159 @@ fn fax_images_decode_with_their_parameters() { assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); } +/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept +/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the +/// file's), and objects added in memory aren't kept. +#[test] +fn shared_jbig2_globals_are_read_once() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped)))); + let image = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.add(Object::Stream(Stream::from_raw(d, coded.clone()))) + }; + let (a, b) = (image(&mut doc), image(&mut doc)); + let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits); + assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept"); + + // The same objects read from a file, the catalog holding on to the images (a full save may + // number them anew). + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap(); + let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let mut doc = Document::open(Arc::new(file)).unwrap(); + let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap()); + let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone(); + let globals = parms.reference(b"JBIG2Globals").unwrap(); + let clone = doc.clone(); + assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits); + assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept"); + let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone(); + assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image"); + let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone(); + assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode"); + doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec()))); + assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh"); + assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's"); +} + +/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image +/// with neither is an error, not an image of no rows. +#[test] +fn a_jbig2_image_needs_its_size_given_directly() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap(); + let mut doc = Document::new_empty(); + let size = doc.add(Object::Int(2)); + let image = |width: Object, height: Object| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), width); + d.set(b"Height".to_vec(), height); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + doc.decoded_image(&Stream::from_raw(d, coded.clone())) + }; + assert!(image(Object::Int(8), Object::Int(2)).is_ok()); + assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference"); + assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference"); +} + +/// A document that kept globals of 64 MiB keeps no more, and the images past that still read: +/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time. +#[test] +fn kept_jbig2_globals_are_bounded_in_total() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let length = 22 * 1024 * 1024; + let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0]; + skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes()); + skipped.extend_from_slice(&[0, 0, 0, 1]); + skipped.resize(skipped.len() + length, 0); + let mut doc = Document::new_empty(); + let mut images = Vec::new(); + for _ in 0..3 { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped)))); + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone()))))); + } + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap(); + let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone(); + for image in &images { + let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") }; + assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads"); + } + let kept = doc.jbig2_globals.lock().unwrap(); + assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't"); + assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts"); +} + +/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so +/// editing it changes what they decode to while the stream itself, the key kept by, stays the same. +#[test] +fn jbig2_globals_with_parameters_by_reference_are_read_afresh() { + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap(); + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 }; + let mut doc = Document::new_empty(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(15)); + let parms = doc.add(Object::Dict(parms)); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &skipped).unwrap(); + let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted))); + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut image_parms = Dict::new(); + image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms)); + let image = doc.add(Object::Stream(Stream::from_raw(d, coded))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap(); + let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap(); + let stream = |doc: &Document| match &*doc.get(image) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits); + assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept"); + let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap(); + let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") }; + let parms = g.dict.reference(b"DecodeParms").unwrap(); + doc.set(parms, Object::Dict(Dict::new())); + assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise"); +} + /// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of /// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference /// too): globals that can't be read are an error, and ones at another filter's place aren't its diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6dae6adf0a..fdee0bcf82 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -58,3 +58,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] pdfcraft-testkit = { workspace = true } +# JBIG2 fixtures (`encode_jbig2_generic`). +pdfcraft-filters = { workspace = true } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 833fa16b9f..2cd226ee1e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8079,6 +8079,105 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert!(d.page_image_copy_file(0, 1).is_err()); } +/// Copy Image reads a JBIG2 image's `/JBIG2Globals`: here they hold the page information, and the +/// image's own stream only an extension segment decoders skip, so it has a picture only through +/// them. Its default pixel is black. +#[test] +fn copy_image_reads_the_globals_of_a_jbig2_picture() { + let mut globals = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19]; + globals.extend_from_slice(&16u32.to_be_bytes()); + globals.extend_from_slice(&2u32.to_be_bytes()); + globals.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0]); // resolution, flags, striping + let image = [0, 0, 0, 1, 62, 0, 1, 0, 0, 0, 0]; + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(), + stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"), + stream( + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 6 0 R >>", + &image, + ), + stream("", &globals), + ]; + 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()); + let mut s = Session::new(); + let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (16, 2)); + assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals"); +} + +/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter +/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`. +#[test] +fn copy_image_copies_a_jbig2_picture_behind_another_filter() { + // 16 × 2: the left half of the top row black, the rest white. + let bits = [0x00, 0xFF, 0xFF, 0xFF]; + let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let stream = |dict: &str, data: &[u8]| { + let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + o.extend_from_slice(data); + o.extend_from_slice(b"\nendstream"); + o + }; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(), + stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"), + stream( + "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter [/ASCIIHexDecode /JBIG2Decode]", + &hex, + ), + ]; + 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()); + let mut s = Session::new(); + let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.page_image_at(0, [30.0, 115.0]), Some(0)); + assert_eq!(d.page_image_copy_file(0, 0).unwrap().0, "png"); + let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); + assert_eq!((w, h), (16, 2)); + let grey: Vec = rgba.chunks(4).map(|px| px[0]).collect(); + let expected: Vec = (0..32).map(|i| if i < 8 { 0 } else { 255 }).collect(); + assert_eq!(grey, expected, "the picture, black as 0"); +} + /// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656). fn fixture_without_page_tree() -> Vec { let bytes = fixture(2); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4878eac627..2187f33c81 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -130,7 +130,7 @@ milestone = "M1" status = "partial" commands = [] tools = [] -evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals"] +evidence = ["crates/filters/src/ccitt.rs::decoding_gives_the_rows_back", "crates/filters/src/ccitt.rs::group_3_rows_on_bytes_read_by_the_codes_in_the_data", "crates/cos/src/document/tests.rs::fax_images_decode_with_their_parameters", "crates/filters/src/jbig2/decode.rs::both_codings_decode_to_their_samples", "crates/filters/src/jbig2/decode.rs::what_it_cannot_decode_is_an_error", "crates/cos/src/document/tests.rs::jbig2_images_decode_with_their_globals", "crates/cos/src/document/tests.rs::shared_jbig2_globals_are_read_once"] notes = "decoded through the hayro bootstrap for display; own codecs are M1.1/M1.2. CCITT fax decoding is in filters (decode_ccitt, through hayro-ccitt), which Document::decoded_image uses for Export ▸ Images, the optimizer and redaction; so is JBIG2 decoding (decode_jbig2, through hayro-jbig2, the page bounded before it is decoded), which it uses with the image's /JBIG2Globals (within 32 MiB) for Export ▸ Images, Copy Image and redaction. Byte-aligned Group 3 is read by its end-of-line codes when the data starts with one and on bytes otherwise; still limited: data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at /K 0 may come out as other rows, which the optimizer then writes back. The bootstrap renderer draws byte-aligned Group 3 whose fill ends each end-of-line code on a byte as other rows (black box, at every /K), which decode_ccitt reads right; redaction removes Group 3 on bytes whole" [[feature]] @@ -1598,7 +1598,7 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] +evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text", "crates/engine/src/tests.rs::copy_image_copies_a_jbig2_picture_behind_another_filter", "crates/engine/src/tests.rs::copy_image_reads_the_globals_of_a_jbig2_picture"] notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000 images, multi-format clipboard (file and bitmap at once), the web build" [[feature]] From d1677b4b3ff39117405e8fb3f92d7e73a5a15cca Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 00:20:10 +0100 Subject: [PATCH 047/562] M11: an indexed image also drawn where the optimizer doesn't read keeps its resolution From CodeRabbit's review: default colour spaces apply to an indexed colour space's device base, so one in force where an annotation's appearance (or a pattern, or another image's /Alternates) draws the image may change which palette entry is darker, and the tie a resampled image keeps. Only black-and-white RGB went through used_elsewhere; indexed images now do too when downsampling is on, and one used beyond the page and form resources the walk read keeps its resolution, as such RGB keeps its colour space. A grey image stays out of this check, since sample 0 stays dark in a grey space, and #5426's rule for grey is unchanged: the test now has a grey image drawn on the page and in an annotation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/README.md | 3 ++- crates/optimize/src/images.rs | 18 ++++++++++++---- crates/optimize/tests/optimize.rs | 35 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index b8346a626a..184053c797 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -65,7 +65,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB), with no decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or `/DefaultRGB` is or may be - in force. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + in force (an indexed image referred to from beyond the page and form resources read, as an + annotation's appearance, may be). Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 2929b98845..843486f917 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -300,6 +300,14 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) } +/// Whether image `r` is indexed: its palette says which of its samples is dark ([`dark_is_one`]), +/// which default colour spaces in force where it is drawn may change. +fn indexed(doc: &Document, r: ObjRef) -> bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I"))) +} + /// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary /// too, but not those in objects referred to. fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { @@ -1144,11 +1152,13 @@ pub(crate) fn run( progress: &mut dyn FnMut(Stage) -> bool, ) -> Result<(), OptimizeError> { let (ppi, mut defaults, read) = walk_pages(doc, pages); - // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used - // where the walk didn't look may be drawn so. + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an + // indexed image's palette says which of its samples a resampled one keeps, which they may + // change: one used where the walk didn't look may be drawn so. let packs = settings.mono.compression != MonoCompression::Retain; - let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); - defaults.extend(used_elsewhere(doc, &rgb, &read)); + let checked: HashSet = + ppi.keys().filter(|r| (packs && device_rgb(doc, **r)) || (settings.mono.downsample && indexed(doc, **r))).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &checked, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 92dc2e2d86..3fe59a1325 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3504,6 +3504,41 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { } } +/// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where +/// other default colour spaces may be in force and change which palette entry is darker) keeps its +/// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is +/// averaged. A grey image is averaged either way: sample 0 stays dark in a grey space, and only an +/// indexed image's palette depends on what's in force where it is drawn. +#[test] +fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), palette]); + let cases = [(&indexed, false, 600), (&indexed, true, 1200), (&Object::name("DeviceGray"), false, 600), (&Object::name("DeviceGray"), true, 600)]; + for (cs, in_annotation, width) in cases { + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs.clone())]); + // 144 × 57.6 pt is 600 ppi. + let p = page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + if in_annotation { + let resources = Object::Dict({ + let mut r = Dict::new(); + r.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", image)]))); + r + }); + let appearance = drawing(&mut doc, Some(resources), &form_entries()); + annotate(&mut doc, p, appearance); + } + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, image).dict.int(b"Width"), Some(width), "{cs:?}, in an annotation: {in_annotation}"); + } +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { From e2adbe7faa44a405878da33ed76842ff37bc6963 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 00:41:23 +0100 Subject: [PATCH 048/562] M11: tests: an indexed palette is read by its colours' luma, and only a real palette counts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/tests/optimize.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3fe59a1325..34df39cb15 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3539,6 +3539,33 @@ fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() { } } +/// Which palette entry is darker comes from the colours, weighted as luma, and blue is darker than +/// red whichever comes first. An array shaped like an indexed colour space and named otherwise has +/// no palette, so its samples aren't averaged. +#[test] +fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 0, 0])); + let optimized = |space: &str| { + let cs = Object::Array(vec![Object::name(space), Object::name("DeviceRGB"), 1.into(), palette.clone()]); + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs)]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + (s.dict.int(b"Width"), doc.decoded_image(&s).unwrap()) + }; + let (width, samples) = optimized("Indexed"); + assert_eq!(width, Some(600)); + assert!(samples.iter().any(|b| *b != 0xFF), "the blue line, half a new pixel wide, stays as the dark sample"); + assert_eq!(optimized("NotIndexed").0, Some(1200)); +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { From 689a1e34cfc3883fd821fad8dfad5e64ae2cfaf6 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 00:41:23 +0100 Subject: [PATCH 049/562] M8: test: a cleared image of more than one bit stays Flate Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2e76bb0bac..4a0a40fd25 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -990,6 +990,29 @@ fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() { } } +/// Only one bit of one component goes to Group 4 or JBIG2. An 8-bit grey image here starts as white +/// as a bilevel scan and goes on as noise Flate can't shrink, so Group 4, which would read only the +/// first rows' worth of bytes, would look far smaller while losing the picture. +#[test] +fn a_cleared_image_of_more_than_one_bit_stays_flate() { + let (w, h) = (256usize, 256usize); + let mut data = vec![0xFF; w * h]; + let mut state = 0x1234_5678u32; + for b in data.iter_mut().skip(w * h / 8) { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + *b = state.to_le_bytes()[0]; + } + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"); + let mut doc = one_page(b"q 200 0 0 20 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, pdfcraft_filters::encode_flate(&data))]); + mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept"); +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. From 6ad95f0aa481d64c8e574ae8fefb00a99cf9b4fd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 01:05:58 +0100 Subject: [PATCH 050/562] M11: an indexed image's palette is read once it matters, within 4 KiB; a colour key keeps its image From a review of the spellings an indexed image can take: - The palette was read for every 1-bit indexed image, resampled or not, and twice each. Images share a table, so 1,000 images sharing one with 16 MiB of raw data took 16 to 60 s, none of them resampled. It is now read only for an image about to be resampled, and a table over 4 KiB is refused before it is copied or decoded, since no palette needs that. - A colour key can hide the dark sample a tie goes to, so a thin rule of the other was lost: a white rule on a hidden black went from 255 to the backdrop's 128. An image with a colour key now keeps its resolution. Grey had this since #5426 and is covered too. - `/Indexed` by reference, and a base of `[/DeviceRGB]`, are the same colour space and are read so. `/I`, an inline abbreviation, and a real hival aren't, and such an image is left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/README.md | 9 +++-- crates/optimize/src/images.rs | 46 ++++++++++++++++----- crates/optimize/tests/optimize.rs | 67 +++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 184053c797..994ccbd9d0 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -63,10 +63,11 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or - `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB), with no decode array or one of two - different numbers, and not drawn or listed where `/DefaultGray` or `/DefaultRGB` is or may be - in force (an indexed image referred to from beyond the page and form resources read, as an - annotation's appearance, may be). Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC + `DeviceRGB`, whose palette is read (Rec. 601 luma for RGB; a table of at most 4 KiB), with no + decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or + `/DefaultRGB` is or may be in force (an indexed image referred to from beyond the page and form + resources read, as an annotation's appearance, may be). Not one with a colour key, which can + hide the dark sample a tie would go to. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, most of whose new pixels would cover both black and white, so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25% diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 843486f917..0f0e67c0d9 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1,6 +1,7 @@ //! Image resampling and recompression. use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; @@ -305,7 +306,12 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool { fn indexed(doc: &Document, r: ObjRef) -> bool { let Object::Stream(s) = &*doc.get(r) else { return false }; let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); - matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I"))) + matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed"))) +} + +/// The first entry of colour space array `a`, its family, read through a reference. +fn family(doc: &Document, a: &[Object]) -> Option> { + a.first().map(|f| doc.resolve(f)) } /// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary @@ -762,7 +768,7 @@ fn one_component(doc: &Document, cs: &Object) -> bool { let cs = doc.resolve(cs); match &*cs { Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), - Object::Array(a) => match a.first().and_then(Object::as_name) { + Object::Array(a) => match family(doc, a).as_deref().and_then(Object::as_name) { Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), @@ -838,7 +844,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); // Components a pixel, bits a component, and a pixel's samples as 1 or 0. let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { - Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + Object::Array(a) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; match decode.as_deref() { None => {} @@ -938,10 +944,18 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { return Some(d1 < d0); } + // A colour key can hide the dark sample, and a tie going to it would lose a thin rule of the + // sample that shows. + if matches!(d.get(b"Mask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Array(_))) { + return None; + } match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), - Some(Object::Array(a)) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { - let hival = a.get(2).and_then(|v| doc.resolve(v).as_int()).filter(|h| (0..=255).contains(h))?; + Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")) => { + let hival = a.get(2).and_then(|v| match &*doc.resolve(v) { + Object::Int(h) if (0..=255).contains(h) => Some(*h), + _ => None, + })?; // `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap. let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize; let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?); @@ -951,21 +965,31 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option { } } +/// The most bytes a palette is read to (see [`lightness`]). +const MAX_PALETTE: usize = 4096; + /// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`) /// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only /// those two bases count, where the palette's values are the colour: under `/DefaultGray` or /// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base /// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold. fn lightness(doc: &Document, a: &[Object], i: usize) -> Option { - let n = match doc.resolve(a.get(1)?).as_name()? { + // A device space is its name, or that name alone in an array. + let base = doc.resolve(a.get(1)?); + let base = match &*base { + Object::Array(b) if b.len() == 1 => doc.resolve(b.first()?), + _ => base, + }; + let n = match base.as_name()? { b"DeviceGray" => 1, b"DeviceRGB" => 3, _ => return None, }; - // At most 256 entries of three components: a stream decoding to more is no palette. + // At most 256 entries of three components: more is no palette. Images share a table, and this + // is read for each of them, so a large one is refused before it's copied or decoded. let table = match &*doc.resolve(a.get(3)?) { - Object::String(s) => s.bytes.clone(), - Object::Stream(s) => doc.with_direct_parms(s).decoded_within(4096).ok()?, + Object::String(s) if s.bytes.len() <= MAX_PALETTE => s.bytes.clone(), + Object::Stream(s) if s.raw.len() <= 4 * MAX_PALETTE => doc.with_direct_parms(s).decoded_within(MAX_PALETTE).ok()?, _ => return None, }; let c: Vec = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect(); @@ -1089,8 +1113,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet } else { (w, h) }; - let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; - let averaged = if (nw, nh) != (w, h) && polarity.is_some() { + let polarity = if defaults || (nw, nh) == (w, h) { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; + let averaged = if polarity.is_some() { // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?; // A halftone or dither: most new pixels cover both samples. Text covers both only along diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 34df39cb15..1db90f92be 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3566,6 +3566,73 @@ fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { assert_eq!(optimized("NotIndexed").0, Some(1200)); } +/// What is averaged, by how the image is spelled. A family or a base by reference, or a device base +/// alone in an array, is the same colour space. `/I`, an inline abbreviation, and a real `hival` +/// aren't one, and neither is a table too large to be a palette, which images share and which +/// would be read for each. A colour key can hide the dark sample a tie goes to, so a thin rule of +/// the other would be lost, in grey too; a stencil `/Mask` doesn't. +#[test] +fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let table = || Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255])); + let indexed = |family: Object, base: Object, hival: Object, table: Object| Object::Array(vec![family, base, hival, table]); + let rgb = || Object::name("DeviceRGB"); + let plain = || indexed(Object::name("Indexed"), rgb(), 1.into(), table()); + let width = |make: &dyn Fn(&mut Document) -> Vec<(&'static str, Object)>| { + let mut doc = Document::new_empty(); + let mut entries = vec![("BitsPerComponent", Object::Int(1))]; + entries.extend(make(&mut doc)); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + stream(&doc, image).dict.int(b"Width") + }; + let averaged = Some(600); + let kept = Some(1200); + assert_eq!(width(&|_| vec![("ColorSpace", plain())]), averaged, "direct"); + assert_eq!( + width(&|doc: &mut Document| { + let family = doc.add(Object::name("Indexed")); + vec![("ColorSpace", indexed(Object::Ref(family), rgb(), 1.into(), table()))] + }), + averaged, + "the family by reference" + ); + let base = Object::Array(vec![rgb()]); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), base.clone(), 1.into(), table()))]), averaged, "the base in an array"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("I"), rgb(), 1.into(), table()))]), kept, "an inline abbreviation"); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival"); + let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat())); + assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette"); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode")); + let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', 16385)).chain(*b">").collect(); + assert_eq!( + width(&|doc: &mut Document| { + let large = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))); + vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(large)))] + }), + kept, + "a stream too large to be read for each image, whatever it decodes to" + ); + let key = || Object::Array(vec![0.into(), 0.into()]); + assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed"); + assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey"); + assert_eq!( + width(&|doc: &mut Document| { + let key = doc.add(key()); + vec![("ColorSpace", plain()), ("Mask", Object::Ref(key))] + }), + kept, + "a colour key by reference" + ); +} + /// White margins must not hide a small screened photograph from the tone guard. #[test] fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1e637d1068..c62a1bacc6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units", "crates/optimize/tests/optimize.rs::ambiguous_monochrome_text_is_recompressed_without_lossy_reduction", "crates/optimize/tests/optimize.rs::indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes", "crates/optimize/src/images.rs::an_indexed_images_palette_says_which_sample_is_dark", "crates/optimize/src/images.rs::it_matches_the_slow_way", "crates/automation/tests/it/automation.rs::doc_optimize_averages_black_and_white_scans_down", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base, images drawn or listed where /DefaultGray or /DefaultRGB is in force, images with a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome grey, image masks and indexed images (1-bit, CCITT fax, and eight-bit black-and-white grey and RGB packed to one bit) averaged by area to 300 ppi above 450 ppi by default, a tie to the dark sample (black in grey, painted in a mask, the darker palette entry over DeviceGray or DeviceRGB, after a decode array of two different numbers), then stored as Group 4 or 1-bit Flate (Monochrome Images ▸ Downsample to; doc_optimize mono); not separations or DeviceN (their tint transforms aren't read), calibrated or ICC grey, indexed images over another base or with a table over 4 KiB, images drawn or listed where /DefaultGray or /DefaultRGB is or may be in force (an indexed image also used beyond the page and form resources read, as an annotation's appearance), images with a colour key, which can hide the dark sample, or a soft mask, halftones and dithers (most of their new pixels would cover both black and white, and their tones turn solid), images with a square of four such new pixels (a small screened picture on a white page; ragged scanned text has them along its strokes too, and keeps its resolution), or images wider than a fax row (1,048,576 samples), and Acrobat's bicubic and subsampling choices for monochrome aren't offered" [[feature]] id = "optimize.recompress-images" From 24c6529f772d33a83199263d5f27770afe3bae15 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 01:15:52 +0100 Subject: [PATCH 051/562] M11: test: a palette stream is read up to exactly 16 KiB of raw data Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/tests/optimize.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 1db90f92be..0efb5df8e3 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3609,17 +3609,25 @@ fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() { assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival"); let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat())); assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette"); + // 16 KiB of raw data is all a table may take, whatever it decodes to. let mut d = Dict::new(); d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode")); - let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', 16385)).chain(*b">").collect(); - assert_eq!( - width(&|doc: &mut Document| { - let large = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))); - vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(large)))] - }), - kept, - "a stream too large to be read for each image, whatever it decodes to" - ); + let table_of = |raw: usize| { + let d = d.clone(); + let hex: Vec = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', raw - 13)).chain(*b">").collect(); + move |doc: &mut Document| doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone()))) + }; + for (raw, expected) in [(16384, averaged), (16385, kept)] { + let add = table_of(raw); + assert_eq!( + width(&|doc: &mut Document| { + let table = add(doc); + vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(table)))] + }), + expected, + "a stream of {raw} bytes" + ); + } let key = || Object::Array(vec![0.into(), 0.into()]); assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed"); assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey"); From 7d422a0998515dc6f14262d7fb59bd32920b9cdf Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 01:39:27 +0100 Subject: [PATCH 052/562] M11: JBIG2 alone isn't decoded for Group 4 unless it is resampled An ordinary scan stored as JBIG2 was decoded for Group 4 by default, at 0.26 s a page at 300 ppi, and came out unchanged: Group 4 of a symbol-coded page was 201,132 bytes against 17,291. Over a 500-page scan that is minutes for nothing. Dropping the guard changed no output, as a cost guard wouldn't, so its test counts the pixels decoded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/README.md | 6 ++--- crates/optimize/src/images.rs | 47 +++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index ee4216a140..27cada0d2f 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -78,12 +78,12 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going 600 ppi page of text that is averaged, Group 4 at 300 ppi is about half the size. - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that - is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; + is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder recovers it, a bad row picked up again at the next code, as it reports no damage there; without - them, damage stops the decoding and the image is left alone. JBIG2, alone or inside another - filter, is stored again as the compression asks when that is smaller (as JBIG2 symbols where + them, damage stops the decoding and the image is left alone. JBIG2 inside another filter, or + alone when JBIG2 is asked for, is stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and often comes out larger, and then stays as it is. A JBIG2 image stored again refers to no `/JBIG2Globals`, and one whose page isn't its size (PdfCraft's diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index e0b0fb6357..cdd3c89d91 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -463,7 +463,7 @@ fn is_dct(s: &Stream) -> Option { #[cfg(test)] thread_local! { - /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + /// Pixels decoded, for the tests that keep an image from being decoded for nothing. static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -819,6 +819,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve // A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit. _ => allowance(len, h)?, }; + count_decoded(u64::from(w) * u64::from(h)); let mut data = doc.decoded_image_within(s, max).ok()?; if data.len() < len { return None; @@ -1079,8 +1080,8 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// - **Stored** as CCITT Group 4 with `CcittG4`, or JBIG2 with `Jbig2`, or as 1-bit Flate when that /// is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image /// that is only stored again becomes Group 4 or JBIG2, or stays. Group 4 alone is only decoded -/// to be resampled; Group 3, Group 4 inside another filter, and JBIG2 are stored again like any -/// other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or +/// to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, Group 4 inside +/// another filter, and JBIG2 otherwise are stored again like any other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or /// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or /// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`. /// @@ -1095,10 +1096,12 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet // Its kind of compression, which Retain keeps, behind any other filters. let ccitt = fax.is_some(); let jbig2 = matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))); - // Group 4 alone is only worth decoding to resample it. Group 3, Group 4 behind ASCII85Decode, - // say, and JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored - // again alone. - let only_to_resample = retain || (fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4); + // Group 4 alone is only worth decoding to resample it, and so is JBIG2 alone when Group 4 is what + // it would be stored as: that output is rarely smaller, and decoding an ordinary scan costs a + // quarter of a second a page. Group 3, Group 4 behind ASCII85Decode, say, and JBIG2 stored as + // JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone. + let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4; + let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0))); if only_to_resample && !downsample { return None; } @@ -1307,6 +1310,36 @@ mod dimension_tests { } /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. + /// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped, + /// no output changes, and an ordinary scan pays a quarter of a second a page for nothing. Behind + /// another filter it can still be stored again, as Group 4 can. + #[test] + fn a_jbig2_image_alone_is_not_decoded_for_group_4_unless_it_is_resampled() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let hex: Vec = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + let image = |filter: Object, data: Vec| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), filter); + Stream::from_raw(dict, data) + }; + let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }; + let decoded = |s: &Stream, settings: &MonoSettings| { + DECODED_PIXELS.with(|c| c.set(0)); + mono(&Document::new_empty(), s, 300.0, false, settings); + DECODED_PIXELS.with(|c| c.get()) + }; + let alone = image(Object::name("JBIG2Decode"), coded.clone()); + assert_eq!(decoded(&alone, &settings), 0, "alone, for Group 4, below the threshold"); + let behind = image(Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")]), hex); + assert!(decoded(&behind, &settings) > 0, "behind another filter it can be stored again"); + let jbig2 = MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }; + assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller"); + } + #[test] fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); From 824395256900d01c2db57caa83c3bd49ef25d2d9 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 01:26:54 +0100 Subject: [PATCH 053/562] fix(render): a JBIG2 stream's decoding work is bounded in total (hayro-jbig2 patch 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each JBIG2 bitmap is bounded, but nothing bounded how many a stream asks for, so a few hundred bytes could tie up a render worker for minutes, and CodeRabbit raised the same on #5461. Measured through pdfcraft-cli, before and after: - one halftone segment, a 795-byte PDF: 265 s, now refused at once; - 66 halftones over patterns 1 wide, a 3,894-byte PDF: about 330 s, now refused at once; - about 226,000 generic regions behind nested Flate, a 923-byte PDF: unfinished at 40 s, some 88 hours by extrapolation, now 4.8 s; - 8 dictionaries of symbols of no width, a 983-byte PDF: 81 s, now 0.7 s; - about 1,048,000 generic regions of no width, a 34 KB PDF: unfinished at 90 s, now 2.6 s; - an aggregated dictionary over 2^20 symbols, a 713-byte PDF: unfinished at 90 s, now 9.1 s; - 10,000 instances of an 8192 × 8192 symbol, an 18 KB PDF: 64 s, now 3.2 s; - 30 generic regions of no data: 48 s, now 4.7 s. A stream may now ask for 2^30 units of work: four of the largest pages, about 6 s on data the decoder predicts and about 17 s on data it can't, inside the viewer's 20 s watchdog. Decoding a bitmap costs a unit a pixel and 4 a row, since a row costs time at any width. Copying one costs a unit a word and 1 a row, so a tall thin bitmap costs its rows. Each item is charged before it is decoded, apart from what is known only as it goes: new symbols and text instances are charged in their loops. An aggregated symbol is charged for the symbol table it rebuilds. Past the bound the image isn't drawn, as with the other caps, and the rest of the page is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 288 +++++++++++++++++++++- vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 24 ++ vendor/hayro-jbig2/src/decode/halftone.rs | 30 ++- vendor/hayro-jbig2/src/decode/pattern.rs | 13 +- vendor/hayro-jbig2/src/decode/symbol.rs | 20 +- vendor/hayro-jbig2/src/decode/text.rs | 21 +- vendor/hayro-jbig2/src/error.rs | 3 + vendor/hayro-jbig2/src/lib.rs | 37 +++ 9 files changed, 422 insertions(+), 16 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 4bf29f9157..e167b3d4a9 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3719,7 +3719,12 @@ fn jbig2_pattern_dictionary(height: u8, patterns: u32) -> Vec { /// A 1 × 1 halftone region over a grid of `width` × `height` cells, all at the region's corner, /// coded by MMR or arithmetically. fn jbig2_halftone(width: u32, height: u32, mmr: bool) -> Vec { - let mut halftone = jbig2_region_info(1, 1); + jbig2_halftone_of(1, width, height, mmr) +} + +/// [`jbig2_halftone`] for a square region of `side` pixels. +fn jbig2_halftone_of(side: u32, width: u32, height: u32, mmr: bool) -> Vec { + let mut halftone = jbig2_region_info(side, side); halftone.push(u8::from(mmr)); // template 0 halftone.extend_from_slice(&width.to_be_bytes()); halftone.extend_from_slice(&height.to_be_bytes()); @@ -3891,6 +3896,287 @@ fn jbig2_globals_that_are_jbig2_themselves_are_not_decoded() { assert!(jbig2_image_draws_with(&black_jbig2_page(), "/DeviceGray", "/DecodeParms << /JBIG2Globals 6 0 R >>", &objects)); } +/// A Huffman-coded symbol dictionary of one white symbol, `side` from 76 up, exported or not. +fn square_jbig2_dictionary(side: u32, exported: bool) -> Vec { + let mut bits = Bits::default(); + bits.put(0b11111, 5); // height class delta, the upper line of table B.4 + bits.put(side - 76, 32); + bits.put(0b111110, 6); // width delta, the upper line of table B.2 + bits.put(side - 75, 32); + bits.put(0b111111, 6); // end of the class (B.2) + let bytes = side.div_ceil(8) + 1; // an MMR-coded white bitmap: one bit a row + bits.table_b1(bytes); // BMSIZE (B.1) + bits.align(); + for _ in 0..bytes { + bits.put(0xFF, 8); + } + if exported { + bits.table_b1(0); + } + bits.table_b1(1); + let mut dictionary = vec![0x00, 0x01]; // Huffman, tables B.4, B.2, B.1, B.1 + dictionary.extend_from_slice(&u32::from(exported).to_be_bytes()); + dictionary.extend_from_slice(&1u32.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + +/// A text region of `width` × `height` placing symbol 0 of one, Huffman-coded, `instances` of 0 or 1. +fn jbig2_text_region(width: u32, height: u32, instances: u32) -> Vec { + let mut text = jbig2_region_info(width, height); + text.extend_from_slice(&[0x00, 0x11, 0, 0]); // Huffman, top left; tables B.6, B.8, B.11 + text.extend_from_slice(&instances.to_be_bytes()); + let mut bits = Bits::default(); + for code in 0..35 { + bits.put(u32::from(code == 1), 4); // only run code 1, one bit long + } + bits.put(0, 1); // symbol 0 is one bit long + bits.align(); + bits.put(0, 1); // the first strip's T (B.11) + if instances > 0 { + bits.put(0, 1); // delta T 1 (B.11) + bits.put(0, 9); // first S 0 (B.6) + bits.put(0, 1); // symbol 0 + bits.put(0b01, 2); // end of the strip (B.8) + } + text.extend_from_slice(&bits.bytes); + text +} + +/// A 1 × 1 text region placing one instance of symbol 0 of one, refined, Huffman-coded. +fn jbig2_refined_text_region() -> Vec { + let mut text = jbig2_region_info(1, 1); + text.extend_from_slice(&[0x80, 0x13, 0, 0]); // Huffman, refined by template 1, top left + text.extend_from_slice(&1u32.to_be_bytes()); + let mut bits = Bits::default(); + for code in 0..35 { + bits.put(u32::from(code == 1), 4); // only run code 1, one bit long + } + bits.put(0, 1); // symbol 0 is one bit long + bits.align(); + bits.put(0, 1); // the first strip's T (B.11) + bits.put(0, 1); // delta T 1 (B.11) + bits.put(0, 9); // first S 0 (B.6) + bits.put(0, 1); // symbol 0 + bits.put(1, 1); // refined + bits.put(0, 4); // no change of width, height or offset (B.14) + bits.table_b1(0); // no refinement data (B.1) + bits.align(); + bits.put(0b01, 2); // end of the strip (B.8) + text.extend_from_slice(&bits.bytes); + text +} + +/// A generic refinement region of 1 × 1, with whatever its data decodes to. +fn jbig2_refinement_region() -> Vec { + let mut region = jbig2_region_info(1, 1); + region.push(1); // template 1, no typical prediction + region.extend_from_slice(&[0; 4]); + region +} + +/// Each item a JBIG2 stream decodes costs its work, and the stream may ask for 2^30: four of the +/// largest pages, some 6 s to 17 s. Each bitmap is bounded on its own, but nothing bounded how many +/// there were, so 1,140 bytes of generic regions took over 40 s, and a 795-byte PDF's one halftone +/// 265 s. Vendored hayro-jbig2 patch: `MAX_WORK`. A row costs 4 pixels, even in a bitmap of no +/// width, and a copy goes by the word and the row, so a tall thin one costs its rows. Here regions +/// of typical rows, cheap to decode though charged like any, spend all but exactly what the item +/// costs, so it draws with that left and is refused with one less, for each item at its own cost. +#[test] +fn jbig2_decoding_work_is_counted_item_by_item() { + struct Item { + what: &'static str, + /// What comes first, and what it costs. + before: Vec<(u8, Vec)>, + spent: u64, + kind: u8, + refs: &'static [u8], + data: Vec, + cost: u64, + } + let stream = |item: &Item, left: u64| { + let mut stream = black_jbig2_page(); + let mut number = 0; + for (kind, data) in &item.before { + number += 1; + jbig2_segment(&mut stream, number, *kind, &[], data); + } + // A square costs 16,384 rows of 16,388, and a single row what it has and 4 more. + let work = (1u64 << 30) - item.spent - left; + let (row, square) = (16388u64, 16384 * 16388u64); + let mut squares = work / square; + let mut rest = work % square; + if rest > 0 && rest < 5 { + squares -= 1; + rest += square; + } + let mut rows = rest / row; + let mut last = rest % row; + if last > 0 && last < 5 { + rows -= 1; + last += row; + } + let regions = std::iter::repeat_n((16384, 16384), usize::try_from(squares).unwrap()) + .chain((rows > 0).then_some((16384, rows))) + .chain((last > 0).then_some((last - 4, 1))); + for (width, height) in regions { + number += 1; + let region = jbig2_white_region(u32::try_from(width).unwrap(), u32::try_from(height).unwrap()); + jbig2_segment(&mut stream, number, 38, &[], ®ion); // immediate generic region + } + jbig2_segment(&mut stream, number + 1, item.kind, item.refs, &item.data); + stream + }; + let symbol = || vec![(0, zero_width_jbig2_dictionary(1, true))]; + let patterns = || vec![(16, jbig2_pattern_dictionary(1, 2))]; + let exporting_none = { + let mut dictionary = vec![0x00, 0x01]; + dictionary.extend_from_slice(&[0; 8]); // exported, new + let mut bits = Bits::default(); + bits.table_b1(1); // the first export run, not exported, is the one it refers to + dictionary.extend_from_slice(&bits.bytes); + dictionary + }; + let zero = |height: u32| { + let mut bits = Bits::default(); + bits.put(0b11111, 5); // height class delta, the upper line of table B.4 + bits.put(height - 76, 32); + bits.put(0, 1); // width delta 0 (B.2) + bits.put(0b111111, 6); // end of the class (B.2) + bits.table_b1(0); // BMSIZE 0, stored (B.1) + bits.align(); + bits.table_b1(1); // the first export run, not exported, is the symbol + let mut dictionary = vec![0x00, 0x01]; + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&1u32.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary + }; + // The cost of a pattern dictionary of two patterns: rows of them side by side, then each taken out. + let two_patterns = |width: u64, height: u64| height * (2 * width + 4) + 2 * height * (width.div_ceil(32) + 1); + let thin_region = |side: u32| { + let mut region = jbig2_white_region(1, 1); + region.splice(0..8, [0u32.to_be_bytes(), side.to_be_bytes()].concat()); + region + }; + let items = vec![ + Item { what: "immediate generic", before: vec![], spent: 0, kind: 38, refs: &[], data: jbig2_white_region(1, 1), cost: 5 }, + Item { what: "intermediate generic", before: vec![], spent: 0, kind: 36, refs: &[], data: jbig2_white_region(1, 1), cost: 5 }, + Item { what: "a region of no width", before: vec![], spent: 0, kind: 38, refs: &[], data: thin_region(1000), cost: 4000 }, + Item { what: "immediate refinement", before: vec![], spent: 0, kind: 42, refs: &[], data: jbig2_refinement_region(), cost: 5 }, + Item { what: "intermediate refinement", before: vec![], spent: 0, kind: 40, refs: &[], data: jbig2_refinement_region(), cost: 5 }, + Item { + what: "pattern dictionary", + before: vec![], + spent: 0, + kind: 16, + refs: &[], + data: jbig2_pattern_dictionary(1, 2), + cost: two_patterns(1, 1), + }, + Item { + what: "tall patterns", + before: vec![], + spent: 0, + kind: 16, + refs: &[], + data: jbig2_pattern_dictionary(32, 2), + cost: two_patterns(1, 32), + }, + // Rows cost in a symbol of no pixels too. + Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 12 }, + Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4000 }, + Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6720 }, + Item { what: "referred symbols", before: symbol(), spent: 4, kind: 0, refs: &[1], data: exporting_none, cost: 1 }, + // Its symbol, and its one row and word; Huffman-coded, a table line for the symbol. + Item { what: "immediate text", before: symbol(), spent: 4, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "intermediate text", before: symbol(), spent: 4, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "a text region's pixels", before: symbol(), spent: 4, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 }, + Item { + what: "an intermediate text region's pixels", + before: symbol(), + spent: 4, + kind: 4, + refs: &[1], + data: jbig2_text_region(32, 32, 0), + cost: 75, + }, + // The region's 13, a copy of the symbol's row and the round of the loop. + Item { what: "an instance", before: symbol(), spent: 4, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 15 }, + // The region's 13, 80 rows of 4 words copied, the round of the loop. + Item { + what: "an instance of a large symbol", + before: vec![(0, square_jbig2_dictionary(80, true))], + spent: 6720, + kind: 6, + refs: &[1], + data: jbig2_text_region(1, 1, 1), + cost: 334, + }, + // And decoded as well as copied. + Item { + what: "a refined instance", + before: vec![(0, square_jbig2_dictionary(80, true))], + spent: 6720, + kind: 6, + refs: &[1], + data: jbig2_refined_text_region(), + cost: 7054, + }, + // A bit plane, a cell's copy and the region's. + Item { + what: "immediate halftone", + before: patterns(), + spent: two_patterns(1, 1), + kind: 22, + refs: &[1], + data: jbig2_halftone(1, 1, false), + cost: 9, + }, + Item { + what: "intermediate halftone", + before: patterns(), + spent: two_patterns(1, 1), + kind: 20, + refs: &[1], + data: jbig2_halftone(1, 1, false), + cost: 9, + }, + // A plane of 2 cells, each a copy of 32 rows of 2 words' cost, and the region's. + Item { + what: "a halftone's cells", + before: vec![(16, jbig2_pattern_dictionary(32, 2))], + spent: two_patterns(1, 32), + kind: 22, + refs: &[1], + data: jbig2_halftone(2, 1, false), + cost: 136, + }, + Item { + what: "a grid of no width", + before: patterns(), + spent: two_patterns(1, 1), + kind: 22, + refs: &[1], + data: jbig2_halftone(0, 1000, false), + cost: 4002, + }, + Item { + what: "a halftone's pixels", + before: patterns(), + spent: two_patterns(1, 1), + kind: 22, + refs: &[1], + data: jbig2_halftone_of(32, 1, 1, false), + cost: 71, + }, + ]; + for item in &items { + assert!(jbig2_image_draws(&stream(item, item.cost)), "{}: with its cost left it draws", item.what); + assert!(!jbig2_image_draws(&stream(item, item.cost - 1)), "{}: with one less it's refused", item.what); + } +} + /// An image's page header can claim any size, and the Indexed path reserves its output from it /// before the page bitmap is sized: a 743-byte PDF asked for 137 GB, which aborts the process. /// Vendored hayro-jbig2 patch: a page past the bounds is refused when the stream is opened. diff --git a/vendor/README.md b/vendor/README.md index 88c39bee44..e3806a57a6 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 | (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` | +| 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 item is charged, one with no pixels too: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s | (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` | | 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 | (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` | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index e960ab59d8..b76ebbdb2b 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -24,6 +24,30 @@ pub(crate) const MAX_PIXELS: u64 = 1 << 28; /// the pixel bound alone let a 98-byte segment take 1 GiB; this one leaves 128 MiB. pub(crate) const MAX_GRID_CELLS: u64 = 1 << 25; +/// PdfCraft patch: the decoding work a stream may ask for, in pixels decoded from their code: four +/// of the largest pages, about 6 s on data the decoder predicts and about 17 s on data it can't, so +/// a render finishes inside its 20 s watchdog. Each bitmap is bounded on its own, but nothing +/// bounded how many there are: 1,140 bytes of generic regions took over 40 s, a 795-byte PDF's one +/// halftone 265 s, and a 923-byte PDF's regions would have taken days. +pub(crate) const MAX_WORK: u64 = 4 * MAX_PIXELS; + +/// PdfCraft patch: the work of a line of a Huffman table built for symbols, about 50 ns (see +/// [`MAX_WORK`]). +pub(crate) const HUFFMAN_LINE_WORK: u64 = 10; + +/// PdfCraft patch: the work of decoding a bitmap pixel by pixel (see [`MAX_WORK`]). A row costs up +/// to 21 ns however few pixels it has, about 4 pixels' worth, so one of no width isn't free: 16,384 +/// regions of 0 × 65535 took 9 s at a cost of nothing. +pub(crate) fn decode_work(width: u32, height: u32) -> u64 { + u64::from(height) * (u64::from(width) + 4) +} + +/// PdfCraft patch: the work of copying a bitmap (see [`MAX_WORK`]): a word at a time, and a row is +/// a copy of its own, so a tall thin bitmap costs its rows, not its pixels. +pub(crate) fn copy_work(width: u32, height: u32) -> u64 { + u64::from(height) * (u64::from(width.div_ceil(WORD_BITS)) + 1) +} + /// The underlying word type for packed pixel storage. /// Change this to `u32` to switch back to 32-bit words. pub(crate) type Word = u32; diff --git a/vendor/hayro-jbig2/src/decode/halftone.rs b/vendor/hayro-jbig2/src/decode/halftone.rs index 6ea7e4be34..10000f801b 100644 --- a/vendor/hayro-jbig2/src/decode/halftone.rs +++ b/vendor/hayro-jbig2/src/decode/halftone.rs @@ -7,7 +7,7 @@ use super::RegionBitmap; use super::pattern::PatternDictionary; use super::{CombinationOperator, RegionSegmentInfo, Template, parse_region_segment_info}; use crate::ScratchBuffers; -use crate::bitmap::{Bitmap, MAX_DIMENSION, WORD_BITS}; +use crate::bitmap::{Bitmap, MAX_DIMENSION, WORD_BITS, copy_work, decode_work}; use crate::error::{OverflowError, ParseError, RegionError, Result, bail}; use crate::gray_scale::{GrayScaleParams, decode_gray_scale_image}; use crate::reader::Reader; @@ -48,11 +48,7 @@ pub(crate) fn decode_into( None }; - // "3) Set HBPP to ⌈log₂(HNUMPATS)⌉." (6.6.5) - let bits_per_pixel = (pattern_dict.patterns.len() as u32) - .saturating_sub(1) - .checked_ilog2() - .map_or(1, |n| n + 1); + let bits_per_pixel = bits_per_pixel(pattern_dict); // "4) Decode an image GI of size HGW by HGH with HBPP bits per pixel using // the gray-scale image decoding procedure as described in Annex C." (6.6.5) @@ -73,6 +69,28 @@ pub(crate) fn decode_into( Ok(()) } +/// PdfCraft patch: the work of a halftone region (see `bitmap::MAX_WORK`): a bit plane of the grid +/// for each bit of a pattern's index, a pattern copied for each cell, and the region itself. One +/// such segment in a 795-byte PDF took 265 s. +pub(crate) fn work(header: &HalftoneRegionHeader<'_>, patterns: &PatternDictionary) -> u64 { + let grid = &header.grid_position_and_size; + let cells = u64::from(grid.width) * u64::from(grid.height); + let planes = u64::from(bits_per_pixel(patterns)); + let copied = copy_work(patterns.pattern_width, patterns.pattern_height); + planes + .saturating_mul(decode_work(grid.width, grid.height)) + .saturating_add(cells.saturating_mul(copied)) + .saturating_add(copy_work(header.region_info.width, header.region_info.height)) +} + +/// "3) Set HBPP to ⌈log₂(HNUMPATS)⌉." (6.6.5) +fn bits_per_pixel(patterns: &PatternDictionary) -> u32 { + (patterns.patterns.len() as u32) + .saturating_sub(1) + .checked_ilog2() + .map_or(1, |n| n + 1) +} + /// Parse a halftone region segment header (7.4.5.1). pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result> { let region_info = parse_region_segment_info(reader)?; diff --git a/vendor/hayro-jbig2/src/decode/pattern.rs b/vendor/hayro-jbig2/src/decode/pattern.rs index 7507fa551f..03d4d8eb89 100644 --- a/vendor/hayro-jbig2/src/decode/pattern.rs +++ b/vendor/hayro-jbig2/src/decode/pattern.rs @@ -3,7 +3,7 @@ use super::{AdaptiveTemplatePixel, Template, generic}; use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, WORD_BITS, Word}; +use crate::bitmap::{Bitmap, WORD_BITS, Word, copy_work}; use crate::error::{OverflowError, ParseError, Result}; use crate::reader::Reader; use alloc::vec::Vec; @@ -218,6 +218,17 @@ pub(crate) struct PatternDictionaryHeader<'a> { pub(crate) data: &'a [u8], } +impl PatternDictionaryHeader<'_> { + /// PdfCraft patch: the work of decoding the patterns, side by side in one bitmap, and of + /// taking each out of it (see `bitmap::MAX_WORK`). + pub(crate) fn work(&self) -> u64 { + let (width, height) = (u32::from(self.pattern_width), u32::from(self.pattern_height)); + let patterns = u64::from(self.num_patterns) + 1; + let side_by_side = u64::from(height) * (patterns.saturating_mul(u64::from(width)) + 4); + side_by_side.saturating_add(patterns.saturating_mul(copy_work(width, height))) + } +} + /// Parse a pattern dictionary segment header (7.4.4.1). pub(crate) fn parse<'a>(reader: &mut Reader<'a>) -> Result> { let flags_byte = reader.read_byte().ok_or(ParseError::UnexpectedEof)?; diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index 082856c530..94250a3de9 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -4,7 +4,7 @@ use alloc::vec; use alloc::vec::Vec; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{BITMAP_WORDS, Bitmap, WORD_BITS, Word}; +use crate::bitmap::{BITMAP_WORDS, Bitmap, HUFFMAN_LINE_WORK, WORD_BITS, Word, decode_work}; use crate::decode::generic::{decode_bitmap_mmr, parse_adaptive_template_pixels}; use crate::decode::text::{ DecodeContext, ReferenceCorner, TextRegionContexts, TextRegionFlags, TextRegionHeader, @@ -46,6 +46,7 @@ pub(crate) fn decode( standard_tables: &StandardHuffmanTables, input_contexts: Option<&RetainedContexts>, room: u64, + work_left: &mut u64, ) -> Result { let num_new_symbols = header.num_new_symbols; @@ -61,6 +62,7 @@ pub(crate) fn decode( pixels: 0, new_words: 0, room, + work_left, header, standard_tables, }; @@ -133,6 +135,9 @@ pub(crate) fn decode( if ctx.pixels > MAX_DICTIONARY_PIXELS { bail!(OverflowError::BitmapDimension); } + // PdfCraft patch: see `bitmap::MAX_WORK`. + let work = decode_work(symbol_width, ctx.height_class_height); + *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; // PdfCraft patch: see `room`. Rows take a word at least, so symbols of one column // held 270 MiB within the pixel bound, however few the dictionary exported. let rows = u64::from(symbol_width.div_ceil(WORD_BITS)) * u64::from(ctx.height_class_height); @@ -359,6 +364,11 @@ fn decode_aggregation_bitmap( ) -> Result { let use_huffman = ctx.header.flags.use_huffman; + // PdfCraft patch: see `bitmap::MAX_WORK`. What follows is sized by all the symbols, for each + // aggregated symbol, at about 50 ns a symbol: a 713-byte PDF asked for an hour. + let work = u64::from(ctx.total_symbols()).saturating_mul(HUFFMAN_LINE_WORK); + *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + // Concatenate input and new symbols. let mut all_symbols: Vec<&Bitmap> = Vec::with_capacity(ctx.symbols.input.len() + ctx.symbols.new.len()); @@ -444,7 +454,7 @@ fn decode_aggregation_bitmap( header.region_info.y_location, header.flags.default_pixel, )?; - decode_with(decode_ctx, &all_symbols, &header, &mut bitmap)?; + decode_with(decode_ctx, &all_symbols, &header, &mut bitmap, ctx.work_left)?; Ok(bitmap) } @@ -491,6 +501,8 @@ struct SymbolDecodeContext<'a> { room: u64, /// PdfCraft patch: the words of the new symbols so far (see `room`). new_words: u64, + /// PdfCraft patch: the decoding work the stream may still ask for (see `bitmap::MAX_WORK`). + work_left: &'a mut u64, /// PdfCraft patch: symbol pixels decoded so far (see `MAX_DICTIONARY_PIXELS`). pixels: u64, } @@ -914,8 +926,8 @@ mod tests { let input = vec![&symbol; 1000]; let tables = StandardHuffmanTables::new(); let words = symbol.words() as u64 * 1000; - let decoded = decode(&header, &input, &[], &tables, None, words).unwrap(); + let decoded = decode(&header, &input, &[], &tables, None, words, &mut u64::MAX.clone()).unwrap(); assert_eq!(decoded.exported_symbols.len(), 1000); - assert!(decode(&header, &input, &[], &tables, None, words - 1).is_err()); + assert!(decode(&header, &input, &[], &tables, None, words - 1, &mut u64::MAX.clone()).is_err()); } } diff --git a/vendor/hayro-jbig2/src/decode/text.rs b/vendor/hayro-jbig2/src/decode/text.rs index bbc2ac55c7..0bd32a68db 100644 --- a/vendor/hayro-jbig2/src/decode/text.rs +++ b/vendor/hayro-jbig2/src/decode/text.rs @@ -11,7 +11,7 @@ use super::{ use super::{RegionBitmap, generic_refinement}; use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::Bitmap; +use crate::bitmap::{Bitmap, copy_work, decode_work}; use crate::error::{HuffmanError, OverflowError, ParseError, Result, SymbolError, bail}; use crate::huffman_table::{HuffmanTable, StandardHuffmanTables, TableLine}; use crate::integer_decoder::IntegerDecoder; @@ -25,6 +25,7 @@ pub(crate) fn decode( referred_tables: &[HuffmanTable], standard_tables: &StandardHuffmanTables, scratch: &mut ScratchBuffers, + work_left: &mut u64, ) -> Result { let mut bitmap = Bitmap::new_with( header.region_info.width, @@ -41,6 +42,7 @@ pub(crate) fn decode( standard_tables, &mut bitmap, scratch, + work_left, )?; Ok(RegionBitmap { @@ -56,12 +58,13 @@ pub(crate) fn decode_into( standard_tables: &StandardHuffmanTables, bitmap: &mut Bitmap, scratch: &mut ScratchBuffers, + work_left: &mut u64, ) -> Result<()> { if header.flags.use_huffman { let mut reader = Reader::new(header.data); let ctx = DecodeContext::new_huffman(&mut reader, header, referred_tables, standard_tables)?; - decode_with(ctx, symbols, header, bitmap)?; + decode_with(ctx, symbols, header, bitmap, work_left)?; } else { let mut decoder = ArithmeticDecoder::new(header.data); @@ -76,7 +79,7 @@ pub(crate) fn decode_into( .resize(num_gr_contexts, ArithmeticDecoderContext::default()); let ctx = DecodeContext::new_arithmetic(&mut decoder, &mut contexts, &mut scratch.contexts); - decode_with(ctx, symbols, header, bitmap)?; + decode_with(ctx, symbols, header, bitmap, work_left)?; } Ok(()) @@ -88,6 +91,7 @@ pub(crate) fn decode_with( symbols: &[&Bitmap], header: &TextRegionHeader<'_>, region: &mut Bitmap, + work_left: &mut u64, ) -> Result<()> { let strip_size = header.strip_size(); @@ -156,6 +160,17 @@ pub(crate) fn decode_with( let symbol_width = symbol_bitmap_ref.width as i32; let symbol_height = symbol_bitmap_ref.height as i32; + // PdfCraft patch: see `bitmap::MAX_WORK`. An instance of a large symbol costs its copy, + // and 10,000 of one took 64 s. A refined one was decoded as well. Even one with no + // pixels goes round this loop. + let (width, height) = (symbol_bitmap_ref.width, symbol_bitmap_ref.height); + let copied = copy_work(width, height).saturating_add(1); + let work = match &symbol_bitmap { + SymbolBitmap::Reference(_) => copied, + SymbolBitmap::Owned(_) => copied.saturating_add(decode_work(width, height)), + }; + *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + if !header.flags.transposed && (header.flags.reference_corner == ReferenceCorner::TopRight || header.flags.reference_corner == ReferenceCorner::BottomRight) diff --git a/vendor/hayro-jbig2/src/error.rs b/vendor/hayro-jbig2/src/error.rs index 75fbd7b759..c8bcf8b811 100644 --- a/vendor/hayro-jbig2/src/error.rs +++ b/vendor/hayro-jbig2/src/error.rs @@ -139,6 +139,8 @@ pub enum OverflowError { RetainedBitmaps, /// PdfCraft patch: a stream has more segments, or references, than it may. SegmentCount, + /// PdfCraft patch: a stream asks for more decoding work than it may. + DecodingWork, } impl fmt::Display for DecodeError { @@ -168,6 +170,7 @@ impl fmt::Display for OverflowError { Self::SymbolCount => write!(f, "too many symbols in a dictionary or referred to"), 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"), } } } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 1604c3ed7a..b8ab097b76 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -259,6 +259,18 @@ fn emit_bitmap(bitmap: &Bitmap, decoder: &mut D) { } } +/// PdfCraft patch: count `work` more decoding work, refusing past [`bitmap::MAX_WORK`] before what +/// asks for it is decoded. +fn charge_work(left: &mut u64, work: u64) -> Result<()> { + *left = left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + Ok(()) +} + +/// PdfCraft patch: the work of a region decoded pixel by pixel. +fn region_work(region: &decode::RegionSegmentInfo) -> u64 { + bitmap::decode_work(region.width, region.height) +} + fn decode_segments( segments: &[segment::Segment<'_>], height_from_stripes: Option, @@ -283,6 +295,7 @@ fn decode_segments( let page_bitmap = &mut decoder_ctx.page_bitmap; let page_state = &mut decoder_ctx.page_state; let scratch_buffers = &mut decoder_ctx.scratch_buffers; + let mut work_left = bitmap::MAX_WORK; // Process all segments. for seg in segments { @@ -295,6 +308,7 @@ fn decode_segments( SegmentType::ImmediateGenericRegion | SegmentType::ImmediateLosslessGenericRegion => { let had_unknown_length = seg.header.data_length.is_none(); let header = generic::parse(&mut reader, had_unknown_length)?; + charge_work(&mut work_left, region_work(&header.region_info))?; if page_state.can_decode_directly(page_bitmap, &header.region_info, false) { generic::decode_into(&header, page_bitmap, scratch_buffers)?; @@ -312,11 +326,13 @@ fn decode_segments( SegmentType::IntermediateGenericRegion => { // Intermediate segments cannot have unknown length. let header = generic::parse(&mut reader, false)?; + charge_work(&mut work_left, region_work(&header.region_info))?; let region = generic::decode(&header, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::PatternDictionary => { let header = pattern::parse(&mut reader)?; + charge_work(&mut work_left, header.work())?; let dictionary = pattern::decode(&header, scratch_buffers, page_state.retained_room())?; page_state.store_pattern_dictionary(seg.header.segment_number, dictionary)?; } @@ -325,6 +341,7 @@ fn decode_segments( // (6.5.5, step 1) // Collect references to avoid cloning; symbols are only cloned if re-exported. let input_symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(&mut work_left, input_symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -351,12 +368,14 @@ fn decode_segments( &page_state.standard_tables, retained_contexts, page_state.retained_room(), + &mut work_left, )?; page_state.store_symbol_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::ImmediateTextRegion | SegmentType::ImmediateLosslessTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(&mut work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. // "These user-supplied Huffman decoding tables may be supplied either @@ -370,6 +389,11 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; + charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + if header.flags.use_huffman { + // Its symbol ID table has a line for each symbol. + charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + } if page_state.can_decode_directly( page_bitmap, @@ -383,6 +407,7 @@ fn decode_segments( &page_state.standard_tables, page_bitmap, scratch_buffers, + &mut work_left, )?; } else { let region = text::decode( @@ -391,6 +416,7 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, scratch_buffers, + &mut work_left, )?; page_bitmap.combine( ®ion.bitmap, @@ -404,6 +430,7 @@ fn decode_segments( SegmentType::IntermediateTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; + charge_work(&mut work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -415,12 +442,18 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; + charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + if header.flags.use_huffman { + // Its symbol ID table has a line for each symbol. + charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + } let region = text::decode( &header, &symbols, &referred_tables, &page_state.standard_tables, scratch_buffers, + &mut work_left, )?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } @@ -433,6 +466,7 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; + charge_work(&mut work_left, halftone::work(&header, pattern_dict))?; if page_state.can_decode_directly( page_bitmap, @@ -460,6 +494,7 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; + charge_work(&mut work_left, halftone::work(&header, pattern_dict))?; let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } @@ -473,6 +508,7 @@ fn decode_segments( .unwrap_or(page_bitmap); let header = generic_refinement::parse(&mut reader)?; + charge_work(&mut work_left, region_work(&header.region_info))?; let region = generic_refinement::decode(&header, reference, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } @@ -490,6 +526,7 @@ fn decode_segments( .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; + charge_work(&mut work_left, region_work(&header.region_info))?; if let Some(referred_segment) = referred_segment && page_state.can_decode_directly(page_bitmap, &header.region_info, false) From 53874375cbf7afe77be0df35bbeb32a0d5110c4a Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 02:53:16 +0100 Subject: [PATCH 054/562] M8: test: a cleared copy stored as Group 4 or JBIG2 can be redacted again; the README's not-yet list The README still listed metadata and hidden-information sanitising as not done, though sanitize does it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/README.md | 4 +-- crates/redact/src/tests.rs | 60 +++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index e52c1d8b98..0a33c4e20b 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -109,5 +109,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r `CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes. Custom code sets aren't supported yet. -Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information -sanitising (M8.4), re-encoding of DCT images (they are removed instead). +Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed +instead). diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 4a0a40fd25..9db24fff4e 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -897,14 +897,9 @@ fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { } } -/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when -/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it -/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples. -#[test] -fn a_cleared_black_and_white_scan_is_stored_as_group_4() { - // 800 × 200: lines of word-sized dark blocks 14 rows tall, their edges ragged by a sample from - // row to row, as a scan's are; Group 4 codes such rows from the row above. - let (w, h) = (800usize, 200usize); +/// `w` × `h` rows, `w` a multiple of 8: lines of word-sized dark blocks 14 rows tall, their edges +/// ragged by a sample from row to row, as a scan's are; Group 4 codes such rows from the row above. +fn ragged_scan(w: usize, h: usize) -> Vec { let stride = w / 8; let mut seed = 0x9E37_79B9_7F4A_7C15u64; let mut next = move || { @@ -927,6 +922,17 @@ fn a_cleared_black_and_white_scan_is_stored_as_group_4() { x += len + 6 + (next() % 6) as usize; } } + bits +} + +/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when +/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it +/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples. +#[test] +fn a_cleared_black_and_white_scan_is_stored_as_group_4() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap(); assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here"); let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); @@ -1013,6 +1019,44 @@ fn a_cleared_image_of_more_than_one_bit_stays_flate() { assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept"); } +/// A copy stored as Group 4 or JBIG2 is read like any image: a second redaction, across a save, +/// clears it again rather than removing it whole, keeps what the first cleared, and stores it +/// smaller than Flate again. +#[test] +fn a_cleared_copy_stored_as_group_4_or_jbig2_can_be_redacted_again() { + let (w, h) = (800usize, 200usize); + let stride = w / 8; + let bits = ragged_scan(w, h); + let image = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray"); + for (filter, data, wanted) in [ + ("/FlateDecode", pdfcraft_filters::encode_flate(&bits), &b"CCITTFaxDecode"[..]), + ("/JBIG2Decode", pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(), &b"JBIG2Decode"[..]), + ] { + let dict = format!("{image} /Filter {filter}"); + let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{filter}"); + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(wanted), "{filter}"); + mark(&mut saved, 0, &[[150.0, 100.0, 200.0, 125.0]], ""); + let report = apply(&mut saved, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{filter}: cleared again, not removed"); + let again = Document::open(Arc::new(write_full(&saved, &SaveOptions::default()).unwrap())).unwrap(); + // Rows 0 to 99 of the left quarter, then 100 to 199 of the right. + let mut expected = bits.clone(); + for (rows, columns) in [(0..100, 0..200), (100..200, 600..800)] { + for y in rows { + for x in columns.clone() { + expected[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let names: Vec> = (1..4).map(|n| format!("PCRedacted{n}").into_bytes()).collect(); + let stored = names.iter().find_map(|n| named_image(&again, 0, n)); + assert_eq!(stored, Some(expected), "{filter}"); + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. From 738cbadab75051ce120932d5cd83dbe8e8878c6e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 08:33:12 +0100 Subject: [PATCH 055/562] M2.6: a page past the JBIG2 cap may be refused by the decoder first The test said such a page is refused here, as Unsupported. #5491 makes hayro-jbig2 refuse it when the stream is opened, as Corrupt, so whichever of the two PRs landed second would fail this test. A page past the cap is refused either way, and that's what the test now says. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index ae72f9bc83..2ec9f97dd5 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -137,9 +137,10 @@ mod tests { let page = 26 * h as usize; assert!(matches!(decode_jbig2(&data, None, w, h, page - 1), Err(FilterError::LimitExceeded(m)) if m == page - 1), "a byte short"); assert_eq!(decode_jbig2(&data, None, w, h, page).unwrap().len(), page, "the page within max"); - // 16,384 × 16,384 is 2^28 pixels, and fails only later; a row more is past the cap. + // 16,384 × 16,384 is 2^28 pixels, and fails only later; a row more is past the cap, here or + // in the decoder, which can refuse it first. let large = |rows| decode_jbig2(&page_of(&data, 16_384, rows), None, 16_384, rows, usize::MAX); - assert!(matches!(large(16_385), Err(FilterError::Unsupported(_))), "{:?}", large(16_385)); + assert!(matches!(large(16_385), Err(FilterError::Unsupported(_) | FilterError::Corrupt { .. })), "{:?}", large(16_385)); assert!(!matches!(large(16_384), Err(FilterError::Unsupported(_))), "{:?}", large(16_384)); // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips: its 11-byte header From 84a2005f9e3b523b1c10b5d9a58c36c5d640b2f5 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 08:19:54 +0100 Subject: [PATCH 056/562] M2: an Indexed lookup table in an indirect string is read A colour space [/Indexed /DeviceRGB 1 5 0 R] whose table is a string in object 5 couldn't be read: the table was tried as a stream first, and a failed read uses up a reference, though not a direct object, so nothing was left to read it as a string. The space then drew in grey from the index, so a palette's colours were lost. The table is now read once, as whichever it is (hayro-interpret patch 71). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 56 +++++++++++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/color.rs | 15 ++++---- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index df6896b980..520c666728 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -802,6 +802,62 @@ fn indexed_palettes_over_lab_keep_their_colours() { } assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}"); } + +/// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can +/// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it +/// as a stream used up the reference, so the space couldn't be read and drew in grey. +#[test] +fn an_indexed_lookup_table_is_read_in_every_legal_form() { + use super::*; + let render = |space: &str, content: &str, extra: &str| -> [u8; 4] { + let bytes = 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 << /ColorSpace << /I {space} >> /XObject << /Im 9 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj <000000FF0000> endobj\n\ + 6 0 obj << /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF0000>\nendstream endobj\n\ + 7 0 obj /DeviceRGB endobj\n\ + 8 0 obj 1 endobj\n\ + {extra}\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{space}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let paint = "/I cs 1 sc 0 0 200 200 re f"; + // One byte of index 1, as an image in the same space: the image path reads it too. + let image = "9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 5 0 R] /Filter /ASCIIHexDecode /Length 3 >> stream\n01>\nendstream endobj"; + for space in [ + "[/Indexed /DeviceRGB 1 <000000FF0000>]", + "[/Indexed /DeviceRGB 1 5 0 R]", + "[/Indexed /DeviceRGB 1 6 0 R]", + "[/Indexed 7 0 R 8 0 R 5 0 R]", + "[/I /DeviceRGB 1 5 0 R]", + ] { + assert_eq!(render(space, paint, ""), [255, 0, 0, 255], "{space}"); + } + let indirect = "10 0 obj [/Indexed /DeviceRGB 1 5 0 R] endobj"; + assert_eq!(render("10 0 R", paint, indirect), [255, 0, 0, 255], "the whole space by reference"); + assert_eq!(render("/DeviceRGB", "q 200 0 0 200 0 0 cm /Im Do Q", image), [255, 0, 0, 255], "an image's table by reference"); + // What's neither table form leaves the space unreadable, as a missing table does. + for space in ["[/Indexed /DeviceRGB 1 8 0 R]", "[/Indexed /DeviceRGB 1 99 0 R]", "[/Indexed /DeviceRGB 1]"] { + let _ = render(space, paint, ""); + } +} #[test] fn watchdog_skips_a_stuck_page_and_keeps_rendering() { use super::*; diff --git a/vendor/README.md b/vendor/README.md index 84b2546bf3..5d9ec134d0 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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` | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 29a74a6de8..b1ea8c1001 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -999,13 +999,14 @@ impl Indexed { let hival = iter.next::()?.min(u8::MAX as u32) as u8; let values = { - let data = iter - .next::>() - .and_then(|s| s.decoded().ok()) - .or_else(|| { - iter.next::>() - .map(|s| Cow::Owned(s.to_vec())) - })?; + // PdfCraft patch (71): read once, as whichever it is. A failed read as a stream leaves + // a direct string in place but uses up a reference, so a table in an indirect string + // was lost, and the space with it. + let data = match iter.next::>()? { + Object::Stream(s) => s.decoded().ok()?, + Object::String(s) => Cow::Owned(s.to_vec()), + _ => return None, + }; let num_components = base_color_space.num_components(); From fa7acd315690311b6cae752298a94822c9e2490d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 08:58:25 +0100 Subject: [PATCH 057/562] M2: a Pattern space's underlying space and a group of array items keep their references Two more ways a reference was lost in the same readers. A Pattern space read its family name twice, which took the underlying space when that was a name or a reference, so an uncoloured pattern took its components as RGB: [/Pattern /DeviceCMYK] drew a cyan as red, direct names included (patch 72). Tuples of array items, which /Decode, /W and /W2 are read as, took a reference at their start for the whole tuple. A tuple can't be given by reference, so it was lost and the array ended there: an image with /Decode [7 0 R 0] wasn't drawn, and CID widths fell back to /DW. A tuple now reads its items directly, and each follows its own reference (hayro-syntax patch 32). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 101 ++++++++++++++++++++++++ vendor/README.md | 4 +- vendor/hayro-interpret/src/color.rs | 3 +- vendor/hayro-interpret/src/font/cid.rs | 54 +++++++++++++ vendor/hayro-syntax/src/object/array.rs | 6 ++ vendor/hayro-syntax/src/object/mod.rs | 6 +- vendor/hayro-syntax/src/object/tuple.rs | 4 + 7 files changed, 174 insertions(+), 4 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 520c666728..dc8ddb1a44 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -803,6 +803,107 @@ fn indexed_palettes_over_lab_keep_their_colours() { assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}"); } +/// A Pattern space's underlying space is any other, named or by reference as well as a direct array +/// (ISO 32000-2 §8.7.3.3). It was skipped when it was a name or a reference, so an uncoloured tiling +/// pattern took its components as RGB: a CMYK cyan drew red, and a grey of 0.5 dark red. Each is +/// compared with the same colour painted without the pattern. +#[test] +fn an_uncoloured_pattern_uses_its_underlying_space_in_every_form() { + use super::*; + let render = |space: &str, content: &str| -> [u8; 4] { + let pattern = "0 0 10 10 re f"; + let bytes = 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 << /ColorSpace << /C {space} >> /Pattern << /P 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream\n{pattern}\nendstream endobj\n\ + 6 0 obj /DeviceCMYK endobj\n\ + 7 0 obj /DeviceGray endobj\n\ + 8 0 obj [/Indexed /DeviceRGB 1 <00FF00FF0000>] endobj\n\ + 9 0 obj [/Pattern /DeviceCMYK] endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + pattern.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{space}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let fill = "0 0 200 200 re f"; + let cyan = render("/DeviceCMYK", &format!("/DeviceCMYK cs 1 0 0 0 sc {fill}")); + let grey = render("/DeviceGray", &format!("/DeviceGray cs 0.5 sc {fill}")); + let green = render("8 0 R", &format!("/C cs 0 sc {fill}")); + assert_ne!(cyan[0], 255, "cyan has no red"); + for (space, colour, want) in [ + ("[/Pattern /DeviceCMYK]", "1 0 0 0", cyan), + ("[/Pattern 6 0 R]", "1 0 0 0", cyan), + ("9 0 R", "1 0 0 0", cyan), + ("[/Pattern /DeviceGray]", ".5", grey), + ("[/Pattern 7 0 R]", ".5", grey), + ("[/Pattern 8 0 R]", "0", green), + ("[/Pattern [/Indexed /DeviceRGB 1 <00FF00FF0000>]]", "0", green), + ] { + assert_eq!(render(space, &format!("/C cs {colour} /P scn {fill}")), want, "{space}"); + } +} + +/// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for +/// the whole pair, found no pair there, and ended the array, so the image didn't draw at all. +#[test] +fn a_decode_array_with_references_in_it_is_read() { + use super::*; + let render = |width_of: &str, decode: &str, space: &str, data: &str| -> [u8; 4] { + let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q"; + let bytes = 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 {width_of} /Height 1 /BitsPerComponent 8 /ColorSpace {space} /Decode {decode} /Filter /ASCIIHexDecode /Length {} >> stream\n{data}\nendstream endobj\n\ + 6 0 obj 1 endobj\n\ + 7 0 obj 0 endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + data.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{decode}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let grey = render("1", "[1 0]", "/DeviceGray", "40>"); + assert_eq!(grey, [191, 191, 191, 255], "the direct form inverts the sample"); + for decode in ["[6 0 R 0]", "[1 7 0 R]", "[6 0 R 7 0 R]"] { + assert_eq!(render("1", decode, "/DeviceGray", "40>"), grey, "{decode}"); + } + let rgb = render("1", "[1 0 1 0 1 0]", "/DeviceRGB", "204060>"); + for decode in ["[6 0 R 0 1 0 1 0]", "[1 0 6 0 R 0 1 0]", "[1 0 1 0 6 0 R 0]", "[6 0 R 7 0 R 6 0 R 7 0 R 6 0 R 7 0 R]"] { + assert_eq!(render("1", decode, "/DeviceRGB", "204060>"), rgb, "{decode}"); + } +} + /// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can /// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it /// as a stream used up the reference, so the space couldn't be read and drew in grey. diff --git a/vendor/README.md b/vendor/README.md index 5d9ec134d0..d9d87f78e3 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; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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) | (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` | +| 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); (32) a group of array items read together (a tuple: `/Decode`, `/W`, `/W2`) 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` | (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`, `raster::tests::a_decode_array_with_references_in_it_is_read`, `font::cid::width_tests::a_width_group_can_start_with_a_reference` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index b1ea8c1001..af59ab5af3 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -255,7 +255,8 @@ impl ColorSpaceType { return Some(Self::DeviceN(DeviceN::new(&color_array, cache)?)); } PATTERN => { - let _ = iter.next::>(); + // PdfCraft patch (72): the family is already read. Reading a name here too took + // the underlying space when it was a name or a reference. let cs = iter .next::>() .and_then(|o| ColorSpace::new(o, cache)) diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 44e076b4b1..341a8fa155 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -698,3 +698,57 @@ mod tests { assert_eq!(map.len(), 12); } } + +#[cfg(test)] +mod width_tests { + use super::*; + use hayro_syntax::Pdf; + use hayro_syntax::object::ObjectIdentifier; + + /// A PDF whose object 5 is `array`, for what references in it can reach. + fn with_array(array: &str) -> Pdf { + let 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 10 10] >> endobj\n\ + 4 0 obj 20 endobj\n5 0 obj {array} endobj\n6 0 obj 22 endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n" + ); + Pdf::new(pdf.into_bytes()).unwrap() + } + + /// `/W` and `/W2` read groups of items, and either kind can start with a reference: taken for + /// the whole group it was lost, with all that followed, and those glyphs fell back to `/DW`. + #[test] + fn a_width_group_can_start_with_a_reference() { + let widths = |array: &str| { + let pdf = with_array(array); + let array = pdf.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + let mut read: Vec<_> = read_widths(&array).unwrap().into_iter().collect(); + read.sort_by_key(|(cid, _)| *cid); + read + }; + let by_list = vec![(20, 500.0), (21, 600.0)]; + assert_eq!(widths("[20 [500 600]]"), by_list); + assert_eq!(widths("[4 0 R [500 600]]"), by_list); + let by_range = vec![(20, 700.0), (21, 700.0), (22, 700.0)]; + assert_eq!(widths("[20 22 700]"), by_range); + assert_eq!(widths("[4 0 R 22 700]"), by_range); + assert_eq!(widths("[4 0 R 6 0 R 700]"), by_range); + assert_eq!(widths("[1 [100] 4 0 R [500 600]]"), vec![(1, 100.0), (20, 500.0), (21, 600.0)], "after a group"); + + let vertical = |array: &str| { + let pdf = with_array(array); + let array = pdf.xref().get::>(ObjectIdentifier::new(5, 0)).unwrap(); + let mut read: Vec<_> = read_widths2(&array).unwrap().into_iter().collect(); + read.sort_by_key(|(cid, _)| *cid); + read + }; + let by_list = vec![(20, [-1000.0, 500.0, 880.0])]; + assert_eq!(vertical("[20 [-1000 500 880]]"), by_list); + assert_eq!(vertical("[4 0 R [-1000 500 880]]"), by_list); + let by_range = vec![(20, [-900.0, 400.0, 800.0]), (21, [-900.0, 400.0, 800.0]), (22, [-900.0, 400.0, 800.0])]; + assert_eq!(vertical("[20 22 -900 400 800]"), by_range); + assert_eq!(vertical("[4 0 R 22 -900 400 800]"), by_range); + } +} diff --git a/vendor/hayro-syntax/src/object/array.rs b/vendor/hayro-syntax/src/object/array.rs index d39230f7db..fb136e867a 100644 --- a/vendor/hayro-syntax/src/object/array.rs +++ b/vendor/hayro-syntax/src/object/array.rs @@ -191,6 +191,12 @@ impl<'a> FlexArrayIter<'a> { self.reader.skip_white_spaces_and_comments(); if !self.reader.at_end() { + // PdfCraft patch (32): its items each follow a reference. Read as one that might be, a + // reference at the start was taken for the whole group, which it can't be, and lost. + if T::GROUP { + return self.reader.read_with_context::(&self.ctx); + } + return match self.reader.read_with_context::>(&self.ctx)? { MaybeRef::Ref(r) => self.ctx.xref().get_with::(r.into(), &self.ctx), MaybeRef::NotRef(i) => Some(i), diff --git a/vendor/hayro-syntax/src/object/mod.rs b/vendor/hayro-syntax/src/object/mod.rs index d692db8b38..6ad97b64e4 100644 --- a/vendor/hayro-syntax/src/object/mod.rs +++ b/vendor/hayro-syntax/src/object/mod.rs @@ -32,7 +32,11 @@ pub mod dict; pub mod stream; /// A trait for PDF objects. -pub(crate) trait ObjectLike<'a>: TryFrom> + Readable<'a> + Debug + Clone {} +pub(crate) trait ObjectLike<'a>: TryFrom> + Readable<'a> + Debug + Clone { + /// PdfCraft patch (32): whether this is several items of an array, read one by one (a tuple). + /// Such a thing can't itself be given by reference, so one at its start is its first item's. + const GROUP: bool = false; +} /// A trait for PDF objects that can be borrowed directly from an [`Object`]. pub(crate) trait ObjectRefLike<'a>: Sized { diff --git a/vendor/hayro-syntax/src/object/tuple.rs b/vendor/hayro-syntax/src/object/tuple.rs index 4a7190e916..c76c1b408c 100644 --- a/vendor/hayro-syntax/src/object/tuple.rs +++ b/vendor/hayro-syntax/src/object/tuple.rs @@ -39,6 +39,7 @@ where T: ObjectLike<'a> + Debug + Clone, U: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V> Readable<'a> for (T, U, V) @@ -78,6 +79,7 @@ where U: ObjectLike<'a> + Debug + Clone, V: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V, W> Readable<'a> for (T, U, V, W) @@ -122,6 +124,7 @@ where V: ObjectLike<'a> + Debug + Clone, W: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } impl<'a, T, U, V, W, X> Readable<'a> for (T, U, V, W, X) @@ -171,4 +174,5 @@ where W: ObjectLike<'a> + Debug + Clone, X: ObjectLike<'a> + Debug + Clone, { + const GROUP: bool = true; } From ae94b545053a112d91383b60b0cde4a85592eecd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 10:01:27 +0100 Subject: [PATCH 058/562] M10: export reads a /Decode array's numbers through references Since the renderer now draws an image with /Decode [7 0 R 0], export disagreed with it. Its check of which way round a stencil mask or a CMYK JPEG is compared the two numbers as given, and a reference reads as none, so the file came out a negative of what the page draws. Tests for the function arrays the previous commit's tuple fix also covers: a function's /Domain, /Range, /Encode and /Decode are read a pair at a time too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/create/src/extract.rs | 3 +- crates/create/src/tests.rs | 28 ++++++++++++++++++ crates/render/src/raster/tests.rs | 47 +++++++++++++++++++++++++++++++ vendor/README.md | 4 +-- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b12bd50ec2..e0e90f746a 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -236,11 +236,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()); let inverted = (jpeg || mask) && s.dict .get(b"Decode") .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .and_then(|d| d.as_array().map(|a| endpoint(a.first()) > endpoint(a.get(1)))) .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 03c93d4a51..9c7e551142 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2209,3 +2209,31 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); } } + +/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read +/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws. +#[test] +fn a_stencil_masks_decode_array_can_hold_references() { + use pdfcraft_cos::Document; + let export = |decode: &str| { + let 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 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\ + 4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\ + 5 0 obj 1 endobj\n6 0 obj 0 endobj\ntrailer << /Root 1 0 R >>\n%%EOF" + ); + let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap(); + let mut export = crate::extract::extract_images(&doc, &[0], 1); + assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped); + export.images.remove(0).data + }; + let (inverted, identity) = (export("[1 0]"), export("[0 1]")); + assert_ne!(inverted, identity, "the two ways round differ"); + for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] { + assert_eq!(export(decode), inverted, "{decode}"); + } + for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] { + assert_eq!(export(decode), identity, "{decode}"); + } +} diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index dc8ddb1a44..4a982040a8 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -904,6 +904,53 @@ fn a_decode_array_with_references_in_it_is_read() { } } +/// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they +/// lost a reference at the start of a pair the same way, and the array with it. Each form is compared +/// with the direct one, which differs from the identity so the array is shown to count. +#[test] +fn a_functions_arrays_can_start_with_a_reference() { + use super::*; + let render = |function: String| -> [u8; 4] { + let content = "/C cs 0.25 sc 0 0 200 200 re f"; + let bytes = 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 << /ColorSpace << /C [/Separation /X /DeviceRGB 9 0 R] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj 1 endobj\n6 0 obj 0 endobj\n\ + 7 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 0 0] /N 1 >> endobj\n\ + 8 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 1 0] /C1 [0 0 1] /N 1 >> endobj\n\ + 9 0 obj {function} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{function}: {:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let sampled = |encode: &str| { + format!( + "<< /FunctionType 0 /Size [2] /BitsPerSample 8 /Domain [0 1] /Range [0 1 0 1 0 1] /Encode {encode} /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF8040>\nendstream" + ) + }; + let stitched = |encode: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [7 0 R 8 0 R] /Bounds [.5] /Encode {encode} >>"); + assert_ne!(render(sampled("[1 0]")), render(sampled("[0 1]")), "/Encode counts"); + assert_eq!(render(sampled("[5 0 R 0]")), render(sampled("[1 0]")), "a sampled function"); + assert_ne!(render(stitched("[1 0 0 1]")), render(stitched("[0 1 0 1]")), "/Encode counts"); + assert_eq!(render(stitched("[5 0 R 0 0 1]")), render(stitched("[1 0 0 1]")), "a stitching function"); +} + /// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can /// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it /// as a stream used up the reference, so the space couldn't be read and drew in grey. diff --git a/vendor/README.md b/vendor/README.md index d9d87f78e3..91aeceda61 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; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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); (32) a group of array items read together (a tuple: `/Decode`, `/W`, `/W2`) 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` | (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`, `raster::tests::a_decode_array_with_references_in_it_is_read`, `font::cid::width_tests::a_width_group_can_start_with_a_reference` | +| 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); (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` | (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`; (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) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | From 7d53e07f4330ca398a555ac9efc608b5eaa10cc7 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 10:36:41 +0100 Subject: [PATCH 059/562] M2.6: the JBIG2 cap's doc and test say what happens The decoder has the same 2^28 cap as decode_jbig2, and can refuse a page first, so the doc says either error can come. The page at the cap decodes, which the test now asserts, rather than failing later. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 2ec9f97dd5..320e02445a 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -20,7 +20,8 @@ pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; /// /// Errors: `data` or `globals` that don't decode, or a page of another size /// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or -/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`]). +/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the +/// same cap and can refuse the page first). pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { if rows == 0 { return Ok(Vec::new()); @@ -137,11 +138,11 @@ mod tests { let page = 26 * h as usize; assert!(matches!(decode_jbig2(&data, None, w, h, page - 1), Err(FilterError::LimitExceeded(m)) if m == page - 1), "a byte short"); assert_eq!(decode_jbig2(&data, None, w, h, page).unwrap().len(), page, "the page within max"); - // 16,384 × 16,384 is 2^28 pixels, and fails only later; a row more is past the cap, here or - // in the decoder, which can refuse it first. + // 16,384 × 16,384 is 2^28 pixels, which decodes; a row more is past the cap, here or in the + // decoder, which has the same one and can refuse it first. let large = |rows| decode_jbig2(&page_of(&data, 16_384, rows), None, 16_384, rows, usize::MAX); assert!(matches!(large(16_385), Err(FilterError::Unsupported(_) | FilterError::Corrupt { .. })), "{:?}", large(16_385)); - assert!(!matches!(large(16_384), Err(FilterError::Unsupported(_))), "{:?}", large(16_384)); + assert!(large(16_384).is_ok(), "{:?}", large(16_384).map(|page| page.len())); // An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips: its 11-byte header // gives the length of the data after it. From 43557fa0469c912c9315b645b2cba86980d74e02 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 11:44:23 +0100 Subject: [PATCH 060/562] M2: a test that a colour space referring to itself ends The Pattern fix opened a recursion path: a Pattern's underlying space is now followed, as an Indexed space's base always was. Self-references and cycles between the two end as a space that can't be read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 4a982040a8..1a639d0bb8 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -859,6 +859,43 @@ fn an_uncoloured_pattern_uses_its_underlying_space_in_every_form() { } } +/// A colour space's parts can refer back to the space, now that a Pattern's underlying space is +/// followed as an Indexed space's base always was. A file made so ends as a space that can't be +/// read, and the page draws what it can. +#[test] +fn a_colour_space_that_refers_to_itself_ends() { + use super::*; + let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f"; + for (space, objects) in [ + ("5 0 R", "5 0 obj [/Pattern 5 0 R] endobj"), + ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Pattern 5 0 R] endobj"), + ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Indexed 5 0 R 1 <0011>] endobj"), + ("5 0 R", "5 0 obj [/Indexed 5 0 R 1 <0011>] endobj"), + ] { + let bytes = 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 << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + {objects}\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{objects}: {:?}", page.error); + let start = ((150 * page.width as usize) + 50) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{objects}: what comes before it drew"); + } +} + /// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for /// the whole pair, found no pair there, and ended the array, so the image didn't draw at all. #[test] From 72e763ad12e1978c70b0d80c9ce1bd0efd528063 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 11:51:15 +0100 Subject: [PATCH 061/562] fix(render): a JBIG2 symbol with no rows costs a step, and a refined instance is charged first From CodeRabbit on the work count. A symbol with no rows cost no work, though it is decoded all the same, so a stream of thousands of dictionaries of such symbols, exporting none, went uncounted. Each symbol now costs a step more than its pixels. A refined text instance was charged after it was decoded, so one could still be 2^28 pixels past what was left before the decoder refused. It's charged once its size is read, before anything is allocated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 49 +++++++++++++++++++------ vendor/README.md | 2 +- vendor/hayro-jbig2/src/decode/symbol.rs | 5 ++- vendor/hayro-jbig2/src/decode/text.rs | 18 ++++----- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index e167b3d4a9..75af83511b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3673,6 +3673,31 @@ fn zero_width_jbig2_dictionary(symbols: u32, exported: bool) -> Vec { dictionary } +/// A Huffman-coded symbol dictionary of `symbols` symbols of width 1 and no rows, none exported. +/// Only table B.5 has a negative height class delta, so it gets to no rows from one. +fn no_row_jbig2_dictionary(symbols: u32) -> Vec { + let mut bits = Bits::default(); + bits.put(0, 1); // height class delta 1 (B.5), a class of no symbols + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + bits.put(0b1111110, 7); // height class delta -1 (B.5): -255 and 254 + bits.put(254, 8); + bits.put(0b10, 2); // width delta 1 (B.2) + for _ in 1..symbols { + bits.put(0, 1); // width delta 0 (B.2) + } + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + bits.table_b1(symbols); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x05]; // Huffman, tables B.5, 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 +} + /// A Huffman-coded symbol dictionary of `symbols` white symbols of width 1 and `height` rows, none /// exported, for `height` from 76 up. fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { @@ -4083,31 +4108,33 @@ fn jbig2_decoding_work_is_counted_item_by_item() { data: jbig2_pattern_dictionary(32, 2), cost: two_patterns(1, 32), }, + // Each symbol costs one more than its pixels, so that one of no rows isn't free. + Item { what: "symbols of no rows", before: vec![], spent: 0, kind: 0, refs: &[], data: no_row_jbig2_dictionary(5), cost: 5 }, // Rows cost in a symbol of no pixels too. - Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 12 }, - Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4000 }, - Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6720 }, - Item { what: "referred symbols", before: symbol(), spent: 4, kind: 0, refs: &[1], data: exporting_none, cost: 1 }, + Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 15 }, + Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4001 }, + Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6721 }, + Item { what: "referred symbols", before: symbol(), spent: 5, kind: 0, refs: &[1], data: exporting_none, cost: 1 }, // Its symbol, and its one row and word; Huffman-coded, a table line for the symbol. - Item { what: "immediate text", before: symbol(), spent: 4, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, - Item { what: "intermediate text", before: symbol(), spent: 4, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, - Item { what: "a text region's pixels", before: symbol(), spent: 4, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 }, + Item { what: "immediate text", before: symbol(), spent: 5, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "intermediate text", before: symbol(), spent: 5, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "a text region's pixels", before: symbol(), spent: 5, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 }, Item { what: "an intermediate text region's pixels", before: symbol(), - spent: 4, + spent: 5, kind: 4, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75, }, // The region's 13, a copy of the symbol's row and the round of the loop. - Item { what: "an instance", before: symbol(), spent: 4, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 15 }, + Item { what: "an instance", before: symbol(), spent: 5, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 15 }, // The region's 13, 80 rows of 4 words copied, the round of the loop. Item { what: "an instance of a large symbol", before: vec![(0, square_jbig2_dictionary(80, true))], - spent: 6720, + spent: 6721, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), @@ -4117,7 +4144,7 @@ fn jbig2_decoding_work_is_counted_item_by_item() { Item { what: "a refined instance", before: vec![(0, square_jbig2_dictionary(80, true))], - spent: 6720, + spent: 6721, kind: 6, refs: &[1], data: jbig2_refined_text_region(), diff --git a/vendor/README.md b/vendor/README.md index e3806a57a6..f7a6fabdfa 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 item is charged, one with no pixels too: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s | (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` | +| 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 item is charged before it is decoded, one with no pixels too: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s | (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` | | 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 | (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` | | 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 | diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index 94250a3de9..cbebc2df66 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -135,8 +135,9 @@ pub(crate) fn decode( if ctx.pixels > MAX_DICTIONARY_PIXELS { bail!(OverflowError::BitmapDimension); } - // PdfCraft patch: see `bitmap::MAX_WORK`. - let work = decode_work(symbol_width, ctx.height_class_height); + // PdfCraft patch: see `bitmap::MAX_WORK`. One with no rows costs no pixels, but it is + // decoded all the same, and a stream can hold thousands of dictionaries of them. + let work = decode_work(symbol_width, ctx.height_class_height).saturating_add(1); *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; // PdfCraft patch: see `room`. Rows take a word at least, so symbols of one column // held 270 MiB within the pixel bound, however few the dictionary exported. diff --git a/vendor/hayro-jbig2/src/decode/text.rs b/vendor/hayro-jbig2/src/decode/text.rs index 0bd32a68db..9e3672ab0d 100644 --- a/vendor/hayro-jbig2/src/decode/text.rs +++ b/vendor/hayro-jbig2/src/decode/text.rs @@ -151,7 +151,7 @@ pub(crate) fn decode_with( let symbol_id = ctx.read_symbol_id()?; let symbol_bitmap = - decode_symbol_instance_bitmap(&mut ctx, symbols, header, symbol_id)?; + decode_symbol_instance_bitmap(&mut ctx, symbols, header, symbol_id, work_left)?; let symbol_bitmap_ref: &Bitmap = match &symbol_bitmap { SymbolBitmap::Reference(idx) => symbols.get(*idx).ok_or(SymbolError::OutOfRange)?, @@ -161,14 +161,8 @@ pub(crate) fn decode_with( let symbol_height = symbol_bitmap_ref.height as i32; // PdfCraft patch: see `bitmap::MAX_WORK`. An instance of a large symbol costs its copy, - // and 10,000 of one took 64 s. A refined one was decoded as well. Even one with no - // pixels goes round this loop. - let (width, height) = (symbol_bitmap_ref.width, symbol_bitmap_ref.height); - let copied = copy_work(width, height).saturating_add(1); - let work = match &symbol_bitmap { - SymbolBitmap::Reference(_) => copied, - SymbolBitmap::Owned(_) => copied.saturating_add(decode_work(width, height)), - }; + // and 10,000 of one took 64 s. Even one with no pixels goes round this loop. + let work = copy_work(symbol_bitmap_ref.width, symbol_bitmap_ref.height).saturating_add(1); *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; if !header.flags.transposed @@ -546,6 +540,7 @@ fn decode_symbol_instance_bitmap( symbols: &[&Bitmap], header: &TextRegionHeader<'_>, symbol_id: usize, + work_left: &mut u64, ) -> Result { if !header.flags.use_refinement || ctx.read_refinement_flag()? == 0 { return Ok(SymbolBitmap::Reference(symbol_id)); @@ -577,6 +572,11 @@ fn decode_symbol_instance_bitmap( .checked_add(rdy) .ok_or(OverflowError::ReferenceOffset)?; + // PdfCraft patch: see `bitmap::MAX_WORK`. Before it is decoded: charged after, one instance + // could still be 2^28 pixels past what was left. + let work = decode_work(refined_width, refined_height); + *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; + let mut refined_bitmap = Bitmap::new(refined_width, refined_height)?; ctx.decode_refinement_bitmap( From b7ec3669cc795df48d7feca4d4926a7eaa3b48b5 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 13:10:50 +0100 Subject: [PATCH 062/562] fix(render): what a JBIG2 item reads is charged, 38 an integer From a review of the work count. An item with no pixels was charged a step, though reading it can take hundreds of decisions, so a 22,515-byte PDF of refined text instances of no size stayed inside the limit and took 730 s to render. Empty height classes and export runs weren't charged at all, and 19 KB of them took 47 s. Each integer an item reads now costs what one takes at worst, 38, so a decision never costs more than a pixel. A full region of 10,000 instances is 0.14% of the limit, so a real file doesn't notice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 38 ++++++++++++------------- vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 6 ++++ vendor/hayro-jbig2/src/decode/symbol.rs | 14 ++++++--- vendor/hayro-jbig2/src/decode/text.rs | 10 +++---- 5 files changed, 41 insertions(+), 29 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 75af83511b..a6a6f33d3d 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -4108,47 +4108,47 @@ fn jbig2_decoding_work_is_counted_item_by_item() { data: jbig2_pattern_dictionary(32, 2), cost: two_patterns(1, 32), }, - // Each symbol costs one more than its pixels, so that one of no rows isn't free. - Item { what: "symbols of no rows", before: vec![], spent: 0, kind: 0, refs: &[], data: no_row_jbig2_dictionary(5), cost: 5 }, - // Rows cost in a symbol of no pixels too. - Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 15 }, - Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4001 }, - Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6721 }, - Item { what: "referred symbols", before: symbol(), spent: 5, kind: 0, refs: &[1], data: exporting_none, cost: 1 }, + // What an item reads counts, at 38 an integer: two classes, five widths and an export run. + Item { what: "symbols of no rows", before: vec![], spent: 0, kind: 0, refs: &[], data: no_row_jbig2_dictionary(5), cost: 304 }, + // Rows cost in a symbol of no pixels too, on top of a class, three widths and an export run. + Item { what: "symbols without pixels", before: vec![], spent: 0, kind: 0, refs: &[], data: zero_width_jbig2_dictionary(3, false), cost: 202 }, + Item { what: "a tall symbol of no width", before: vec![], spent: 0, kind: 0, refs: &[], data: zero(1000), cost: 4114 }, + Item { what: "new symbols", before: vec![], spent: 0, kind: 0, refs: &[], data: square_jbig2_dictionary(80, false), cost: 6834 }, + Item { what: "referred symbols", before: symbol(), spent: 156, kind: 0, refs: &[1], data: exporting_none, cost: 39 }, // Its symbol, and its one row and word; Huffman-coded, a table line for the symbol. - Item { what: "immediate text", before: symbol(), spent: 5, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, - Item { what: "intermediate text", before: symbol(), spent: 5, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, - Item { what: "a text region's pixels", before: symbol(), spent: 5, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 }, + Item { what: "immediate text", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "intermediate text", before: symbol(), spent: 156, kind: 4, refs: &[1], data: jbig2_text_region(1, 1, 0), cost: 13 }, + Item { what: "a text region's pixels", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75 }, Item { what: "an intermediate text region's pixels", before: symbol(), - spent: 5, + spent: 156, kind: 4, refs: &[1], data: jbig2_text_region(32, 32, 0), cost: 75, }, - // The region's 13, a copy of the symbol's row and the round of the loop. - Item { what: "an instance", before: symbol(), spent: 5, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 15 }, - // The region's 13, 80 rows of 4 words copied, the round of the loop. + // The region's 13, a copy of the symbol's row, and the instance's 4 integers. + Item { what: "an instance", before: symbol(), spent: 156, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), cost: 166 }, + // The region's 13, 80 rows of 4 words copied, and the instance's 4 integers. Item { what: "an instance of a large symbol", before: vec![(0, square_jbig2_dictionary(80, true))], - spent: 6721, + spent: 6872, kind: 6, refs: &[1], data: jbig2_text_region(1, 1, 1), - cost: 334, + cost: 485, }, - // And decoded as well as copied. + // And decoded as well as copied, with the 4 integers that gave its size. Item { what: "a refined instance", before: vec![(0, square_jbig2_dictionary(80, true))], - spent: 6721, + spent: 6872, kind: 6, refs: &[1], data: jbig2_refined_text_region(), - cost: 7054, + cost: 7357, }, // A bit plane, a cell's copy and the region's. Item { diff --git a/vendor/README.md b/vendor/README.md index f7a6fabdfa..c350c8deb3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 item is charged before it is decoded, one with no pixels too: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s | (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` | +| 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 | (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` | | 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 | (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` | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index b76ebbdb2b..216255bf44 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -31,6 +31,12 @@ pub(crate) const MAX_GRID_CELLS: u64 = 1 << 25; /// halftone 265 s, and a 923-byte PDF's regions would have taken days. pub(crate) const MAX_WORK: u64 = 4 * MAX_PIXELS; +/// PdfCraft patch: the work of reading one integer of an item, what an arithmetic-coded one takes +/// at worst in decisions (see [`MAX_WORK`]), so that a decision costs no more than a pixel. An item +/// with no pixels still reads its integers: charged a step each, a 22,515-byte PDF of refined text +/// instances of no size stayed inside `MAX_WORK` and took 730 s. +pub(crate) const INTEGER_WORK: u64 = 38; + /// PdfCraft patch: the work of a line of a Huffman table built for symbols, about 50 ns (see /// [`MAX_WORK`]). pub(crate) const HUFFMAN_LINE_WORK: u64 = 10; diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index cbebc2df66..3e7646964d 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -4,7 +4,7 @@ use alloc::vec; use alloc::vec::Vec; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{BITMAP_WORDS, Bitmap, HUFFMAN_LINE_WORK, WORD_BITS, Word, decode_work}; +use crate::bitmap::{BITMAP_WORDS, Bitmap, HUFFMAN_LINE_WORK, INTEGER_WORK, WORD_BITS, Word, decode_work}; use crate::decode::generic::{decode_bitmap_mmr, parse_adaptive_template_pixels}; use crate::decode::text::{ DecodeContext, ReferenceCorner, TextRegionContexts, TextRegionFlags, TextRegionHeader, @@ -101,6 +101,8 @@ pub(crate) fn decode( bail!(SymbolError::Invalid); } height_classes_left -= 1; + // PdfCraft patch: see `bitmap::INTEGER_WORK`. A class of no symbols still reads its delta. + *ctx.work_left = ctx.work_left.checked_sub(INTEGER_WORK).ok_or(OverflowError::DecodingWork)?; let height_class_delta = read_height_class_delta(&mut ctx)?.ok_or(SymbolError::UnexpectedOob)?; @@ -135,9 +137,11 @@ pub(crate) fn decode( if ctx.pixels > MAX_DICTIONARY_PIXELS { bail!(OverflowError::BitmapDimension); } - // PdfCraft patch: see `bitmap::MAX_WORK`. One with no rows costs no pixels, but it is - // decoded all the same, and a stream can hold thousands of dictionaries of them. - let work = decode_work(symbol_width, ctx.height_class_height).saturating_add(1); + // 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 + // and two offsets as well. + let integers = if ctx.header.flags.use_refagg { 5 } else { 1 }; + let work = decode_work(symbol_width, ctx.height_class_height).saturating_add(integers * INTEGER_WORK); *ctx.work_left = ctx.work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; // PdfCraft patch: see `room`. Rows take a word at least, so symbols of one column // held 270 MiB within the pixel bound, however few the dictionary exported. @@ -760,6 +764,8 @@ fn export_symbols(ctx: &mut SymbolDecodeContext<'_>) -> Result> { bail!(SymbolError::Invalid); } runs_left -= 1; + // PdfCraft patch: see `bitmap::INTEGER_WORK`. A run of no symbols still reads its length. + *ctx.work_left = ctx.work_left.checked_sub(INTEGER_WORK).ok_or(OverflowError::DecodingWork)?; let run_length = read_run_length()?; let end_index = index.checked_add(run_length).ok_or(OverflowError::Index)?; diff --git a/vendor/hayro-jbig2/src/decode/text.rs b/vendor/hayro-jbig2/src/decode/text.rs index 9e3672ab0d..068ce6ec3a 100644 --- a/vendor/hayro-jbig2/src/decode/text.rs +++ b/vendor/hayro-jbig2/src/decode/text.rs @@ -11,7 +11,7 @@ use super::{ use super::{RegionBitmap, generic_refinement}; use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; -use crate::bitmap::{Bitmap, copy_work, decode_work}; +use crate::bitmap::{Bitmap, INTEGER_WORK, copy_work, decode_work}; use crate::error::{HuffmanError, OverflowError, ParseError, Result, SymbolError, bail}; use crate::huffman_table::{HuffmanTable, StandardHuffmanTables, TableLine}; use crate::integer_decoder::IntegerDecoder; @@ -161,8 +161,8 @@ pub(crate) fn decode_with( let symbol_height = symbol_bitmap_ref.height as i32; // PdfCraft patch: see `bitmap::MAX_WORK`. An instance of a large symbol costs its copy, - // and 10,000 of one took 64 s. Even one with no pixels goes round this loop. - let work = copy_work(symbol_bitmap_ref.width, symbol_bitmap_ref.height).saturating_add(1); + // and 10,000 of one took 64 s. Even one with no pixels reads its four integers. + let work = copy_work(symbol_bitmap_ref.width, symbol_bitmap_ref.height).saturating_add(4 * INTEGER_WORK); *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; if !header.flags.transposed @@ -573,8 +573,8 @@ fn decode_symbol_instance_bitmap( .ok_or(OverflowError::ReferenceOffset)?; // PdfCraft patch: see `bitmap::MAX_WORK`. Before it is decoded: charged after, one instance - // could still be 2^28 pixels past what was left. - let work = decode_work(refined_width, refined_height); + // could still be 2^28 pixels past what was left. With the four integers that gave its size. + let work = decode_work(refined_width, refined_height).saturating_add(4 * INTEGER_WORK); *work_left = work_left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; let mut refined_bitmap = Bitmap::new(refined_width, refined_height)?; From 0ed339901e6b7f55e2257fe188aef7a63fa06dab Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 13:21:45 +0100 Subject: [PATCH 063/562] M2.6: the JBIG2 images of an operation or a page share one allowance of decoding One image has its own limit, about 6 s to 17 s, but nothing bounded a file of thousands of images made to reach it: an operation took that long for each, and an image sharing /JBIG2Globals decodes them again. Image::decode_within (hayro-jbig2 patch 11) spends from what a run may still do, starting with its page's pixels, and charge_reading charges reading a stream and its globals, 4 steps a byte, before anything is parsed, so an image that fails has paid too. pdfcraft_filters::decode_jbig2_within takes it, and ImageBudget in cos is that allowance: 2^34 steps, about 1,000 scanned pages, and 2^13 more for each byte of the file, so a long scan is read whole. An image past it spends all that's left, and later ones aren't read, globals included. Optimize leaves such an image as it is and counts it in Report::images_refused. Image export leaves it out, with the reason, and stops at 2 GiB of files held. Office export shares one allowance, leaves the picture out and says the page was truncated. Redaction removes it whole, as any image it can't read. Rendering gives a page's images 2^30 steps between them, inside the watchdog, and each page starts afresh in Context::new as its other budgets do (hayro-syntax patch 31, hayro-interpret patch 73). It's a thread-local, unbounded until a page sets it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/automation/src/documents.rs | 1 + crates/cos/README.md | 3 +- crates/cos/src/budget.rs | 135 ++++++++++++++++++++ crates/cos/src/document/objects.rs | 14 +- crates/cos/src/lib.rs | 2 +- crates/create/README.md | 6 +- crates/create/src/extract.rs | 55 +++++--- crates/create/src/lib.rs | 2 +- crates/create/src/tests.rs | 42 ++++++ crates/engine/src/compare.rs | 23 +++- crates/engine/src/tests.rs | 7 + crates/filters/src/jbig2.rs | 2 +- crates/filters/src/jbig2/decode.rs | 58 ++++++++- crates/filters/src/lib.rs | 2 +- crates/optimize/README.md | 4 +- crates/optimize/src/images.rs | 73 ++++++++++- crates/optimize/src/lib.rs | 2 + crates/redact/README.md | 3 +- crates/redact/src/image.rs | 10 +- crates/redact/src/interp.rs | 9 +- crates/redact/src/lib.rs | 12 +- crates/redact/src/sanitize.rs | 3 +- crates/redact/src/tests.rs | 21 ++- crates/render/src/raster.rs | 10 +- crates/render/src/raster/tests.rs | 116 ++++++++++++++++- parity/acrobat-features.toml | 4 +- vendor/README.md | 6 +- vendor/hayro-interpret/src/context.rs | 2 + vendor/hayro-interpret/src/interpret/mod.rs | 5 + vendor/hayro-jbig2/src/lib.rs | 86 +++++++++---- vendor/hayro-syntax/src/filter/jbig2.rs | 68 +++++++++- vendor/hayro-syntax/src/filter/mod.rs | 4 + 32 files changed, 705 insertions(+), 85 deletions(-) diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 859c92da48..979b041a73 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -410,6 +410,7 @@ impl Automation { "images": o.images, "images_resampled": o.images_resampled, "images_recompressed": o.images_recompressed, + "images_refused": o.images_refused, "image_bytes_before": o.image_bytes_before, "image_bytes_after": o.image_bytes_after, "thumbnails": o.thumbnails, diff --git a/crates/cos/README.md b/crates/cos/README.md index 1e2551036f..f3195a46dd 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -93,5 +93,6 @@ recognisable in a diff and byte-stable when re-serialized. - Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB, their filters undone once a document for the images that share them, up to 64 MiB kept, while - their segments are decoded for every image); + their segments are decoded for every image, and `ImageBudget` for an operation over many images, + which share one allowance of JBIG2 decoding and are refused past it); DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 1ddd42eaf5..f982a39b45 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -6,6 +6,17 @@ use pdfcraft_filters::FilterError; use crate::{CosError, Document, ObjRef, Object, Stream}; +/// The steps of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) an operation over a +/// document's images may take whatever the file's size. A step is about a pixel, and takes 6 to 16 +/// ns to decode, so this is 2 to 5 minutes, and about as long again for what an operation does with +/// the pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again +/// to decode them, so this alone reads about 1,000 of them. +const IMAGE_WORK: u64 = 1 << 34; +/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a +/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored +/// in. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB. +const IMAGE_WORK_PER_BYTE: u64 = 1 << 13; + /// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every /// form XObject it enters, each occurrence counted again. pub const MAX_PAGE_DECODED: usize = 256 << 20; @@ -16,6 +27,52 @@ pub const MAX_PAGE_FORMS: usize = 50_000; /// the error, each with that count. type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; +/// What one operation over a document's images may still spend decoding them: exporting them, +/// optimizing them, clearing them for redaction. +/// +/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it. +/// JBIG2 is what such a file would use: a few hundred bytes can ask for seconds of decoding, and an +/// image that shares `/JBIG2Globals` decodes them again. So those images share this allowance, and +/// past it they aren't decoded. A caller then treats them as it does an image it can't read, and +/// [`ImageBudget::refused`] says how many there were. +#[derive(Debug)] +pub struct ImageBudget { + jbig2_work: u64, + refused: usize, +} + +impl ImageBudget { + /// The allowance for an operation on `doc`, which a longer file makes larger. + pub fn new(doc: &Document) -> Self { + let bytes = u64::try_from(doc.bytes().len()).unwrap_or(u64::MAX); + Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE))) + } + + /// An allowance of `jbig2_work` steps of JBIG2 decoding. + pub fn with_work(jbig2_work: u64) -> Self { + Self { jbig2_work, refused: 0 } + } + + /// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance. + pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { + let decoded = doc.decoded_image_spending(s, max, &mut self.jbig2_work); + if decoded.is_err() && self.jbig2_work == 0 && matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + self.refused += 1; + } + decoded + } + + /// The JBIG2 images refused for lack of allowance. + pub fn refused(&self) -> usize { + self.refused + } + + /// The steps of JBIG2 decoding still allowed. + pub fn work_left(&self) -> u64 { + self.jbig2_work + } +} + /// What one operation may still decode and enter across all the streams it reads. /// /// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose @@ -143,6 +200,84 @@ impl DecodeBudget { } } +#[cfg(test)] +mod image_tests { + use super::*; + use crate::Dict; + + fn jbig2(parms: Option) -> Stream { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + if let Some(parms) = parms { + dict.set(b"DecodeParms".to_vec(), parms); + } + Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()) + } + + /// Each JBIG2 image spends what it took from one allowance, and those past it are refused and + /// counted. Other images aren't charged, and a failure of theirs isn't counted as one of these. + #[test] + fn the_images_of_an_operation_share_its_allowance() { + let doc = Document::new_empty(); + let image = jbig2(None); + let mut budget = ImageBudget::with_work(u64::MAX); + let samples = budget.decode(&doc, &image, 1 << 20).unwrap(); + let cost = u64::MAX - budget.work_left(); + assert!(cost > 0); + let mut budget = ImageBudget::with_work(3 * cost); + for left in [2, 1, 0] { + assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples); + assert_eq!(budget.work_left(), left * cost); + } + assert_eq!(budget.refused(), 0, "the allowance held exactly three"); + for refused in [1, 2] { + assert!(budget.decode(&doc, &image, 1 << 20).is_err()); + assert_eq!(budget.refused(), refused); + } + let flate = Stream::flate(Dict::new(), b"samples"); + assert_eq!(budget.decode(&doc, &flate, 1 << 20).unwrap(), b"samples", "not JBIG2: no charge"); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.refused(), 2, "not JBIG2: not one of these"); + } + + /// The images of a file share its globals, so an image refused doesn't read them: this one's + /// can't be read, which is how to tell. + #[test] + fn an_image_past_the_allowance_does_not_read_its_globals() { + let mut doc = Document::new_empty(); + let mut unreadable = Dict::new(); + unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + let globals = doc.add(Object::Stream(Stream::from_raw(unreadable, b"data".to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let image = jbig2(Some(Object::Dict(parms))); + let read = ImageBudget::with_work(u64::MAX).decode(&doc, &image, 1 << 20).unwrap_err().to_string(); + assert!(read.contains("globals"), "{read}"); + let refused = ImageBudget::with_work(0).decode(&doc, &image, 1 << 20).unwrap_err().to_string(); + assert!(refused.contains("more JBIG2 decoding than"), "{refused}"); + } + + /// A longer file gets more, by its length, and an empty one the fixed part. + #[test] + fn a_longer_file_allows_more_decoding() { + assert_eq!(ImageBudget::new(&Document::new_empty()).work_left(), IMAGE_WORK); + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let len = pdf.len() as u64; + let doc = Document::open(std::sync::Arc::new(pdf)).unwrap(); + assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + len * IMAGE_WORK_PER_BYTE); + } + + // What the constants' comments promise, for a page scanned at 300 dpi: about 1,000 of them + // whatever the file's size, and a page paying for itself with 2 KB of the file. + const PAGE: u64 = 17_000_000; + const _: () = assert!(IMAGE_WORK >= 1_000 * PAGE); + const _: () = assert!(IMAGE_WORK_PER_BYTE * 2_100 >= PAGE); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 39a4d9a6da..d9f8026a61 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -294,6 +294,14 @@ impl Document { /// more than `max` bytes. A JBIG2 image's `/JBIG2Globals` are another stream, shared between /// images, and are decoded within [`MAX_JBIG2_GLOBALS`] whatever `max` is. pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + let mut unbounded = u64::MAX; + self.decoded_image_spending(s, max, &mut unbounded) + } + + /// [`Document::decoded_image_within`], a JBIG2 image spending from `work`, what an operation + /// may still do across its images ([`ImageBudget`](crate::ImageBudget)). With nothing left a + /// JBIG2 image is refused before its globals are read. + pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); @@ -310,8 +318,10 @@ impl Document { let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else { return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into())); }; - let globals = self.jbig2_globals(s)?; - return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter); + // Nothing to read for what can't be decoded: the images of a file share its globals. + let globals = if *work == 0 { None } else { self.jbig2_globals(s)? }; + let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before)? }; + return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter); } let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index a35902a031..6187acb846 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod parser; mod security; mod writer; -pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; +pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/create/README.md b/crates/create/README.md index 555d8b3aba..a2981b8e55 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -27,12 +27,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. -JBIG2 images and masks are decoded through `pdfcraft_cos::Document::decoded_image` (`pdfcraft_filters::decode_jbig2`), behind +JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a -limit of their own), is skipped; such a mask is left out and the image exported opaque, as JPX and CCITT -masks are. +limit of their own), is skipped, and so is one past what one export may decode, which its images share; such a mask is left out and +the image exported opaque, as JPX and CCITT masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 22943fd11b..7b770ccb51 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream}; /// One image, ready to write. #[derive(Clone, Debug, PartialEq)] @@ -31,12 +31,23 @@ pub struct ImageExport { /// Images larger than this many pixels are skipped (memory). const MAX_PIXELS: u64 = 100_000_000; +/// What one export may hold of the files it makes: they are all in memory until it ends, and a file +/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason. +const MAX_EXPORT_BYTES: usize = 2 << 30; + /// The images of `pages` (0-based; each image once, on the first page using it), skipping /// those with fewer than `min_side` pixels on their shorter side. pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport { + extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES) +} + +/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is +/// left out, with the reason. +pub(crate) fn extract_images_within(doc: &Document, pages: &[usize], min_side: u32, budget: &mut ImageBudget, max_bytes: usize) -> ImageExport { let all = pdfcraft_model::pages(doc); let mut out = ImageExport::default(); let mut seen = HashSet::new(); + let mut held = 0usize; for &p in pages { let Some(page) = all.get(p) else { continue }; let mut refs = Vec::new(); @@ -53,8 +64,15 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 { continue; } - match image(doc, s) { - Ok((extension, data)) => out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }), + if held >= max_bytes { + out.skipped.push((p, r, "more images than one export can hold".into())); + continue; + } + match image(doc, s, budget) { + Ok((extension, data)) => { + held = held.saturating_add(data.len()); + out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }); + } Err(why) => out.skipped.push((p, r, why)), } } @@ -64,8 +82,13 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx /// One image XObject as a file: JPEG as is, else PNG ("Save image as"). pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec), String> { + image_file_within(doc, image, &mut ImageBudget::new(doc)) +} + +/// [`image_file`] within `budget`, for a caller making a file of many images. +pub fn image_file_within(doc: &Document, image: ObjRef, budget: &mut ImageBudget) -> Result<(&'static str, Vec), String> { match &*doc.get(image) { - Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s), + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s, budget), _ => Err("not an image".into()), } } @@ -100,20 +123,20 @@ fn filters(s: &Stream) -> Vec> { } } -/// The samples of image `s`, decoded ([`Document::decoded_image`]): through its filters, with a +/// The samples of image `s`, decoded ([`ImageBudget::decode`]): through its filters, with a /// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by /// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image /// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`. /// A JBIG2 page has to be the image's size, which the caller checked against the export's limit, /// before the page is decoded. -fn decoded(doc: &Document, s: &Stream) -> Result, String> { - doc.decoded_image(s).map_err(|e| e.to_string()) +fn decoded(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result, String> { + budget.decode(doc, s, MAX_DECODED).map_err(|e| e.to_string()) } /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft /// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as /// alpha, JPEG images included, so cut-out images keep their transparent background. -fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> { +fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'static str, Vec), String> { let f = filters(s); let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { @@ -132,7 +155,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> }; let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). - let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; + let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) }; let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. let inverted = (jpeg || mask) @@ -156,7 +179,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> space = decoded; (samples, 8) } else { - let data = decoded(doc, s)?; + let data = decoded(doc, s, budget)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -459,25 +482,25 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] { } /// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another. -fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option> { +fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let r = s.dict.get(b"SMask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; - gray_plane(doc, m, w, h) + gray_plane(doc, m, w, h, budget) } /// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with /// `/Decode [1 0]`) hide the image. -fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option> { +fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let r = s.dict.get(b"Mask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; - Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect()) + Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect()) } /// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to /// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks). -fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> { +fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); let f = filters(m); @@ -498,7 +521,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 36b52d4211..4c117019d7 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -6,7 +6,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize} mod extract; mod upright_image; -pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; +pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; use pdfcraft_fonts::{literal, win_ansi, wrap}; #[derive(Debug, thiserror::Error, PartialEq)] diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index bfb57ab835..cab718a1ef 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2233,3 +2233,45 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { assert!(why.contains("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); } } + +/// One allowance for all the images of an export: an image past it is left out, saying so. +#[test] +fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()); + let mut cost = ImageBudget::with_work(u64::MAX); + cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap(); + let cost = u64::MAX - cost.work_left(); + + let 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 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap(); + let (a, b) = (doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image))); + let mut xobjects = Dict::new(); + xobjects.set(b"A".to_vec(), Object::Ref(a)); + xobjects.set(b"B".to_vec(), Object::Ref(b)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let page = pdfcraft_model::pages(&doc).swap_remove(0).obj; + doc.update_dict(page, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + + let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(cost), usize::MAX); + assert_eq!(export.images.len(), 1); + assert_eq!(export.skipped.len(), 1); + assert!(export.skipped[0].2.contains("more JBIG2 decoding than"), "{:?}", export.skipped); + // What an export holds is bounded too: the files are all in memory until it ends. + let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(u64::MAX), 1); + assert_eq!(export.images.len(), 1, "the first goes past it"); + assert_eq!(export.skipped.len(), 1); + assert!(export.skipped[0].2.contains("than one export can hold"), "{:?}", export.skipped); + // A file of many images shares one allowance too, image by image. + let mut budget = ImageBudget::with_work(cost); + assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok()); + assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err()); +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index dca1e24af5..24512b566b 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -173,14 +173,15 @@ pub struct OfficeExport { /// The exported file. pub bytes: Vec, /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them - /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops - /// reading there to stay bounded on hostile files, so the text and images of those forms, or + /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`] or past what one export + /// may decode: export stops reading there to stay bounded on hostile files, so the text and images of those forms, or /// those pictures, are missing from `bytes`. pub truncated_pages: Vec, } /// An image object prepared for Office export: its file extension and encoded bytes, or why it is -/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded). +/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`], or what the export may decode; +/// `Err(false)`: it can't be encoded). type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; impl crate::Document { @@ -194,6 +195,18 @@ impl crate::Document { /// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without /// `images` no picture is prepared. pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec, Vec) { + let Some(editor) = self.editor.as_ref() else { return (Vec::new(), Vec::new()) }; + self.export_pages_within(images, image_budget, &mut pdfcraft_cos::ImageBudget::new(&editor.cos)) + } + + /// [`Self::export_pages_checked`], its pictures decoded within `image_work`, which is shared + /// by the whole export: a picture past it is left out like one past `image_budget`. + pub(crate) fn export_pages_within( + &self, + images: bool, + image_budget: usize, + image_work: &mut pdfcraft_cos::ImageBudget, + ) -> (Vec, Vec) { let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; let mut truncated = Vec::new(); // Each image object is encoded once and shared by every place that draws it. @@ -244,7 +257,9 @@ impl crate::Document { .filter_map(|im| { let object = im.object?; let prepared = encoded.entry(object).or_insert_with(|| { - let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?; + let refused = image_work.refused(); + let (ext, bytes) = + pdfcraft_create::image_file_within(cos, object, &mut *image_work).map_err(|_| image_work.refused() > refused)?; image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?; Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes))) }); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dde4375a00..b47211afc4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8200,6 +8200,13 @@ fn copy_image_reads_the_globals_of_a_jbig2_picture() { let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap(); assert_eq!((w, h), (16, 2)); assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals"); + // An Office export shares one allowance across its pictures. One past it is left out, and the + // export says the page is missing something, as for a picture past its bytes. + let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + assert_eq!((pages[0].images.len(), truncated.len()), (1, 0)); + let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(0)); + assert!(pages[0].images.is_empty()); + assert_eq!(truncated, [0]); } /// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index a36a81e1fb..471814f790 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -7,7 +7,7 @@ mod decode; mod symbols; -pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2}; +pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within}; use crate::FilterError; use crate::mq::{Context, MqEncoder}; diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 320e02445a..42280c0e8b 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -23,9 +23,26 @@ pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; /// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the /// same cap and can refuse the page first). pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { + let mut unbounded = u64::MAX; + decode_jbig2_within(data, globals, columns, rows, max, &mut unbounded) +} + +/// [`decode_jbig2`], spending from `work`: what decoding may still cost across a run of images, in +/// the decoder's count of steps. Reading `data` and `globals` is charged first +/// ([`hayro_jbig2::charge_reading`]), so an image that turns out not to decode has paid for that, +/// then what decoding takes ([`hayro_jbig2::Image::decode_within`]). An image takes at most what +/// one decode may on top of its reading and its page, whatever `work` allows, and one that `work` +/// can't pay for spends all of it. With nothing left an image isn't read at all, so the images after +/// that one cost nothing but this check, however large their globals. +pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize, work: &mut u64) -> Result, FilterError> { if rows == 0 { return Ok(Vec::new()); } + let spent = || FilterError::Unsupported("more JBIG2 decoding than one operation may do".into()); + if *work == 0 { + return Err(spent()); + } + hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| spent())?; let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail }; let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?; let (width, height) = (image.width(), image.height()); @@ -41,7 +58,7 @@ pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32 return Err(FilterError::LimitExceeded(max)); } let mut out = Rows::new(width, height, false); - image.decode(&mut out).map_err(|e| corrupt(e.to_string()))?; + image.decode_within(&mut out, work).map_err(|e| corrupt(e.to_string()))?; Ok(out.out) } @@ -123,6 +140,45 @@ mod tests { assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); } + /// What a page costs is the same however much a run has left. Given exactly that it decodes, + /// and less stops it: spending all that's left, and what comes after isn't read. A page that + /// fails still spends what it took. + #[test] + fn a_run_of_images_spends_its_allowance_down_to_the_step() { + let (w, h) = (203u32, 41u32); + let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap(); + let whole = decode_jbig2(&data, None, w, h, usize::MAX).unwrap(); + let mut work = u64::MAX; + assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole); + let cost = u64::MAX - work; + assert!(cost >= u64::from(w) * u64::from(h), "a pixel at a time: {cost}"); + let mut work = cost; + assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole, "exactly enough"); + assert_eq!(work, 0); + let mut work = cost + 7; + decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(); + assert_eq!(work, 7, "only what it took"); + let mut work = cost - 1; + assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. })), "a step short"); + assert_eq!(work, 0, "spends all that was left"); + // Nothing left: not read at all, so this garbage isn't reported as garbage. + assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_)))); + // Too little even for its page. + let mut work = 3; + assert!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).is_err()); + assert_eq!(work, 0); + // A region that can't be read after the one that can: the page fails having spent the + // first, and the segment it fails on, and that isn't being out of allowance. + let bad_region = [0, 0, 0, 9, 38, 0, 1, 0, 0, 0, 1, 0xFF]; + let mut work = u64::MAX; + assert!(decode_jbig2_within(&[&data[..], &bad_region].concat(), None, w, h, usize::MAX, &mut work).is_err()); + assert_eq!(u64::MAX - work, cost + 1 + 4 * bad_region.len() as u64, "the first region, a segment more, and reading it"); + // Not a stream at all: it pays for being read, and globals count with it. + let mut work = u64::MAX; + assert!(matches!(decode_jbig2_within(b"garbage", Some(b"globals"), w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. }))); + assert_eq!(u64::MAX - work, 4 * 14); + } + /// The page has to be the image's size, and is bounded before it is decoded, being decoded /// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given /// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index ba455e2bae..6dc4813f78 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -29,7 +29,7 @@ mod runlength; use std::borrow::Cow; pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, encode_jbig2, encode_jbig2_generic}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 27cada0d2f..1b2291d48d 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -100,7 +100,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 - images, with bounded shared globals. + images, with bounded shared globals. All of a run's JBIG2 images share one allowance of + decoding (`pdfcraft_cos::ImageBudget`): about 2,000 scanned pages' worth, plus more for each + byte of the file. Images past it stay as they are, and `Report::images_refused` counts them. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index cdd3c89d91..405f15c5bb 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use pdfcraft_content::{Matrix, Pieces}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; @@ -799,7 +799,7 @@ type Pixel = fn(&[u8]) -> Option; /// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and /// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than /// the default stops it: in index units it would mean something else at one bit. -fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Vec, u32, u32, Option)> { +fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut ImageBudget) -> Option<(Vec, u32, u32, Option)> { let d = &s.dict; // Through u32 first, as everywhere here, so the product can't overflow. let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); @@ -820,7 +820,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve _ => allowance(len, h)?, }; count_decoded(u64::from(w) * u64::from(h)); - let mut data = doc.decoded_image_within(s, max).ok()?; + let mut data = budget.decode(doc, s, max).ok()?; if data.len() < len { return None; } @@ -1088,7 +1088,7 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i /// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for /// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default /// decode array, which goes as the default at one bit. -fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings) -> Option<(Stream, bool)> { +fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings, budget: &mut ImageBudget) -> Option<(Stream, bool)> { let d = &s.dict; let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); let retain = settings.compression == MonoCompression::Retain; @@ -1105,7 +1105,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet if only_to_resample && !downsample { return None; } - let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?; + let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?; let packed_from_more = packed.is_some(); let k = settings.target_ppi / ppi; // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, @@ -1178,6 +1178,19 @@ pub(crate) fn run( settings: &Settings, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool, +) -> Result<(), OptimizeError> { + let mut budget = ImageBudget::new(doc); + run_within(doc, pages, settings, report, progress, &mut budget) +} + +/// [`run`] within `budget`. An image past it stays as it is, and is counted in the report. +fn run_within( + doc: &mut Document, + pages: &[ObjRef], + settings: &Settings, + report: &mut Report, + progress: &mut dyn FnMut(Stage) -> bool, + budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { let (ppi, mut defaults, read) = walk_pages(doc, pages); // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an @@ -1209,7 +1222,7 @@ pub(crate) fn run( // Packed from more bits a sample, it is lossless at one bit, but the grey or colour // settings can make it smaller still (below). let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); - let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono) { + let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) { Some(m) if packed_from_more => (Some(m), None), m => (None, m), }; @@ -1266,6 +1279,7 @@ pub(crate) fn run( doc.set(mr, Object::Stream(m)); } } + report.images_refused = budget.refused(); if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } @@ -1329,7 +1343,7 @@ mod dimension_tests { let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }; let decoded = |s: &Stream, settings: &MonoSettings| { DECODED_PIXELS.with(|c| c.set(0)); - mono(&Document::new_empty(), s, 300.0, false, settings); + mono(&Document::new_empty(), s, 300.0, false, settings, &mut ImageBudget::with_work(u64::MAX)); DECODED_PIXELS.with(|c| c.get()) }; let alone = image(Object::name("JBIG2Decode"), coded.clone()); @@ -1340,6 +1354,51 @@ mod dimension_tests { assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller"); } + /// A run has one allowance for all its JBIG2 images. Those past it stay as they are, and the + /// report says how many: a file of thousands of them can't hold a run for hours. + #[test] + fn jbig2_images_past_the_runs_allowance_stay_as_they_are() { + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("XObject")); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let image = Stream::from_raw(dict, coded); + let mut cost = ImageBudget::with_work(u64::MAX); + cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap(); + let cost = u64::MAX - cost.work_left(); + + let mut doc = Document::new_empty(); + let refs = [doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone()))]; + let mut xobjects = Dict::new(); + for (i, r) in refs.iter().enumerate() { + xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r)); + } + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + // Drawn small enough, at 720 ppi, for the settings to resample all three. + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1.6 0 0 .2 0 0 cm /I0 Do /I1 Do /I2 Do Q"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + + let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }; + let mut report = Report::default(); + let mut budget = ImageBudget::with_work(2 * cost); + run_within(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + assert_eq!(report.images_refused, 1); + assert_eq!(budget.work_left(), 0, "two decoded and the third refused"); + assert_eq!(report.images_resampled, 2); + let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count(); + assert_eq!(changed, 2, "the third is as it was"); + } + #[test] fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 4c8845c82d..92671d2381 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -279,6 +279,8 @@ pub struct Report { pub images: usize, pub images_resampled: usize, pub images_recompressed: usize, + /// JBIG2 images left as they are: decoding them all would take more than one run may spend. + pub images_refused: usize, /// Encoded image bytes before and after (images that changed). pub image_bytes_before: usize, pub image_bytes_after: usize, diff --git a/crates/redact/README.md b/crates/redact/README.md index af43e958d8..fdb641e804 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -24,7 +24,8 @@ clear_marks(&mut doc, None)?; // remove marks without app is Flate, and refers to no globals: when no other image uses them, they leave the file with the original on the full rewrite; when one does, they stay as they are, and may hold the shapes of marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are - removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer + removed whole (fail-closed), and so is a JBIG2 image past what one redaction may decode + (`pdfcraft_cos::ImageBudget`), one whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 6c7a6bf93b..2880bd2304 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -7,7 +7,7 @@ //! caller removes the whole image instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; -use pdfcraft_cos::{Document, Object, Stream}; +use pdfcraft_cos::{Document, ImageBudget, Object, Stream}; /// Most pixels an image may have to be cleared in place (larger ones are removed). const MAX_PIXELS: u64 = 64 << 20; @@ -38,11 +38,11 @@ fn components(doc: &Document, s: &Stream) -> Option { /// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] -pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { - decode_and_clear(doc, s, ctm, rects).ok_or(()) +pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Result, ()> { + decode_and_clear(doc, s, ctm, rects, budget).ok_or(()) } -fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option> { +fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Option> { // Separate masks can retain the original image data. if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") { return None; @@ -111,7 +111,7 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels), // and refuses a JBIG2 page of another size than the image: the renderer draws the whole page // into the image, so the rows cleared wouldn't be the ones a mark covers on screen. - let mut data = doc.decoded_image_within(s, allowance).ok()?; + let mut data = budget.decode(doc, s, allowance).ok()?; if data.len() < bytes { return None; } diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ac69c73acb..5f02be7fd0 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ImageBudget, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -105,6 +105,8 @@ pub(crate) struct Scope<'a> { pub rects: &'a [[f64; 4]], pub mode: Mode, pub report: &'a mut Report, + /// What the images it clears may spend, shared by all the pages of a run. + pub images: &'a mut ImageBudget, /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). pub hidden_text: Option<[f64; 4]>, /// Sanitize: optional content groups that are off; content marked with them is removed. @@ -134,11 +136,12 @@ pub(crate) struct Scope<'a> { impl<'a> Scope<'a> { /// A scope whose form walk spends `budget`, what is left of the page's allowance after its /// content streams were decoded. - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget, images: &'a mut ImageBudget) -> Self { Scope { rects, mode, report, + images, hidden_text: None, hidden_layers: HashSet::new(), layers_enabled: false, @@ -750,7 +753,7 @@ fn xobject( out.doomed.push(r); return Some(None); } - match image::clear(doc, s, &gs.ctm, scope.rects) { + match image::clear(doc, s, &gs.ctm, scope.rects, &mut *scope.images) { Ok(None) => None, Ok(Some(new)) => { let nr = doc.add(Object::Stream(new)); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..8b7d663be8 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -412,6 +412,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); + let images = pdfcraft_cos::ImageBudget::new(doc); let mut applying = Applying { report, doomed_fields: Vec::new(), @@ -419,6 +420,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result 0 { @@ -542,6 +546,8 @@ struct Applying { retired_properties: Vec, widget_owner: Vec<(ObjRef, ObjRef)>, removed_everywhere: HashSet, + /// What clearing images may spend, across the run's pages. + images: pdfcraft_cos::ImageBudget, } /// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. @@ -552,13 +558,13 @@ fn redact_page_content( page: &pdfcraft_model::Page, rects: &[[f64; 4]], ) -> Result<(), RedactError> { - let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + let Applying { report, doomed_xobjects, retired_properties, images, .. } = applying; // 1. Content. let mut budget = pdfcraft_cos::DecodeBudget::page(); let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let out = { - let mut scope = Scope::new(rects, Mode::Apply, report, budget); + let mut scope = Scope::new(rects, Mode::Apply, report, budget, images); process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) }; // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 98703dfbf2..6fd4354300 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -369,7 +369,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report, budget); + let mut images = pdfcraft_cos::ImageBudget::new(doc); + let mut scope = Scope::new(&[], mode, &mut report, budget, &mut images); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 08faef69a7..cca7507ddd 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -52,7 +52,8 @@ fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap(); let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut rep = Report::default(); - let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget); + let mut images = pdfcraft_cos::ImageBudget::with_work(0); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget, &mut images); process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue } @@ -746,6 +747,24 @@ fn jbig2_globals(secret: &[u8]) -> Vec { g } +/// An image the run's allowance can't decode can't be cleared, so it is removed whole, like any +/// other image that can't be read: what a mark covers never stays. +#[test] +fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()); + let doc = Document::new_empty(); + let half = [[0.0, 0.0, 0.5, 1.0]]; + let clear = |work| crate::image::clear(&doc, &image, &pdfcraft_content::Matrix::IDENTITY, &half, &mut pdfcraft_cos::ImageBudget::with_work(work)); + assert!(matches!(clear(u64::MAX), Ok(Some(_))), "cleared in a copy"); + assert!(matches!(clear(0), Err(())), "removed whole"); +} + /// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy, /// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by /// reference, behind another filter with its globals at its own place, or as a mask, its decode diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index f973851d26..9331c82ea7 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -83,6 +83,10 @@ pub struct RenderConfig { /// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an /// installed font is saved or printed (AGENTS.md §1.2, §1.4). pub system_fonts: bool, + /// Steps of JBIG2 decoding a render may take across the page's images, instead of the usual + /// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't + /// be decoded. + pub jbig2_work: Option, } impl RenderConfig { @@ -100,7 +104,8 @@ impl RenderConfig { /// Passwords belong to parser construction; request-specific preview overrides are applied /// by `render_page`, which also preserves the caller's warning and cancellation hooks. fn settings(&self) -> InterpreterSettings { - let standard = InterpreterSettings::default().font_resolver; + let defaults = InterpreterSettings::default(); + let standard = defaults.font_resolver.clone(); let system_fonts = self.system_fonts; InterpreterSettings { ocg_overrides: self.layers.clone(), @@ -108,7 +113,8 @@ impl RenderConfig { font_resolver: Arc::new(move |query| { japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query)) }), - ..InterpreterSettings::default() + jbig2_work: self.jbig2_work.unwrap_or(defaults.jbig2_work), + ..defaults } } } diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index d3a8e1a0cd..84fbc35252 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3592,7 +3592,10 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[ pdf.extend_from_slice(b"\nendstream endobj\n"); pdf.extend_from_slice(objects); pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); - let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + // What one image may take, which the page's allowance, tested on its own, would otherwise reach + // first: it is the same size, and the page itself comes out of it. + let config = RenderConfig { jbig2_work: Some(u64::MAX), ..RenderConfig::default() }; + let mut r = PageRenderer::new(Arc::new(pdf), config); let page = r.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[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); @@ -3604,6 +3607,117 @@ fn jbig2_image_draws_with(stream: &[u8], space: &str, entries: &str, objects: &[ } } +/// The JBIG2 images of a page share one allowance, down to the step, and the next page starts with +/// its own. The images differ by an extension segment: one image decoded again isn't decoded twice. +#[test] +fn the_jbig2_images_of_a_page_share_one_allowance() { + let image = |number: u8, id: u8| { + let mut stream = black_jbig2_page(); + jbig2_segment(&mut stream, 1, 62, &[], &[0, 0, 0, 0, id]); + let mut object = format!( + "{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + stream.len() + ) + .into_bytes(); + object.extend_from_slice(&stream); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let page = |number: u8, content: u8, xobjects: &str| { + format!("{number} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents {content} 0 R /Resources << /XObject << {xobjects} >> >> >> endobj\n").into_bytes() + }; + let content = |number: u8, text: &str| format!("{number} 0 obj << /Length {} >> stream\n{text}\nendstream endobj\n", text.len()).into_bytes(); + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n".to_vec(); + pdf.extend(page(3, 5, "/A 7 0 R /B 8 0 R")); + pdf.extend(page(4, 6, "/C 9 0 R")); + pdf.extend(content(5, "q 20 0 0 40 0 0 cm /A Do Q q 20 0 0 40 20 0 cm /B Do Q")); + pdf.extend(content(6, "q 20 0 0 40 0 0 cm /C Do Q")); + pdf.extend(image(7, 1)); + pdf.extend(image(8, 2)); + pdf.extend(image(9, 3)); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + let pdf = Arc::new(pdf); + let black = |page: &RenderedPage, x: usize| match &page.rgba[((20 * 40 + x) * 4)..][..4] { + [0, 0, 0, 255] => true, + [255, 255, 255, 255] => false, + other => panic!("neither an image nor the page: {other:?}"), + }; + let pages = |work: u64| { + let mut renderer = PageRenderer::new(pdf.clone(), RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() }); + [0, 1].map(|page| renderer.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })) + }; + let drawn = |work: u64| { + let [first, _] = pages(work); + assert!(first.error.is_none(), "{:?}", first.error); + (black(&first, 10), black(&first, 30)) + }; + let one = (1..1_000).find(|work| drawn(*work).0).expect("a small image is cheap"); + assert_eq!(drawn(one - 1), (false, false), "a step short of the first"); + assert_eq!(drawn(one), (true, false), "enough for one"); + assert_eq!(drawn(2 * one - 1), (true, false), "a step short of both"); + assert_eq!(drawn(2 * one), (true, true), "enough for both"); + let [_, second] = pages(one); + assert!(black(&second, 10), "the next page has its own allowance, though this one spent all of it"); + let usual = PageRenderer::new(pdf.clone(), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(black(&usual, 10) && black(&usual, 30), "the usual allowance is far above a page like this"); +} + +/// An image that can't be decoded has still cost its reading, which a page repeats for each time +/// it draws one, since such an image isn't kept. So what it reads comes out of the page's allowance, +/// and an image after it has less. Allowed exactly what the good one takes, it isn't drawn behind +/// the bad one, and is drawn alone. +#[test] +fn an_image_that_fails_spends_the_pages_allowance_too() { + let image = |number: u8, stream: &[u8]| { + let mut object = format!( + "{number} 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", + stream.len() + ) + .into_bytes(); + object.extend_from_slice(stream); + object.extend_from_slice(b"\nendstream endobj\n"); + object + }; + let pdf = |content: &str| { + 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\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Bad 5 0 R /Good 6 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + pdf.extend(image(5, &[0xFF; 64])); + pdf.extend(image(6, &black_jbig2_page())); + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + Arc::new(pdf) + }; + let good = "q 40 0 0 40 0 0 cm /Good Do Q"; + let drawn = |content: &str, work: u64| { + let config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() }; + let page = PageRenderer::new(pdf(content), config).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba[((20 * 40 + 20) * 4)..][..4] == [0, 0, 0, 255] + }; + let cost = (1..2_000).find(|work| drawn(good, *work)).expect("a small image is cheap"); + assert!(!drawn(&format!("q 1 0 0 1 0 0 cm /Bad Do Q {good}"), cost), "the bad one spent some of it"); + assert!(drawn(&format!("q 1 0 0 1 0 0 cm /Bad Do Q {good}"), cost + 4 * 64), "and what it spent is its reading"); +} + /// Bits written from the top of each byte, as JBIG2's Huffman-coded data is. #[derive(Default)] struct Bits { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cdd4fcd88d..cd2a11abf6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7408,8 +7408,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again"] -notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] +notes = "All of a run's JBIG2 images share one allowance of decoding (about 2,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" diff --git a/vendor/README.md b/vendor/README.md index c350c8deb3..bd92ac62a4 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; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `Context::new` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance` (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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 | (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` | +| 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 | (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) | | 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 | (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` | +| 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; (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 | (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` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index d41f35ee83..de3907b1ff 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -218,6 +218,8 @@ impl<'a> Context<'a> { NESTED_PAINTS.with(|n| n.set(0)); CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT)); CONTENT_TRUNCATED.with(|truncated| truncated.set(false)); + // PdfCraft patch (73): and fresh JBIG2 decoding, which every image of it shares. + hayro_syntax::set_page_jbig2_work(settings.jbig2_work); // PdfCraft patch (55): a fallback left over from another page is not this page's. crate::color::take_color_fallback(); crate::encode::reset_mesh_budget(); diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 58213439ac..9ae93dbc68 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -120,6 +120,10 @@ pub struct InterpreterSettings { /// costs a decode and a resample each for pixels nobody can see. Never set it for a final /// render (tiles, whole pages, print, export). pub preview: bool, + /// PdfCraft patch (73): the steps of JBIG2 decoding a page's images may take between them + /// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, each with + /// [`InterpreterWarning::ImageDecodeFailure`]. + pub jbig2_work: u64, } impl Default for InterpreterSettings { @@ -142,6 +146,7 @@ impl Default for InterpreterSettings { ocg_overrides: Arc::new(Vec::new()), cancelled: None, preview: false, + jbig2_work: hayro_syntax::MAX_PAGE_JBIG2_WORK, } } } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index b8ab097b76..2ee0f8f050 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -195,11 +195,37 @@ impl<'a> Image<'a> { /// This is useful in case you want to convert multiple JBIG2 images, /// as it allows `hayro-jbig2` to reuse allocations during decoding. pub fn decode_with(&self, decoder: &mut D, ctx: &mut DecoderContext) -> Result<()> { - decode_segments(&self.segments, self.height_from_stripes, ctx)?; + let mut work_left = bitmap::MAX_WORK; + decode_segments(&self.segments, self.height_from_stripes, ctx, &mut work_left)?; emit_bitmap(&ctx.page_bitmap, decoder); Ok(()) } + + /// PdfCraft patch: [`Image::decode`], spending from `work`, what decoding may still cost + /// across a run of images. An image spends what it took, failing or not, starting with its + /// page's pixels, which whoever asked for them will go through, and its segments, so images + /// that decode nothing add up too. On top of that, it takes at most [`bitmap::MAX_WORK`] + /// whatever `work` says. One that `work` can't pay for spends all of it, so a run ends rather + /// than trying every image it has left. Reading the stream comes before this and is charged + /// by [`charge_reading`]. + pub fn decode_within(&self, decoder: &mut D, work: &mut u64) -> Result<()> { + let start = bitmap::decode_work(self.width, self.height).saturating_add(self.segments.len() as u64); + if charge_work(work, start).is_err() { + *work = 0; + bail!(OverflowError::DecodingWork); + } + let allowed = bitmap::MAX_WORK.min(*work); + let mut left = allowed; + let mut ctx = DecoderContext::default(); + let decoded = decode_segments(&self.segments, self.height_from_stripes, &mut ctx, &mut left); + let refused = decoded == Err(OverflowError::DecodingWork.into()); + *work = if refused && allowed < bitmap::MAX_WORK { 0 } else { *work - (allowed - left) }; + decoded?; + emit_bitmap(&ctx.page_bitmap, &mut *decoder); + + Ok(()) + } } fn emit_bitmap(bitmap: &Bitmap, decoder: &mut D) { @@ -259,13 +285,29 @@ fn emit_bitmap(bitmap: &Bitmap, decoder: &mut D) { } } -/// PdfCraft patch: count `work` more decoding work, refusing past [`bitmap::MAX_WORK`] before what -/// asks for it is decoded. +/// PdfCraft patch: count `work` more decoding work, refusing past what is `left` of +/// [`bitmap::MAX_WORK`] or a run's allowance before what asks for it is decoded. fn charge_work(left: &mut u64, work: u64) -> Result<()> { *left = left.checked_sub(work).ok_or(OverflowError::DecodingWork)?; Ok(()) } +/// PdfCraft patch: the steps of reading a byte of a stream or its globals: parsing and sorting +/// segments, and building their tables, measured at about 3 a byte at the worst. +const READ_WORK: u64 = 4; + +/// PdfCraft patch: charge `work`, what a run of images may still cost, for reading `bytes` of a stream +/// and its globals. This comes before anything is parsed, as it counts whether or not they turn out +/// to decode: an image that fails has still cost its reading, and it is read again for each use. What +/// `work` can't pay for spends all of it. +pub fn charge_reading(work: &mut u64, bytes: usize) -> Result<()> { + if charge_work(work, (bytes as u64).saturating_mul(READ_WORK)).is_err() { + *work = 0; + bail!(OverflowError::DecodingWork); + } + Ok(()) +} + /// PdfCraft patch: the work of a region decoded pixel by pixel. fn region_work(region: &decode::RegionSegmentInfo) -> u64 { bitmap::decode_work(region.width, region.height) @@ -275,6 +317,7 @@ fn decode_segments( segments: &[segment::Segment<'_>], height_from_stripes: Option, decoder_ctx: &mut DecoderContext, + work_left: &mut u64, ) -> Result<()> { // Find and parse page information segment first. if let Some(page_info) = segments @@ -295,7 +338,6 @@ fn decode_segments( let page_bitmap = &mut decoder_ctx.page_bitmap; let page_state = &mut decoder_ctx.page_state; let scratch_buffers = &mut decoder_ctx.scratch_buffers; - let mut work_left = bitmap::MAX_WORK; // Process all segments. for seg in segments { @@ -308,7 +350,7 @@ fn decode_segments( SegmentType::ImmediateGenericRegion | SegmentType::ImmediateLosslessGenericRegion => { let had_unknown_length = seg.header.data_length.is_none(); let header = generic::parse(&mut reader, had_unknown_length)?; - charge_work(&mut work_left, region_work(&header.region_info))?; + charge_work(work_left, region_work(&header.region_info))?; if page_state.can_decode_directly(page_bitmap, &header.region_info, false) { generic::decode_into(&header, page_bitmap, scratch_buffers)?; @@ -326,13 +368,13 @@ fn decode_segments( SegmentType::IntermediateGenericRegion => { // Intermediate segments cannot have unknown length. let header = generic::parse(&mut reader, false)?; - charge_work(&mut work_left, region_work(&header.region_info))?; + charge_work(work_left, region_work(&header.region_info))?; let region = generic::decode(&header, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::PatternDictionary => { let header = pattern::parse(&mut reader)?; - charge_work(&mut work_left, header.work())?; + charge_work(work_left, header.work())?; let dictionary = pattern::decode(&header, scratch_buffers, page_state.retained_room())?; page_state.store_pattern_dictionary(seg.header.segment_number, dictionary)?; } @@ -341,7 +383,7 @@ fn decode_segments( // (6.5.5, step 1) // Collect references to avoid cloning; symbols are only cloned if re-exported. let input_symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; - charge_work(&mut work_left, input_symbols.len() as u64)?; + charge_work(work_left, input_symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -368,14 +410,14 @@ fn decode_segments( &page_state.standard_tables, retained_contexts, page_state.retained_room(), - &mut work_left, + work_left, )?; page_state.store_symbol_dictionary(seg.header.segment_number, dictionary)?; } SegmentType::ImmediateTextRegion | SegmentType::ImmediateLosslessTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; - charge_work(&mut work_left, symbols.len() as u64)?; + charge_work(work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. // "These user-supplied Huffman decoding tables may be supplied either @@ -389,10 +431,10 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; - charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + charge_work(work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; if header.flags.use_huffman { // Its symbol ID table has a line for each symbol. - charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + charge_work(work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; } if page_state.can_decode_directly( @@ -407,7 +449,7 @@ fn decode_segments( &page_state.standard_tables, page_bitmap, scratch_buffers, - &mut work_left, + work_left, )?; } else { let region = text::decode( @@ -416,7 +458,7 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, scratch_buffers, - &mut work_left, + work_left, )?; page_bitmap.combine( ®ion.bitmap, @@ -430,7 +472,7 @@ fn decode_segments( SegmentType::IntermediateTextRegion => { // Collect symbols from referred symbol dictionaries (SBSYMS). let symbols = page_state.referred_symbols(&seg.header.referred_to_segments)?; - charge_work(&mut work_left, symbols.len() as u64)?; + charge_work(work_left, symbols.len() as u64)?; // Collect Huffman tables from referred table segments. let referred_tables: Vec = seg @@ -442,10 +484,10 @@ fn decode_segments( .collect(); let header = text::parse(&mut reader, symbols.len() as u32)?; - charge_work(&mut work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; + charge_work(work_left, bitmap::copy_work(header.region_info.width, header.region_info.height))?; if header.flags.use_huffman { // Its symbol ID table has a line for each symbol. - charge_work(&mut work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; + charge_work(work_left, (symbols.len() as u64).saturating_mul(bitmap::HUFFMAN_LINE_WORK))?; } let region = text::decode( &header, @@ -453,7 +495,7 @@ fn decode_segments( &referred_tables, &page_state.standard_tables, scratch_buffers, - &mut work_left, + work_left, )?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } @@ -466,7 +508,7 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; - charge_work(&mut work_left, halftone::work(&header, pattern_dict))?; + charge_work(work_left, halftone::work(&header, pattern_dict))?; if page_state.can_decode_directly( page_bitmap, @@ -494,7 +536,7 @@ fn decode_segments( .ok_or(SegmentError::MissingPatternDictionary)?; let header = halftone::parse(&mut reader)?; - charge_work(&mut work_left, halftone::work(&header, pattern_dict))?; + charge_work(work_left, halftone::work(&header, pattern_dict))?; let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } @@ -508,7 +550,7 @@ fn decode_segments( .unwrap_or(page_bitmap); let header = generic_refinement::parse(&mut reader)?; - charge_work(&mut work_left, region_work(&header.region_info))?; + charge_work(work_left, region_work(&header.region_info))?; let region = generic_refinement::decode(&header, reference, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } @@ -526,7 +568,7 @@ fn decode_segments( .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; - charge_work(&mut work_left, region_work(&header.region_info))?; + charge_work(work_left, region_work(&header.region_info))?; if let Some(referred_segment) = referred_segment && page_state.can_decode_directly(page_bitmap, &header.region_info, false) diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index 6f1bfb0768..356c539538 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -9,6 +9,64 @@ use alloc::vec; use alloc::vec::Vec; use core::iter; +/// PdfCraft patch (31): the steps of decoding a page's JBIG2 images may take between them, as many +/// as one may. Each has its own limit, which doesn't bound a page of many made to reach it. That +/// is about 6 s to 17 s, inside a render's watchdog; an image of the largest size costs a quarter of +/// it to decode and a quarter for its page, and ordinary ones are far below that. +pub const MAX_PAGE_JBIG2_WORK: u64 = 1 << 30; + +#[cfg(feature = "std")] +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 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)); +} + +/// Pays for reading `bytes` of a stream and its globals out of what the page has left, or `None`. +/// An image that doesn't decode isn't kept, so a page drawing it again and again reads it again. +#[cfg(feature = "std")] +fn charge_page_reading(bytes: usize) -> Option<()> { + let mut left = PAGE_WORK_LEFT.with(core::cell::Cell::get); + let charged = hayro_jbig2::charge_reading(&mut left, bytes); + PAGE_WORK_LEFT.with(|cell| cell.set(left)); + charged.ok() +} + +#[cfg(not(feature = "std"))] +fn charge_page_reading(_: usize) -> Option<()> { + Some(()) +} + +/// `image` decoded through `decoder`, spending what the page has left, if there is anything. +#[cfg(feature = "std")] +fn decode_for_page(image: &hayro_jbig2::Image<'_>, decoder: &mut D) -> Option<()> { + 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)); + decoded.ok() +} + +#[cfg(not(feature = "std"))] +fn decode_for_page(image: &hayro_jbig2::Image<'_>, decoder: &mut D) -> Option<()> { + image.decode(decoder).ok() +} + +/// Whether the page has nothing left for another image, which then isn't read at all. +#[cfg(feature = "std")] +fn page_work_spent() -> bool { + PAGE_WORK_LEFT.with(core::cell::Cell::get) == 0 +} + +#[cfg(not(feature = "std"))] +fn page_work_spent() -> bool { + false +} + /// Decode JBIG2 data from a PDF stream. /// /// The `params` dictionary may contain a `JBIG2Globals` entry pointing to @@ -18,6 +76,10 @@ pub(crate) fn decode( params: &Dict<'_>, image_params: &ImageDecodeParams, ) -> Option> { + if page_work_spent() { + return None; + } + // PdfCraft patch: globals are raw segments, so JBIG2 inside them means nothing, and decoding // it reads its own globals in turn: a chain of 16,000 such streams overflowed the stack, which // aborts the process. Such a stream reads as globals that can't be decoded. @@ -26,6 +88,8 @@ pub(crate) fn decode( .filter(|g| !g.filters().iter().any(|f| matches!(f, Filter::Jbig2Decode))) .and_then(|g| g.decoded().ok()); + 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()?; // Whenever possible (if we don't have an indexed color space), we convert @@ -60,7 +124,7 @@ pub(crate) fn decode( let writer = BitWriter::new(&mut packed, 1)?; let mut decoder = BitWriterDecoder { writer }; - image.decode(&mut decoder).ok()?; + decode_for_page(&image, &mut decoder)?; (packed, 1) } else { @@ -83,7 +147,7 @@ pub(crate) fn decode( } let mut decoder = Luma8Decoder { output: Vec::new() }; - image.decode(&mut decoder).ok()?; + decode_for_page(&image, &mut decoder)?; (decoder.output, 8) }; diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 06bda29487..7627b98408 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -9,6 +9,10 @@ mod dct; #[cfg(feature = "images")] mod jbig2; #[cfg(feature = "images")] +pub use jbig2::MAX_PAGE_JBIG2_WORK; +#[cfg(all(feature = "images", feature = "std"))] +pub use jbig2::set_page_jbig2_work; +#[cfg(feature = "images")] mod jpx; mod lzw_flate; mod run_length; From 1a7663cb48d1ba6740a50c3a3a23b89f27e7a5f9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 31 Oct 2026 13:41:56 +0100 Subject: [PATCH 064/562] fix(ui): select the bookmark title when the rename editor opens (#5529) The inline rename field asked for focus without setting a selection, so the caret landed at the end of the default text and typing appended to it. The field now selects the whole title on the frame it takes focus, so typing replaces the old title at once, for both new bookmarks and rename. Later frames leave the user's own caret and selection alone. Tests: three unit tests on rename_field (first focus, multibyte title, caret kept across frames) and two kittest tests driving the panel like a user (typing replaces the title; the caret survives a collapse). Co-Authored-By: Zoo --- crates/ui-egui/src/panels/bookmarks.rs | 87 ++++++++++++++++++++++++-- crates/ui-egui/tests/it/bookmarks.rs | 34 ++++++++++ parity/acrobat-features.toml | 4 +- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index ccc45d4eb0..2a92a4c227 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -88,6 +88,24 @@ pub(crate) fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool) } } +/// The inline rename field (#5529). On the frame it takes focus it selects the whole title, so +/// typing replaces the old text at once; later frames leave the user's own caret and selection +/// alone. Same pattern as the zoom box in `chrome.rs`. +fn rename_field(ui: &mut egui::Ui, text: &mut String, id: egui::Id) -> egui::Response { + let first_frame = !ui.memory(|m| m.has_focus(id)); + let resp = ui.add(crate::widgets::line(text).desired_width(ui.available_width() - 8.0).id(id)); + if !resp.has_focus() && !resp.lost_focus() { + if first_frame { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + resp.request_focus(); + } + resp +} + /// How a bookmark's title is drawn: in its own style and colour (ISO 32000-2 §12.3.3, Tables 151 /// and 152), bold in the heavier face and italic slanted, its colour kept readable on the panel. /// Top-level bookmarks without a style keep the medium face. @@ -138,11 +156,7 @@ pub(super) fn outline_item( ui.horizontal(|ui| { ui.add_space(x_text); crate::widgets::begin_dialog(ui); - let edit = crate::widgets::line(text).desired_width(ui.available_width() - 8.0).id(id.with("rename")); - let resp = ui.add(edit); - if !resp.has_focus() && !resp.lost_focus() { - resp.request_focus(); - } + let resp = rename_field(ui, text, id.with("rename")); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { cancel = true; } else if resp.lost_focus() { @@ -360,3 +374,66 @@ mod bookmark_filter { assert!(outline_matches(&outline, &fold("izmir")).is_empty()); } } + +#[cfg(test)] +mod rename_field { + use super::rename_field as field; + + fn cursor_range(ctx: &egui::Context, id: egui::Id) -> Option<(usize, usize)> { + egui::TextEdit::load_state(ctx, id) + .and_then(|s| s.cursor.char_range()) + .map(|r| (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0))) + } + + /// #5529: the rename field opens with the whole title selected, so typing replaces it. + #[test] + fn first_focus_selects_the_whole_title() { + let ctx = egui::Context::default(); + let id = egui::Id::unique("bm-rename-test"); + let mut text = String::from("Untitled"); + for _ in 0..2 { + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + field(ui, &mut text, id); + }); + output.drop_without_applying_deltas(); + } + assert_eq!(cursor_range(&ctx, id), Some((0, 8))); + assert!(ctx.memory(|m| m.has_focus(id)), "the field takes focus"); + } + + /// A Unicode title is counted in characters, not bytes. + #[test] + fn a_multibyte_title_is_selected_in_characters() { + let ctx = egui::Context::default(); + let id = egui::Id::unique("bm-rename-unicode"); + let mut text = String::from("図面(一階平面図)"); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + field(ui, &mut text, id); + }); + output.drop_without_applying_deltas(); + assert_eq!(cursor_range(&ctx, id), Some((0, 9))); + } + + /// A later frame must not reset the selection the user made; a caret stays a caret. + #[test] + fn a_later_frame_keeps_the_users_selection() { + let ctx = egui::Context::default(); + let id = egui::Id::unique("bm-rename-keep"); + let mut text = String::from("Chapter"); + for _ in 0..2 { + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + field(ui, &mut text, id); + }); + output.drop_without_applying_deltas(); + } + // The user moves the caret to 3 with no selection. + let mut state = egui::TextEdit::load_state(&ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(3)))); + state.store(&ctx, id); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + field(ui, &mut text, id); + }); + output.drop_without_applying_deltas(); + assert_eq!(cursor_range(&ctx, id), Some((3, 3)), "the user's caret is kept"); + } +} diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 1f07102a18..d5cd5efd2f 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -119,6 +119,40 @@ fn the_panel_keeps_its_width_while_the_pointer_moves() { assert_eq!(panel_width(&h), before, "the Bookmarks panel drifted wider"); } +/// #5529: the title is selected when the rename editor opens, so typing replaces it at once, +/// with no Ctrl+A. New bookmarks and double-click rename both get this. +#[test] +fn typing_replaces_the_title_when_the_editor_opens() { + let mut h = harness(); + h.key_press_modifiers(Modifiers::COMMAND, Key::B); + h.run_steps(3); + field(&h, "Untitled").type_text("Intro"); // the whole default is replaced + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(outline(&h), [("Intro".into(), Some(0), 0)]); + + menu(&mut h, "Intro", "Rename"); + field(&h, "Intro").type_text("Introduction"); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(outline(&h)[0].0, "Introduction"); +} + +/// The selection is one-shot: after the user collapses it to a caret, later frames must not +/// reselect, so typing at that caret inserts. +#[test] +fn a_rename_editor_keeps_the_users_caret_across_frames() { + let mut h = harness(); + h.key_press_modifiers(Modifiers::COMMAND, Key::B); + h.run_steps(3); + h.key_press(Key::ArrowLeft); // collapses the opening selection to the caret at 0 + h.run_steps(2); + field(&h, "Untitled").type_text("I"); // inserts at the caret, does not replace + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(outline(&h)[0].0, "IUntitled"); +} + #[test] fn escape_cancels_a_rename() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..d0377fc883 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3291,8 +3291,8 @@ milestone = "M4" status = "shipped" commands = [] tools = ["bookmark_rename"] -evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/it/bookmarks.rs::escape_cancels_a_rename", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"] -notes = "" +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::new_rename_reorder_indent_delete_and_undo", "crates/ui-egui/tests/it/bookmarks.rs::escape_cancels_a_rename", "crates/ui-egui/tests/it/bookmarks.rs::typing_replaces_the_title_when_the_editor_opens", "crates/ui-egui/tests/it/bookmarks.rs::a_rename_editor_keeps_the_users_caret_across_frames", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete", "crates/organize/src/tests.rs::bookmark_edits_keep_unknown_keys_and_touch_few_objects"] +notes = "The inline editor opens with the whole title selected, so typing replaces it at once (#5529); a caret the user moves afterwards is kept by later frames." [[feature]] id = "organize.bookmark-delete" From 52080ac8983c616e1916beea4c5ecc4d76dd78eb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 31 Oct 2026 13:56:51 +0100 Subject: [PATCH 065/562] M3/M4: add Crop pages to the document quick toolbar (#5330) Co-Authored-By: Codex --- ROADMAP.md | 3 ++ crates/ui-egui/src/canvas/bars.rs | 6 +++ crates/ui-egui/tests/it/pageboxes.rs | 64 ++++++++++++++++++++++++++-- docs/roadmap.md | 5 ++- parity/acrobat-features.toml | 4 +- 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 56f6237e8d..9c098748d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -213,6 +214,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged. + - **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. - **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged. diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 2472366672..7c2ef781d8 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -296,6 +296,12 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() { app.quick_tool = QuickTool::Hand; } + let may_crop = pdfcraft_engine::commands::command("page.crop").is_some_and(|spec| app.command_enabled(spec)); + ui.add_enabled_ui(may_crop, |ui| { + if icons::button(ui, "crop", 32.0, app.quick_tool == QuickTool::Crop, tl!("Crop pages")).clicked() { + app.execute("page.crop"); + } + }); // Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a // group selects its last-used tool; clicking it again opens the flyout. for g in 0..comments::GROUPS.len() { diff --git a/crates/ui-egui/tests/it/pageboxes.rs b/crates/ui-egui/tests/it/pageboxes.rs index 2e2829e05b..c54b4f01ef 100644 --- a/crates/ui-egui/tests/it/pageboxes.rs +++ b/crates/ui-egui/tests/it/pageboxes.rs @@ -1,7 +1,7 @@ //! Crop tool, Set Page Boxes and Duplicate pages in the real shell (egui_kittest). use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; const FIXTURE: &[u8] = b"%PDF-1.7 @@ -37,6 +37,64 @@ fn the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select() { assert!(h.state_mut().execute("page.crop")); assert_eq!(h.state().quick_tool, QuickTool::Crop); h.run_steps(2); + crop_middle_half(&mut h); +} + +#[test] +fn quick_toolbar_crop_crops_the_page_and_undo_restores_it() { + let mut h = harness(); + h.get_by_label("Crop pages").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, QuickTool::Crop); + assert!(!h.state().views[0].organize); + crop_middle_half(&mut h); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(size(&h, 0), (300.0, 400.0)); +} + +#[test] +fn quick_toolbar_crop_is_disabled_when_page_assembly_is_forbidden() { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(FIXTURE.to_vec())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: 0b0100, + encrypt_metadata: true, + seed: [5; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("restricted.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(6); + assert!(h.get_by_label("Crop pages").accesskit_node().is_disabled()); + assert!(!h.state_mut().execute("page.crop")); + assert_eq!(h.state().quick_tool, QuickTool::Select); + assert_eq!(size(&h, 0), (300.0, 400.0)); +} + +#[test] +fn quick_toolbar_crop_is_hidden_on_home_and_in_read_mode() { + let mut h = harness(); + h.state_mut().execute("view.read_mode"); + h.run_steps(3); + assert!(h.query_by_label("Crop pages").is_none()); + h.state_mut().execute("view.read_mode"); + h.run_steps(3); + h.get_by_label("Crop pages"); + h.state_mut().active = None; + h.run_steps(3); + assert!(h.query_by_label("Crop pages").is_none()); +} + +fn crop_middle_half(h: &mut Harness<'static, PdfCraftApp>) { let r = h.state().views[0].page_screen_rect(0).unwrap(); // The middle half of the page in both directions. let (a, b) = (r.min + r.size() * 0.25, r.min + r.size() * 0.75); @@ -50,9 +108,9 @@ fn the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select() { } h.drop_at(b); h.run_steps(4); - let (w, hh) = size(&h, 0); + let (w, hh) = size(h, 0); assert!((w - 150.0).abs() < 2.0 && (hh - 200.0).abs() < 2.0, "{w}×{hh}"); - assert_eq!(size(&h, 1), (300.0, 400.0)); + assert_eq!(size(h, 1), (300.0, 400.0)); assert_eq!(h.state().quick_tool, QuickTool::Select); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Crop page")); } diff --git a/docs/roadmap.md b/docs/roadmap.md index 0f1fb6f4d5..a09e597a36 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -194,7 +195,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
    Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
    Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Crop pages is directly available in the quick toolbar (#5330). | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
    Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
    Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
    Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -274,6 +275,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-31 · minor:** M3/M4 #5330: direct quick-toolbar Crop pages action reuses command permissions and the existing crop tool. Estimates unchanged. + **2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged. **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..bb3f8e7762 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3195,8 +3195,8 @@ milestone = "M4" status = "shipped" commands = ["page.crop"] tools = ["page_set_box"] -evidence = ["crates/ui-egui/tests/it/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select"] -notes = "drag to crop; double-click opens Set Page Boxes" +evidence = ["crates/ui-egui/tests/it/pageboxes.rs::the_crop_tool_crops_to_the_dragged_rectangle_then_returns_to_select", "crates/ui-egui/tests/it/pageboxes.rs::quick_toolbar_crop_crops_the_page_and_undo_restores_it", "crates/ui-egui/tests/it/pageboxes.rs::quick_toolbar_crop_is_disabled_when_page_assembly_is_forbidden", "crates/ui-egui/tests/it/pageboxes.rs::quick_toolbar_crop_is_hidden_on_home_and_in_read_mode"] +notes = "drag to crop; double-click opens Set Page Boxes; Crop pages is also directly available in the document quick toolbar (#5330), using the existing command availability and assembly permission check. The quick toolbar is hidden on Home and in Read/full-screen modes; arbitrary toolbar customization remains outside this feature." [[feature]] id = "organize.remove-white-margins" From 4aa732ea91348b351a97a789f02ec2f46b73df81 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 14:13:48 +0100 Subject: [PATCH 066/562] M2.6: what is left out past the JBIG2 allowance is said, and redaction stops From a review of who reads a refusal. The rule is to leave it out and say so, and it held on some paths only. An image whose JBIG2 mask is refused no longer comes out opaque. The first image past the allowance reads as that and not as corrupt. A page reports what it left out. The Office warning stopped blaming forms. Redaction refuses the run, leaving the document as it was: removing a readable image whole would take what no mark covers with it, unreported. The 2 GiB an export holds is twice the document if that is more, so a large honest file loses nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/automation/src/a11y/documents.rs | 2 +- crates/automation/src/tools.rs | 6 +++--- crates/create/README.md | 6 ++++-- crates/create/src/extract.rs | 12 ++++++++--- crates/create/src/tests.rs | 27 +++++++++++++++++++++++++ crates/engine/src/tests.rs | 18 +++++++++++++++++ crates/filters/src/jbig2/decode.rs | 5 +++-- crates/optimize/README.md | 2 +- crates/redact/README.md | 6 ++++-- crates/redact/src/lib.rs | 14 ++++++++++++- crates/redact/src/tests.rs | 21 ++++++++++++++++--- crates/render/src/raster/tests.rs | 4 +++- parity/acrobat-features.toml | 4 ++-- vendor/hayro-interpret/src/x_object.rs | 10 ++++++++- vendor/hayro-syntax/src/filter/jbig2.rs | 9 +++++---- vendor/hayro-syntax/src/filter/mod.rs | 2 +- 16 files changed, 121 insertions(+), 27 deletions(-) diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index e52768d535..295502215a 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -15,7 +15,7 @@ impl Automation { if !export.truncated_pages.is_empty() { use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; out["warnings"] = json!([format!( - "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" + "pictures past what one export may hold or decode, or forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" )]); } Ok(out) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 3809967350..91abb54061 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1194,7 +1194,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") @@ -1281,7 +1281,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations), or past what one export may decode or hold, are listed under skipped, with the reason. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1442,7 +1442,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). JBIG2 images past what one run may decode stay as they are, counted in images_refused. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/create/README.md b/crates/create/README.md index a2981b8e55..7b629a56e4 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -31,8 +31,10 @@ JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraf other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a -limit of their own), is skipped, and so is one past what one export may decode, which its images share; such a mask is left out and -the image exported opaque, as JPX and CCITT masks are. +limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are. +Past what one export may decode of JBIG2, which its images share, an image is skipped, and so is one whose mask is past it, which +would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that +is more, since they stay in memory until it ends. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 7b770ccb51..8f5b40bd82 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -31,14 +31,15 @@ pub struct ImageExport { /// Images larger than this many pixels are skipped (memory). const MAX_PIXELS: u64 = 100_000_000; -/// What one export may hold of the files it makes: they are all in memory until it ends, and a file -/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason. +/// What one export may hold of the files it makes, or twice the file if that is more, so that a file +/// of large images is exported whole: they are all in memory until it ends, and a file of a few KB +/// can make gigabytes of PNG. Images past it are left out, with the reason. const MAX_EXPORT_BYTES: usize = 2 << 30; /// The images of `pages` (0-based; each image once, on the first page using it), skipping /// those with fewer than `min_side` pixels on their shorter side. pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport { - extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES) + extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES.max(doc.bytes().len().saturating_mul(2))) } /// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is @@ -155,7 +156,12 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat }; let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true))); // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). + let refused = budget.refused(); let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) }; + // Without its mask the image would come out opaque, showing what the mask hides. + if budget.refused() > refused { + return Err("more JBIG2 decoding than one operation may do".into()); + } let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. let inverted = (jpeg || mask) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index cab718a1ef..28b1f2d091 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2275,3 +2275,30 @@ fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() { assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok()); assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err()); } + +/// A mask past the allowance can't be left out as an unreadable one is, or the image comes out +/// opaque, showing what the mask hides: the image goes, with the reason. +#[test] +fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut mask = Dict::new(); + mask.set(b"Subtype".to_vec(), Object::name("Image")); + mask.set(b"Width".to_vec(), Object::Int(16)); + mask.set(b"Height".to_vec(), Object::Int(2)); + mask.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + let mut doc = Document::new_empty(); + let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()))); + let mut image = Dict::new(); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); + assert!(crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok()); + let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err(); + assert!(refused.contains("more JBIG2 decoding than"), "{refused}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b47211afc4..e575de9f8b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8209,6 +8209,24 @@ fn copy_image_reads_the_globals_of_a_jbig2_picture() { assert_eq!(truncated, [0]); } +/// An Office export leaves out a picture it can't encode, as it did. That isn't a page cut short: +/// only a picture past what the export may do is reported as one. +#[test] +fn an_office_export_doesnt_call_an_unreadable_picture_truncated() { + let pdf = b"%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 /MediaBox [0 0 200 300] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length 31 >> stream\nq 160 0 0 20 20 100 cm /Im1 Do Q\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >> stream\nnone\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + .to_vec(); + let mut s = Session::new(); + let id = s.open("picture.pdf", None, Arc::new(pdf), None).unwrap(); + let (pages, truncated) = s.get(id).unwrap().export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + assert!(pages[0].images.is_empty()); + assert_eq!(truncated, Vec::::new()); +} + /// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter /// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`. #[test] diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 42280c0e8b..fe3148ff10 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -58,7 +58,8 @@ pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, ro return Err(FilterError::LimitExceeded(max)); } let mut out = Rows::new(width, height, false); - image.decode_within(&mut out, work).map_err(|e| corrupt(e.to_string()))?; + // Stopped by what was left, it isn't a damaged image, whatever the decoder calls it. + image.decode_within(&mut out, work).map_err(|e| if *work == 0 { spent() } else { corrupt(e.to_string()) })?; Ok(out.out) } @@ -159,7 +160,7 @@ mod tests { decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(); assert_eq!(work, 7, "only what it took"); let mut work = cost - 1; - assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. })), "a step short"); + assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_))), "a step short"); assert_eq!(work, 0, "spends all that was left"); // Nothing left: not read at all, so this garbage isn't reported as garbage. assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_)))); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 1b2291d48d..d6083733da 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -101,7 +101,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 images, with bounded shared globals. All of a run's JBIG2 images share one allowance of - decoding (`pdfcraft_cos::ImageBudget`): about 2,000 scanned pages' worth, plus more for each + decoding (`pdfcraft_cos::ImageBudget`): about 1,000 scanned pages' worth, plus more for each byte of the file. Images past it stay as they are, and `Report::images_refused` counts them. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex diff --git a/crates/redact/README.md b/crates/redact/README.md index fdb641e804..726f387376 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -24,8 +24,7 @@ clear_marks(&mut doc, None)?; // remove marks without app is Flate, and refers to no globals: when no other image uses them, they leave the file with the original on the full rewrite; when one does, they stay as they are, and may hold the shapes of marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are - removed whole (fail-closed), and so is a JBIG2 image past what one redaction may decode - (`pdfcraft_cos::ImageBudget`), one whose page isn't its size (the renderer + removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other @@ -34,6 +33,9 @@ clear_marks(&mut doc, None)?; // remove marks without app and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; + A run that would decode more JBIG2 than one redaction may (`pdfcraft_cos::ImageBudget`) is refused + and changes nothing: such an image can't be cleared, and removing it whole would take what no mark + covers with it. - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 8b7d663be8..9ecca0a57d 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -70,6 +70,10 @@ pub enum RedactError { /// could not be shown complete. #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] TooDeep, + /// Past what one redaction may decode an image can't be cleared, and removing it whole would + /// take what no mark covers with it, unreported. + #[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")] + ImagesPastAllowance, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -377,6 +381,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result { + let images = pdfcraft_cos::ImageBudget::new(doc); + apply_within(doc, pages, images) +} + +/// [`apply_to`], its images decoded within `images`. +fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_cos::ImageBudget) -> Result { let (all_marks, unreadable) = marks_checked(doc); // A mark with no readable area is not applied, and applying the others would look complete: // refuse before anything changes. Only the pages being applied count. @@ -412,7 +422,6 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let images = pdfcraft_cos::ImageBudget::new(doc); let mut applying = Applying { report, doomed_fields: Vec::new(), @@ -428,6 +437,9 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result 0 { + return Err(RedactError::ImagesPastAllowance); + } let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index cca7507ddd..9bb713aaa1 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -747,8 +747,9 @@ fn jbig2_globals(secret: &[u8]) -> Vec { g } -/// An image the run's allowance can't decode can't be cleared, so it is removed whole, like any -/// other image that can't be read: what a mark covers never stays. +/// An image the run's allowance can't decode can't be cleared. Removed whole, like an image that +/// can't be read, it would take what no mark covers with it without a word, so the run stops there, +/// and [`apply`] leaves the document as it was. #[test] fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() { let mut dict = Dict::new(); @@ -762,7 +763,21 @@ fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() { let half = [[0.0, 0.0, 0.5, 1.0]]; let clear = |work| crate::image::clear(&doc, &image, &pdfcraft_content::Matrix::IDENTITY, &half, &mut pdfcraft_cos::ImageBudget::with_work(work)); assert!(matches!(clear(u64::MAX), Ok(Some(_))), "cleared in a copy"); - assert!(matches!(clear(0), Err(())), "removed whole"); + assert!(matches!(clear(0), Err(())), "it can't be cleared"); + + let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap(); + let dict = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode"; + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, coded)]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}"); + let mut doc = one_page( + b"q 160 0 0 20 0 0 cm /Im Do Q", + "/XObject << /Im 7 0 R >>", + vec![stream(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())], + ); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok()); } /// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy, diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 84fbc35252..ddfae5da86 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3657,7 +3657,9 @@ fn the_jbig2_images_of_a_page_share_one_allowance() { assert_eq!(drawn(one), (true, false), "enough for one"); assert_eq!(drawn(2 * one - 1), (true, false), "a step short of both"); assert_eq!(drawn(2 * one), (true, true), "enough for both"); - let [_, second] = pages(one); + let [first, second] = pages(one); + assert!(first.warnings.contains(&RenderWarning::ContentTruncated), "the page says it left something out: {:?}", first.warnings); + assert!(pages(2 * one)[0].warnings.is_empty(), "and only then"); assert!(black(&second, 10), "the next page has its own allowance, though this one spent all of it"); let usual = PageRenderer::new(pdf.clone(), RenderConfig::default()).render(RenderRequest { page: 0, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cd2a11abf6..def0b97e37 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7189,7 +7189,7 @@ status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_images_behind_another_filter_export_as_their_picture"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." +notes = "each image once (page resources and nested forms), up to what one export may decode of JBIG2 and hold of files (2 GiB or twice the document), images past that left out with the reason; JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." [[feature]] id = "create.export-html" @@ -7409,7 +7409,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] -notes = "All of a run's JBIG2 images share one allowance of decoding (about 2,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +notes = "All of a run's JBIG2 images share one allowance of decoding (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index cfdd969faf..c93377ae1a 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -755,7 +755,15 @@ fn decode_context<'a>( let decoded = obj .stream .decoded_image(&decode_params) - .map_err(|_| (obj.warning_sink)(InterpreterWarning::ImageDecodeFailure)) + .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() { + InterpreterWarning::ContentTruncated + } else { + InterpreterWarning::ImageDecodeFailure + }); + }) .ok()?; let (mut scale_x, mut scale_y) = (1.0, 1.0); diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index 356c539538..33a7bc4dd8 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -56,14 +56,15 @@ fn decode_for_page(image: &hayro_jbig2::Image<'_>, deco image.decode(decoder).ok() } -/// Whether the page has nothing left for another image, which then isn't read at all. +/// PdfCraft patch (31): whether the page has nothing left for another image, which then isn't read +/// at all. #[cfg(feature = "std")] -fn page_work_spent() -> bool { +pub fn page_jbig2_work_spent() -> bool { PAGE_WORK_LEFT.with(core::cell::Cell::get) == 0 } #[cfg(not(feature = "std"))] -fn page_work_spent() -> bool { +pub fn page_jbig2_work_spent() -> bool { false } @@ -76,7 +77,7 @@ pub(crate) fn decode( params: &Dict<'_>, image_params: &ImageDecodeParams, ) -> Option> { - if page_work_spent() { + if page_jbig2_work_spent() { return None; } diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 7627b98408..e81a37df98 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -9,7 +9,7 @@ mod dct; #[cfg(feature = "images")] mod jbig2; #[cfg(feature = "images")] -pub use jbig2::MAX_PAGE_JBIG2_WORK; +pub use jbig2::{MAX_PAGE_JBIG2_WORK, page_jbig2_work_spent}; #[cfg(all(feature = "images", feature = "std"))] pub use jbig2::set_page_jbig2_work; #[cfg(feature = "images")] From 0f325d44b697d74567bb609a39e1cf7d232e9253 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 14:24:53 +0100 Subject: [PATCH 067/562] M2.6: a picture whose JBIG2 mask is past the allowance isn't drawn either Without its mask a picture shows what the mask hides, so the page leaves it out, as export does. Any other unreadable mask still leaves the picture, which is the renderer's tolerance for bad masks. What an export may hold is a function of its own, tested down to the step and at the top of the range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/create/src/extract.rs | 13 ++++-- crates/create/src/tests.rs | 11 ++++++ crates/redact/src/tests.rs | 2 +- crates/render/src/raster/tests.rs | 44 +++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 4 +- vendor/hayro-interpret/src/x_object.rs | 11 ++++++ 7 files changed, 79 insertions(+), 8 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 8f5b40bd82..9631aa0c91 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -31,15 +31,20 @@ pub struct ImageExport { /// Images larger than this many pixels are skipped (memory). const MAX_PIXELS: u64 = 100_000_000; -/// What one export may hold of the files it makes, or twice the file if that is more, so that a file -/// of large images is exported whole: they are all in memory until it ends, and a file of a few KB -/// can make gigabytes of PNG. Images past it are left out, with the reason. +/// What one export may hold of the files it makes: they are all in memory until it ends, and a file +/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason. const MAX_EXPORT_BYTES: usize = 2 << 30; +/// What an export of a file of `file_bytes` may hold: twice the file if that is more than +/// [`MAX_EXPORT_BYTES`], so that a file of large images is exported whole. +pub(crate) fn export_bytes(file_bytes: usize) -> usize { + MAX_EXPORT_BYTES.max(file_bytes.saturating_mul(2)) +} + /// The images of `pages` (0-based; each image once, on the first page using it), skipping /// those with fewer than `min_side` pixels on their shorter side. pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport { - extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES.max(doc.bytes().len().saturating_mul(2))) + extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), export_bytes(doc.bytes().len())) } /// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 28b1f2d091..55d0612637 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2302,3 +2302,14 @@ fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() { let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err(); assert!(refused.contains("more JBIG2 decoding than"), "{refused}"); } + +/// A large honest file keeps its images: what an export may hold grows with the file past the fixed +/// part, and a file too long to double doesn't wrap round to nothing. +#[test] +fn an_export_may_hold_twice_a_large_file() { + let fixed = crate::extract::export_bytes(0); + assert_eq!(fixed, 2 << 30); + assert_eq!(crate::extract::export_bytes(fixed / 2), fixed); + assert_eq!(crate::extract::export_bytes(fixed / 2 + 1), fixed + 2); + assert_eq!(crate::extract::export_bytes(usize::MAX), usize::MAX); +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9bb713aaa1..a14dffdb98 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -751,7 +751,7 @@ fn jbig2_globals(secret: &[u8]) -> Vec { /// can't be read, it would take what no mark covers with it without a word, so the run stops there, /// and [`apply`] leaves the document as it was. #[test] -fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() { +fn a_jbig2_image_past_the_runs_allowance_stops_the_run() { let mut dict = Dict::new(); dict.set(b"Width".to_vec(), Object::Int(16)); dict.set(b"Height".to_vec(), Object::Int(2)); diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index ddfae5da86..0fa0edc546 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3672,6 +3672,50 @@ fn the_jbig2_images_of_a_page_share_one_allowance() { assert!(black(&usual, 10) && black(&usual, 30), "the usual allowance is far above a page like this"); } +/// Without its mask a picture would show what the mask hides, so a JBIG2 mask past the page's +/// allowance takes the picture with it, and the page says so. A mask that can't be read for any +/// other reason leaves the picture, as it did. +#[test] +fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { + let draw = |entry: &str, mask: &[u8], work: u64| { + 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 16 /Height 2 /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 config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() }; + let page = PageRenderer::new(Arc::new(pdf), config).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 mask = black_jbig2_page(); + assert_eq!(draw("/SMask", &mask, u64::MAX), (false, false), "the mask hides all of it"); + assert_eq!(draw("/SMask", &mask, 0), (false, true), "past the allowance a soft mask hides it too"); + assert_eq!(draw("/Mask", &mask, 0), (false, true), "and so does a stencil mask"); + assert_eq!(draw("/SMask", b"no JBIG2 here", 1 << 20), (true, false), "any other bad mask is no mask"); +} + /// An image that can't be decoded has still cost its reading, which a page repeats for each time /// it draws one, since such an image isn't kept. So what it reads comes out of the page's allowance, /// and an image after it has less. Allowed exactly what the good one takes, it isn't drawn behind diff --git a/vendor/README.md b/vendor/README.md index bd92ac62a4..b4735c556c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `Context::new` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance` (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; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `Context::new`, and an image past it or with a mask past it left out with `ContentTruncated` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance` and `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` (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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | 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 | (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) | | 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` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 9ae93dbc68..1331298604 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -121,8 +121,8 @@ pub struct InterpreterSettings { /// render (tiles, whole pages, print, export). pub preview: bool, /// PdfCraft patch (73): the steps of JBIG2 decoding a page's images may take between them - /// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, each with - /// [`InterpreterWarning::ImageDecodeFailure`]. + /// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, nor images + /// whose masks are past it, each with [`InterpreterWarning::ContentTruncated`]. pub jbig2_work: u64, } diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index c93377ae1a..22f6db8c1a 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1016,6 +1016,17 @@ fn decode_raster( .flatten() }; + // 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. + let dict = obj.stream.dict(); + if alpha.is_none() + && hayro_syntax::page_jbig2_work_spent() + && (dict.contains_key(SMASK) || dict.contains_key(MASK)) + { + return None; + } + Some(DecodedRaster { image, alpha }) } From e3bc4e855f5d5521811131bb6ac3dc4ba9a76ddd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 14:33:57 +0100 Subject: [PATCH 068/562] fix(render): a JBIG2 height class of no symbols decodes no bitmap Rows are charged by the symbol, so an empty class decoded its collective bitmap for nothing: 65,535 rows a class, a thousand classes a dictionary. 806 KB took 196 s. It steps over the bytes instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 57 +++++++++++++++++++++++++ vendor/hayro-jbig2/src/decode/symbol.rs | 11 +++++ 2 files changed, 68 insertions(+) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index a6a6f33d3d..92f92fd982 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3698,6 +3698,44 @@ fn no_row_jbig2_dictionary(symbols: u32) -> Vec { dictionary } +/// A Huffman-coded symbol dictionary that climbs to `height`, stays about there for `classes` +/// classes of no symbols, each with a byte of MMR bitmap, and drops to one row for its one symbol, +/// not exported. +fn empty_class_jbig2_dictionary(classes: u32, height: u32) -> Vec { + let mut bits = Bits::default(); + bits.put(0b111110, 6); // height class delta, the upper line of table B.5 + bits.put(height - 76, 32); + let mut at = height; + for class in 0..classes { + // Table B.5 has no delta of 0, so it goes down one and up one. + if class % 2 == 1 { + bits.put(0b1111110, 7); // height class delta -1 (B.5): -255 and 254 + bits.put(254, 8); + at -= 1; + } else if class > 0 { + bits.put(0, 1); // height class delta 1 (B.5) + at += 1; + } + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(1, 5); // BMSIZE 1 (B.1) + bits.align(); + bits.put(0xFF, 8); + } + bits.put(0b1111111, 7); // height class delta, the lower line of table B.5 + bits.put(at - 1 - 256, 32); + bits.put(0b10, 2); // width delta 1 (B.2) + bits.put(0b111111, 6); // end of the class (B.2) + bits.put(0, 5); // BMSIZE 0, uncompressed (B.1) + bits.align(); + bits.put(0, 8); // its row + bits.table_b1(1); // the first export run, not exported, is all of them + let mut dictionary = vec![0x00, 0x05]; // Huffman, tables B.5, B.2, B.1, B.1 + dictionary.extend_from_slice(&0u32.to_be_bytes()); // exported + dictionary.extend_from_slice(&1u32.to_be_bytes()); // new + dictionary.extend_from_slice(&bits.bytes); + dictionary +} + /// A Huffman-coded symbol dictionary of `symbols` white symbols of width 1 and `height` rows, none /// exported, for `height` from 76 up. fn narrow_jbig2_dictionary(symbols: u32, height: u32) -> Vec { @@ -4000,6 +4038,25 @@ fn jbig2_refinement_region() -> Vec { region } +/// A class of no symbols has nothing to cut from its bitmap, and rows are charged by the symbol, so +/// decoding one would be rows for nothing: a thousand classes of 65,000 rows take a second, from a +/// dictionary of 4 KB. It isn't decoded, however many there are, and the symbol after them shows that +/// its bytes are stepped over. +#[test] +fn jbig2_classes_of_no_symbols_decode_no_bitmap() { + let mut stream = black_jbig2_page(); + for number in 0..1_000 { + jbig2_segment(&mut stream, number + 1, 0, &[], &empty_class_jbig2_dictionary(1024, 65_000)); + } + let (done, finished) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = done.send(jbig2_image_draws(&stream)); + }); + // This takes milliseconds now and took a quarter of an hour, so a margin this wide can't fail on + // a busy machine. + assert_eq!(finished.recv_timeout(std::time::Duration::from_secs(120)), Ok(true)); +} + /// Each item a JBIG2 stream decodes costs its work, and the stream may ask for 2^30: four of the /// largest pages, some 6 s to 17 s. Each bitmap is bounded on its own, but nothing bounded how many /// there were, so 1,140 bytes of generic regions took over 40 s, and a 795-byte PDF's one halftone diff --git a/vendor/hayro-jbig2/src/decode/symbol.rs b/vendor/hayro-jbig2/src/decode/symbol.rs index 3e7646964d..b682d4571c 100644 --- a/vendor/hayro-jbig2/src/decode/symbol.rs +++ b/vendor/hayro-jbig2/src/decode/symbol.rs @@ -657,6 +657,17 @@ fn decode_height_class_collective_bitmap(ctx: &mut SymbolDecodeContext<'_>) -> R ctx.h_ctx.reader.align(); + // PdfCraft patch: a class of no symbols has nothing to cut out, and `bitmap::decode_work` charges + // rows by the symbol, so decoding this would be rows for nothing: 65,535 a class, and a dictionary + // can hold a thousand such classes. Its bytes still have to be stepped over. + if ctx.symbols_decoded_count == ctx.height_class_first_symbol { + ctx.h_ctx + .reader + .read_bytes(bitmap_size as usize) + .ok_or(ParseError::UnexpectedEof)?; + return Ok(()); + } + let collective_bitmap = if bitmap_size == 0 { // Bitmap is stored uncompressed, so we basically just copy-paste // each byte. Rows are padded to the byte boundary. From a78356eefc4a9f7ee9f39a901b9ad94494150984 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 15:43:05 +0100 Subject: [PATCH 069/562] M2.6: a spent page speaks for its JBIG2 streams only, and ends with the page What a page has left is shared by all of it, so it says nothing about a stream that isn't JBIG2: a bad Flate or JPEG mask dropped its picture on a page that had spent its allowance, and a failing image of another kind raised a false "content skipped". Both ask about the stream now. The allowance also outlived the page on its thread, and a Context used again kept it. interpret_page sets it, and a guard ends it with the page. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 66 +++++++++++++++++++-- vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 13 ++++ vendor/hayro-interpret/src/x_object.rs | 24 +++++--- 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 54e5a2edc1..31e062d12f 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3677,7 +3677,7 @@ fn the_jbig2_images_of_a_page_share_one_allowance() { /// other reason leaves the picture, as it did. #[test] fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { - let draw = |entry: &str, mask: &[u8], work: u64| { + let draw = |entry: &str, filter: &str, mask: &[u8], work: u64| { 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()); @@ -3688,7 +3688,11 @@ fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { ); let kind = if entry == "/Mask" { "/ImageMask true" } else { "/ColorSpace /DeviceGray" }; pdf.extend_from_slice( - format!("6 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 {kind} /Filter /JBIG2Decode /Length {} >> stream\n", mask.len()).as_bytes(), + format!( + "6 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 {kind} /Filter {filter} /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"); @@ -3710,10 +3714,60 @@ fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() { (shown, page.warnings.contains(&RenderWarning::ContentTruncated)) }; let mask = black_jbig2_page(); - assert_eq!(draw("/SMask", &mask, u64::MAX), (false, false), "the mask hides all of it"); - assert_eq!(draw("/SMask", &mask, 0), (false, true), "past the allowance a soft mask hides it too"); - assert_eq!(draw("/Mask", &mask, 0), (false, true), "and so does a stencil mask"); - assert_eq!(draw("/SMask", b"no JBIG2 here", 1 << 20), (true, false), "any other bad mask is no mask"); + let jbig2 = "/JBIG2Decode"; + assert_eq!(draw("/SMask", jbig2, &mask, u64::MAX), (false, false), "the mask hides all of it"); + assert_eq!(draw("/SMask", jbig2, &mask, 0), (false, true), "past the allowance a soft mask hides it too"); + assert_eq!(draw("/Mask", jbig2, &mask, 0), (false, true), "and so does a stencil mask"); + assert_eq!(draw("/SMask", jbig2, b"no JBIG2 here", 1 << 20), (true, false), "any other bad mask is no mask"); + let other = draw("/SMask", "/DCTDecode", b"no JPEG here", 0); + assert_eq!(other, (true, false), "and a page with nothing left says nothing of a mask that isn't JBIG2"); +} + +/// 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] +fn a_pages_jbig2_allowance_ends_with_the_page() { + 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 hayro_syntax::page_jbig2_work_spent; + use kurbo::{Affine, BezPath, Rect}; + + /// Notes at each path whether the page has run out. + struct Spent(Vec); + impl<'a> Device<'a> for Spent { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) { + self.0.push(page_jbig2_work_spent()); + } + 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<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let content = "0 0 4 4 re f"; + let bytes = 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 >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n", + content.len() + ); + let pdf = Pdf::new(Arc::new(bytes.into_bytes())).expect("parses"); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + let settings = InterpreterSettings { jbig2_work: 0, ..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); + let mut device = Spent(Vec::new()); + for _ in 0..2 { + interpret_page(page, &mut ctx, &mut device); + assert!(!page_jbig2_work_spent(), "no page is being drawn"); + } + assert_eq!(device.0, [true, true], "each time, the page has what its settings give it"); } /// An image that can't be decoded has still cost its reading, which a page repeats for each time diff --git a/vendor/README.md b/vendor/README.md index b4735c556c..df0a8a99aa 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `Context::new`, and an image past it or with a mask past it left out with `ContentTruncated` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance` and `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` (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; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `Context::new`, and an image past it or with a mask past it left out with `ContentTruncated` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (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) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | 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 | (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) | | 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` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 1331298604..332000e248 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -168,12 +168,25 @@ pub enum InterpreterWarning { ColorFallback, } +/// PdfCraft patch (73): ends a page's JBIG2 allowance with the page, so that what decodes on its +/// thread afterwards has no page to run out on. +struct PageJbig2Work; + +impl Drop for PageJbig2Work { + fn drop(&mut self) { + hayro_syntax::set_page_jbig2_work(u64::MAX); + } +} + /// interpret the contents of the page and render them into the device. pub fn interpret_page<'a>( page: &Page<'a>, context: &mut Context<'a>, device: &mut impl Device<'a>, ) { + // PdfCraft patch (73): a context used again is another page. + hayro_syntax::set_page_jbig2_work(context.settings.jbig2_work); + let _allowance = PageJbig2Work; let resources = page.resources(); let mut content = page.content_reader(); // PdfCraft patch: the page's own contents count against its content budget too, where they diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 22f6db8c1a..6fcb34a5d7 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -681,13 +681,23 @@ fn decoded_mask_size(mask: &DecodedMask) -> usize { /// `/Filter` or its inline abbreviation `/F`. Errs towards `true`: a name the decoder would /// ignore still counts, which only keeps the target in the key as before. fn uses_jpx(stream: &Stream<'_>) -> bool { + uses_filter(stream, JPX_DECODE) +} + +/// PdfCraft patch (73): whether `stream` is JBIG2, so that what the page has left, which all of it +/// shares, says something about why this stream failed. +fn uses_jbig2(stream: &Stream<'_>) -> bool { + uses_filter(stream, JBIG2_DECODE) +} + +fn uses_filter(stream: &Stream<'_>, filter: &[u8]) -> bool { let dict = stream.dict(); - let is_jpx = |name: &Name<'_>| name.deref() == JPX_DECODE; + let is_filter = |name: &Name<'_>| name.deref() == filter; [F, FILTER].into_iter().any(|key| { - dict.get::>(key).is_some_and(|name| is_jpx(&name)) + dict.get::>(key).is_some_and(|name| is_filter(&name)) || dict .get::>(key) - .is_some_and(|filters| filters.iter::>().any(|name| is_jpx(&name))) + .is_some_and(|filters| filters.iter::>().any(|name| is_filter(&name))) }) } @@ -758,7 +768,7 @@ fn decode_context<'a>( .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() { + (obj.warning_sink)(if hayro_syntax::page_jbig2_work_spent() && uses_jbig2(&obj.stream) { InterpreterWarning::ContentTruncated } else { InterpreterWarning::ImageDecodeFailure @@ -1020,10 +1030,8 @@ fn decode_raster( // 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. let dict = obj.stream.dict(); - if alpha.is_none() - && hayro_syntax::page_jbig2_work_spent() - && (dict.contains_key(SMASK) || dict.contains_key(MASK)) - { + 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)) { return None; } From e7cabea9815c6ad80da58b747ac800b44978874d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 16:19:43 +0100 Subject: [PATCH 070/562] M2.6: the patch's row says where the allowance is set and what it leaves out Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- vendor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/README.md b/vendor/README.md index df0a8a99aa..8b906996a2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `Context::new`, and an image past it or with a mask past it left out with `ContentTruncated` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (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) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (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` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (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) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | 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 | (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) | | 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` | From 2ad72dcbd14112862a3c3b2793896411cbcf5ce1 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 17:32:10 +0100 Subject: [PATCH 071/562] M11: the JPEG test has its doc comment back The JBIG2 test went in between the two. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/src/images.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index cdd3c89d91..b25e8484c9 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1309,7 +1309,6 @@ mod dimension_tests { assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4])); } - /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. /// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped, /// no output changes, and an ordinary scan pays a quarter of a second a page for nothing. Behind /// another filter it can still be stored again, as Group 4 can. @@ -1340,6 +1339,7 @@ mod dimension_tests { assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller"); } + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. #[test] fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); From 199354246be8f935b604713ae2a0868edb00c4d3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 17:36:44 +0100 Subject: [PATCH 072/562] M2.6: redaction reads past /Crypt, and what can't be averaged spends nothing Redaction checked the raw /Filter array, so /Crypt in a chain removed an image it could have cleared, though decoding leaves /Crypt out. It leaves it out too. Decoded only to be resampled, an image that can't be averaged came to None after the decode, having spent the run's allowance and perhaps been counted as refused. Each such case now stops before it. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/src/images.rs | 35 ++++++++++++++++++++++++++++++++++- crates/redact/src/image.rs | 16 +++++++++++----- crates/redact/src/tests.rs | 7 +++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 51c130abd8..7666c036fc 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1102,7 +1102,10 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet // JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone. let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4; let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0))); - if only_to_resample && !downsample { + // What can't be averaged, whatever it holds, comes to the same `None` below, after a decode that + // would spend the run's allowance on it. + let too_wide = d.int(b"Width").is_some_and(|w| w > i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS)); + if only_to_resample && (!downsample || defaults || too_wide || (jbig2 && dark_is_one(doc, d).is_none())) { return None; } let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?; @@ -1398,6 +1401,36 @@ mod dimension_tests { assert_eq!(changed, 2, "the third is as it was"); } + /// Decoded only to be resampled, what can't be averaged whatever it holds isn't decoded, so it + /// takes nothing from the images that can be, and isn't counted as refused past the allowance. + #[test] + fn a_jbig2_image_that_cant_be_averaged_spends_nothing() { + let image = |edit: &dyn Fn(&mut Dict)| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + edit(&mut dict); + Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap()) + }; + let settings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; + let spent = |s: &Stream, defaults: bool| { + let mut budget = ImageBudget::with_work(u64::MAX); + let _ = mono(&Document::new_empty(), s, 720.0, defaults, &settings, &mut budget); + u64::MAX - budget.work_left() + }; + let plain = image(&|_| {}); + assert!(spent(&plain, false) > 0, "one that can be averaged is decoded"); + assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped"); + let wide = image(&|d| d.set(b"Width".to_vec(), Object::Int(i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS) + 1))); + assert_eq!(spent(&wide, false), 0, "too wide to average"); + let tinted = image(&|d| d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(Dict::new())]))); + assert_eq!(spent(&tinted, false), 0, "no way to tell which sample is dark"); + } + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. #[test] fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 2880bd2304..52377078be 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -59,13 +59,19 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice()); let readable = |f: &Object| f.as_name().is_some_and(supported); + // `/Crypt` was undone when the object was loaded (ISO 32000-2 §7.4.10), and decoding leaves it + // out wherever it stands. + let crypt = |f: &Object| f.as_name() == Some(b"Crypt".as_slice()); let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") { None => (true, false, false), - Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), - Some(Object::Array(a)) => match a.split_last() { - Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)), - _ => (a.iter().all(readable), false, false), - }, + Some(f @ Object::Name(_)) => (readable(f) || crypt(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), + Some(Object::Array(a)) => { + let chain: Vec<&Object> = a.iter().filter(|f| !crypt(f)).collect(); + match chain.split_last() { + Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(|f| readable(f)), fax(last), jbig2(last)), + _ => (chain.iter().all(|f| readable(f)), false, false), + } + } _ => (false, false, false), }; if !ok { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a14dffdb98..a547193dce 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -804,6 +804,13 @@ fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() { ( format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), hex, + vec![globals.clone()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // `/Crypt` was undone on load, and takes a place in the parameters. + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/Crypt /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), + white.clone(), vec![globals], [0x00, 0xFF, 0xFF, 0xFF], ), From a6944cbbdf90be849abca3f5f8864a668b6814b2 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 17:43:46 +0100 Subject: [PATCH 073/562] M2.6: the widest image that can be averaged is decoded The exclusion's test only looked past the limit, so a mutant moving it by one survived. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/src/images.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 7666c036fc..e99cefe9e1 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1425,8 +1425,10 @@ mod dimension_tests { let plain = image(&|_| {}); assert!(spent(&plain, false) > 0, "one that can be averaged is decoded"); assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped"); - let wide = image(&|d| d.set(b"Width".to_vec(), Object::Int(i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS) + 1))); - assert_eq!(spent(&wide, false), 0, "too wide to average"); + let width = |w: i64| image(&move |d| d.set(b"Width".to_vec(), Object::Int(w))); + let widest = i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS); + assert!(spent(&width(widest), false) > 0, "the widest that can be averaged is decoded"); + assert_eq!(spent(&width(widest + 1), false), 0, "too wide to average"); let tinted = image(&|d| d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(Dict::new())]))); assert_eq!(spent(&tinted, false), 0, "no way to tell which sample is dark"); } From 621f17f3b5bbbe9586ca98c1a6f2b972fa2f8112 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 18:07:06 +0100 Subject: [PATCH 074/562] M2.6: an image already at the target resolution isn't decoded to find that out Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/src/images.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index e99cefe9e1..cd2317590d 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1105,7 +1105,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet // What can't be averaged, whatever it holds, comes to the same `None` below, after a decode that // would spend the run's allowance on it. let too_wide = d.int(b"Width").is_some_and(|w| w > i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS)); - if only_to_resample && (!downsample || defaults || too_wide || (jbig2 && dark_is_one(doc, d).is_none())) { + if only_to_resample && (!downsample || defaults || too_wide || settings.target_ppi >= ppi || (jbig2 && dark_is_one(doc, d).is_none())) { return None; } let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?; @@ -1416,13 +1416,16 @@ mod dimension_tests { edit(&mut dict); Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap()) }; - let settings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; - let spent = |s: &Stream, defaults: bool| { + let spent_at = |target_ppi: f64, s: &Stream, defaults: bool| { + let settings = MonoSettings { compression: MonoCompression::Retain, above_ppi: 100.0, target_ppi, ..MonoSettings::REDUCE }; let mut budget = ImageBudget::with_work(u64::MAX); let _ = mono(&Document::new_empty(), s, 720.0, defaults, &settings, &mut budget); u64::MAX - budget.work_left() }; + let spent = |s: &Stream, defaults: bool| spent_at(300.0, s, defaults); let plain = image(&|_| {}); + assert_eq!(spent_at(720.0, &plain, false), 0, "already at the target"); + assert!(spent_at(719.0, &plain, false) > 0, "just above it"); assert!(spent(&plain, false) > 0, "one that can be averaged is decoded"); assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped"); let width = |w: i64| image(&move |d| d.set(b"Width".to_vec(), Object::Int(w))); From b71914b1ded10c142c8de4622252915a7df05e61 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 19:33:43 +0100 Subject: [PATCH 075/562] M2: colour spaces nest at most 32 levels Reading an object already being read is refused, which ends a cycle but not a chain. 5,000 spaces each naming the next, 180 KB of file, overflowed a 2 MiB stack and aborted the process, through a Pattern's underlying space, an Indexed base, or a Separation's or DeviceN's alternate. An ICC profile's alternate is the same recursion. Indexed, Separation and DeviceN could already do this; the fix for Pattern added a way in. A legal file goes five levels at most. All five ways through count against the one limit, in ColorSpaceType::new, so what comes in from outside is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 81 +++++++++++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/color.rs | 47 +++++++++++------ 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 1a639d0bb8..2214adac1b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -896,6 +896,87 @@ fn a_colour_space_that_refers_to_itself_ends() { } } +/// A chain of colour spaces repeats no object, so what ends a cycle lets it through. Each way one +/// space names another ends, at a depth that leaves room on the stack, and the page draws what it +/// can. +#[test] +fn a_chain_of_colour_spaces_ends() { + use super::*; + const LINKS: u32 = 20_000; + let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f"; + let link: [(&str, &dyn Fn(u32) -> String); 5] = [ + ("[/Pattern 10 0 R]", &|next| format!("[/Pattern {next} 0 R]")), + ("10 0 R", &|next| format!("[/Indexed {next} 0 R 1 <0011>]")), + ("10 0 R", &|next| format!("[/Separation /A {next} 0 R 5 0 R]")), + ("10 0 R", &|next| format!("[/DeviceN [/A] {next} 0 R 5 0 R]")), + ("[/ICCBased 10 0 R]", &|next| format!("<< /N 1 /Alternate [/ICCBased {next} 0 R] /Length 1 >> stream\nx\nendstream")), + ]; + for (space, link) in link { + let objects: String = (0..LINKS).map(|i| format!("{} 0 obj {} endobj\n", 10 + i, link(11 + i))).collect(); + let bytes = 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 << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\ + {objects}{} 0 obj /DeviceGray endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + 10 + LINKS + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{}: {:?}", link(11), page.error); + let start = ((150 * page.width as usize) + 50) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", link(11)); + } +} + +/// A file nests colour spaces five levels at most, and the limit is well past that: 31 spaces over +/// a device space are read and draw their colour, and with one more the space isn't read. +#[test] +fn colour_spaces_are_read_to_the_limit_of_their_nesting() { + use super::*; + let content = "/C cs 1 scn 0 0 200 200 re f"; + let green = |levels: u32| { + let objects: String = (0..levels - 1).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect(); + let bytes = 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 << /ColorSpace << /C 10 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\ + 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\ + {objects}{} 0 obj [/Separation /A /DeviceRGB 6 0 R] endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + 10 + levels - 1 + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{levels}: {:?}", page.error); + page.rgba.get(..4) == Some(&[0, 255, 0, 255][..]) + }; + assert!(green(1), "a space on its own"); + assert!(green(5), "as deep as a legal file goes"); + assert!(green(31), "the deepest that is read"); + assert!(!green(32), "one past it"); +} + /// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for /// the whole pair, found no pair there, and ended the array, so the image didn't draw at all. #[test] diff --git a/vendor/README.md b/vendor/README.md index 91aeceda61..17d1695559 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (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 | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting}` (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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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` | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index af59ab5af3..2c9a9af49c 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -166,12 +166,24 @@ fn icc_stream_object(array: &Array<'_>, stream: &Stream<'_>) -> Option, cache: &Cache) -> Option { - Self::new_inner(object, cache) + fn new(object: Object<'_>, cache: &Cache, depth: u32) -> Option { + if depth >= MAX_COLOR_SPACE_DEPTH { + warn!("colour spaces nest past the limit of {MAX_COLOR_SPACE_DEPTH} levels"); + return None; + } + + Self::new_inner(object, cache, depth) } - fn new_inner(object: Object<'_>, cache: &Cache) -> Option { + fn new_inner(object: Object<'_>, cache: &Cache, depth: u32) -> Option { if let Object::Name(name) = object { return Self::new_from_name(&name); } else if let Object::Array(color_array) = object { @@ -209,7 +221,7 @@ impl ColorSpaceType { }) .or_else(|| { dict.get::>(ALTERNATE) - .and_then(|o| Self::new(o, cache)) + .and_then(|o| Self::new(o, cache, depth + 1)) }) .or_else(|| match dict.get::(N) { Some(1) => Some(Self::DeviceGray), @@ -246,20 +258,20 @@ impl ColorSpaceType { return Some(Self::Lab(Lab::new(&lab_dict)?)); } INDEXED | I => { - return Some(Self::Indexed(Indexed::new(&color_array, cache)?)); + return Some(Self::Indexed(Indexed::new(&color_array, cache, depth)?)); } SEPARATION => { - return Some(Self::Separation(Separation::new(&color_array, cache)?)); + return Some(Self::Separation(Separation::new(&color_array, cache, depth)?)); } DEVICE_N => { - return Some(Self::DeviceN(DeviceN::new(&color_array, cache)?)); + return Some(Self::DeviceN(DeviceN::new(&color_array, cache, depth)?)); } PATTERN => { // PdfCraft patch (72): the family is already read. Reading a name here too took // the underlying space when it was a name or a reference. let cs = iter .next::>() - .and_then(|o| ColorSpace::new(o, cache)) + .and_then(|o| ColorSpace::within(o, cache, depth + 1)) .unwrap_or(ColorSpace::device_rgb()); return Some(Self::Pattern(cs)); } @@ -292,7 +304,12 @@ pub struct ColorSpace(Arc); impl ColorSpace { /// Create a new color space from the given object. pub(crate) fn new(object: Object<'_>, cache: &Cache) -> Option { - Some(Self(Arc::new(ColorSpaceType::new(object, cache)?))) + Self::within(object, cache, 0) + } + + /// A colour space named by another, `depth` levels down. + fn within(object: Object<'_>, cache: &Cache, depth: u32) -> Option { + Some(Self(Arc::new(ColorSpaceType::new(object, cache, depth)?))) } /// Create a new color space from the name. @@ -992,11 +1009,11 @@ pub(crate) struct Indexed { } impl Indexed { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip name let _ = iter.next::>()?; - let base_color_space = ColorSpace::new(iter.next::>()?, cache)?; + let base_color_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; let hival = iter.next::()?.min(u8::MAX as u32) as u8; let values = { @@ -1082,12 +1099,12 @@ fn eval_tint_transform( } impl Separation { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip `/Separation` let _ = iter.next::>()?; let name = iter.next::>()?; - let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; + let alternate_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; // PdfCraft patch (55): an unreadable tint transform keeps the space (see // `eval_tint_transform`). let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); @@ -1149,7 +1166,7 @@ pub(crate) struct DeviceN { } impl DeviceN { - fn new(array: &Array<'_>, cache: &Cache) -> Option { + fn new(array: &Array<'_>, cache: &Cache, depth: u32) -> Option { let mut iter = array.flex_iter(); // Skip `/DeviceN` let _ = iter.next::>()?; @@ -1160,7 +1177,7 @@ impl DeviceN { .collect::>(); let num_components = u8::try_from(names.len()).ok()?; let all_none = names.iter().all(|n| n.as_str() == "None"); - let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; + let alternate_space = ColorSpace::within(iter.next::>()?, cache, depth + 1)?; // PdfCraft patch (55): an unreadable tint transform keeps the space (see // `eval_tint_transform`). let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); From 4c815786c699f3ce745d2dc0a5cb0681fe0d2d76 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 19:53:17 +0100 Subject: [PATCH 076/562] M2: an ICC profile's fallback isn't kept with the profile The depth limit didn't hold. An ICC profile read was kept whole, with the alternate it fell back to, and a later read took it without counting its levels. A page selecting the last of a chain first built each space on the one before, and no read was deep: 20,000 levels, and the stack overflowed painting with it. In the other direction, a profile met at the limit kept the fallback it got there, and a read nearer the top got that instead of its alternate. Both come from keeping a result that depends on the depth. What's kept now is what the profile itself reads as, one level whatever it is, and the alternate is read where it's met. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 70 +++++++++++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/color.rs | 54 +++++++++++----------- 3 files changed, 98 insertions(+), 28 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 2214adac1b..1144375d51 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -977,6 +977,76 @@ fn colour_spaces_are_read_to_the_limit_of_their_nesting() { assert!(!green(32), "one past it"); } +/// A page with the colour space resources `spaces` and the other objects `objects`, drawn with +/// `content`. Objects 5 and 6 are tint transforms: the tint itself, and paper to green. +fn page_with_colour_spaces(spaces: &str, objects: &str, content: &str) -> super::RenderedPage { + use super::*; + let bytes = 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 << /ColorSpace << {spaces} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\ + 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\ + {objects}\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }) +} + +/// What an ICC profile reads as is kept. If what it falls back to were kept with it, a page +/// selecting the last of a chain first would build each space on the one before, and no read +/// would be deep: 20,000 levels past the limit, for whatever walks the space to paint with it. +#[test] +fn a_chain_built_on_spaces_already_read_ends() { + const LINKS: u32 = 20_000; + let selected: String = (0..LINKS).rev().map(|i| format!("/C{i} cs\n")).collect(); + let content = format!("1 0 0 rg 0 0 100 100 re f\n{selected}0 sc 100 100 100 100 re f"); + let spaces: String = (0..LINKS).map(|i| format!("/C{i} [/ICCBased {} 0 R] ", 10 + i)).collect(); + let around: [&dyn Fn(&str) -> String; 4] = [ + &|next| format!("[/Pattern {next}]"), + &|next| format!("[/Indexed {next} 1 <0011>]"), + &|next| format!("[/Separation /A {next} 5 0 R]"), + &|next| format!("[/DeviceN [/A] {next} 5 0 R]"), + ]; + for around in around { + let mut objects: String = (0..LINKS) + .map(|i| { + let alternate = around(&format!("[/ICCBased {} 0 R]", 11 + i)); + format!("{} 0 obj << /N 1 /Alternate {alternate} /Length 1 >> stream\nx\nendstream endobj\n", 10 + i) + }) + .collect(); + objects.push_str(&format!("{} 0 obj << /N 1 /Length 1 >> stream\nx\nendstream endobj\n", 10 + LINKS)); + let page = page_with_colour_spaces(&spaces, &objects, &content); + assert!(page.error.is_none(), "{}: {:?}", around("next"), page.error); + let start = ((150 * page.width as usize) + 50) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", around("next")); + } +} + +/// A profile met at the limit can't read its alternate there, which says nothing of a read nearer +/// the top: the space kept from the first read isn't what the second gets. +#[test] +fn a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top() { + let chain: String = (0..31).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect(); + let objects = format!( + "{chain}41 0 obj [/ICCBased 7 0 R] endobj\n\ + 7 0 obj << /N 1 /Alternate [/Separation /A /DeviceRGB 6 0 R] /Length 1 >> stream\nx\nendstream endobj\n" + ); + let page = page_with_colour_spaces("/Deep 10 0 R /Near 41 0 R", &objects, "/Deep cs /Near cs 1 scn 0 0 200 200 re f"); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(page.rgba.get(..4), Some(&[0, 255, 0, 255][..])); +} + /// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for /// the whole pair, found no pair there, and ended the array, so the image didn't draw at all. #[test] diff --git a/vendor/README.md b/vendor/README.md index 17d1695559..ae0755539c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -39,7 +39,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (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 | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting}` (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; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (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 | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (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) | | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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` | diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 2c9a9af49c..b93b69c73c 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -198,6 +198,9 @@ impl ColorSpaceType { // PdfCraft patch (48): cached by what the profile is read from (see // `IccProfileKey`), not by the stream dictionary's bytes alone. + // PdfCraft patch (74): what's kept is what the profile itself reads as. What + // it falls back to depends on how deep it was met, and kept with the profile + // a page could build a chain past the limit on spaces already read. let read = || { let data = icc_stream.raw_data(); let content = IccProfileKey::Content { @@ -206,40 +209,37 @@ impl ColorSpaceType { }; cache.get_or_insert_with(hash128(&content), || { cache.count_icc_profile_read(); - if let Some(decoded) = icc_stream.decoded().ok().as_ref() { - ICCProfile::new(decoded, num_components) - .map(|icc| { - // TODO: For SVG and PNG we can assume that the output color space is - // sRGB. If we ever implement PDF-to-PDF, we probably want to - // let the user pass the native color type and don't make this optimization - // if it's not sRGB. - if icc.is_srgb() { - Self::DeviceRgb - } else { - Self::ICCBased(icc) - } - }) - .or_else(|| { - dict.get::>(ALTERNATE) - .and_then(|o| Self::new(o, cache, depth + 1)) - }) - .or_else(|| match dict.get::(N) { - Some(1) => Some(Self::DeviceGray), - Some(3) => Some(Self::DeviceRgb), - Some(4) => Some(Self::DeviceCmyk), - _ => None, - }) - } else { - None - } + let decoded = icc_stream.decoded().ok()?; + Some(ICCProfile::new(&decoded, num_components).map(|icc| { + // TODO: For SVG and PNG we can assume that the output color space is + // sRGB. If we ever implement PDF-to-PDF, we probably want to + // let the user pass the native color type and don't make this optimization + // if it's not sRGB. + if icc.is_srgb() { + Self::DeviceRgb + } else { + Self::ICCBased(icc) + } + })) }) }; - return match icc_stream_object(&color_array, &icc_stream) { + let profile = match icc_stream_object(&color_array, &icc_stream) { Some(object) => { cache.get_or_insert_with(hash128(&IccProfileKey::Object(object)), read) } None => read(), }; + return profile? + .or_else(|| { + dict.get::>(ALTERNATE) + .and_then(|o| Self::new(o, cache, depth + 1)) + }) + .or_else(|| match dict.get::(N) { + Some(1) => Some(Self::DeviceGray), + Some(3) => Some(Self::DeviceRgb), + Some(4) => Some(Self::DeviceCmyk), + _ => None, + }); } CALCMYK => return Some(Self::DeviceCmyk), CALGRAY => { From 083191f970dd530b3a079cd0db93984f1ca4c155 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 21:58:22 +0100 Subject: [PATCH 077/562] M2.6: every filter of a run's images pays, whether or not it works Filters were charged only for what they handed a JBIG2 decoder, once they had handed it over: a chain that failed, or went past its image's limit, cost its time and nothing else, and images that weren't JBIG2 weren't charged at all. A 734 KB file of 200 images of Flate inside Flate took an image export 111 s, and that grew with the file. It now takes 12 s. Each byte read and each byte a stage decodes is now a step of the same allowance, paid as it happens, so no stage runs past what is left. That holds for every image read through ImageBudget, and a fax image pays for its rows and its code. JBIG2 globals pay when they have to be read, and kept ones don't. Optimize's two reads that went round the budget go through it, and merging fragments shares it. Optimize reads an image twice, to pack it and to resample it, so a refused image is now counted once a run rather than once a read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/automation/src/tools.rs | 2 +- crates/cos/README.md | 3 +- crates/cos/src/budget.rs | 251 +++++++++++++++++++++++++---- crates/cos/src/document/objects.rs | 28 ++-- crates/cos/src/object.rs | 8 + crates/create/README.md | 4 +- crates/create/src/extract.rs | 2 +- crates/create/src/tests.rs | 48 +++++- crates/filters/README.md | 2 + crates/filters/src/ccitt.rs | 52 ++++++ crates/filters/src/jbig2/decode.rs | 7 +- crates/filters/src/lib.rs | 212 ++++++++++++++++++++++-- crates/optimize/README.md | 8 +- crates/optimize/src/fragments.rs | 65 ++++++-- crates/optimize/src/images.rs | 157 +++++++++++++++--- crates/optimize/src/lib.rs | 8 +- crates/redact/README.md | 3 +- crates/redact/src/tests.rs | 22 +++ parity/acrobat-features.toml | 2 +- 19 files changed, 774 insertions(+), 110 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 91abb54061..b78acbe0f9 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1442,7 +1442,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). JBIG2 images past what one run may decode stay as they are, counted in images_refused. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/cos/README.md b/crates/cos/README.md index f3195a46dd..ff192d2e6e 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -94,5 +94,6 @@ recognisable in a diff and byte-stable when re-serialized. (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB, their filters undone once a document for the images that share them, up to 64 MiB kept, while their segments are decoded for every image, and `ImageBudget` for an operation over many images, - which share one allowance of JBIG2 decoding and are refused past it); + which share one allowance of decoding, filters and fax and JBIG2 decoders alike, and are refused + past it); DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index f982a39b45..b2ea9a82fd 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -6,15 +6,17 @@ use pdfcraft_filters::FilterError; use crate::{CosError, Document, ObjRef, Object, Stream}; -/// The steps of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) an operation over a -/// document's images may take whatever the file's size. A step is about a pixel, and takes 6 to 16 -/// ns to decode, so this is 2 to 5 minutes, and about as long again for what an operation does with -/// the pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again -/// to decode them, so this alone reads about 1,000 of them. +/// The steps of decoding an operation over a document's images may take whatever the file's size. +/// A step of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) is about a pixel, and takes 6 to +/// 16 ns, so this is 2 to 5 minutes, and about as long again for what an operation does with the +/// pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again to +/// decode them, so this alone reads about 1,000 of them. A byte into or out of a filter is a step +/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit. const IMAGE_WORK: u64 = 1 << 34; /// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a /// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored -/// in. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB. +/// in, and a stream of any filter this reads can be decoded about 8 times at Flate's best, 1,032 to +/// one. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB. const IMAGE_WORK_PER_BYTE: u64 = 1 << 13; /// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every @@ -30,14 +32,18 @@ type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usiz /// What one operation over a document's images may still spend decoding them: exporting them, /// optimizing them, clearing them for redaction. /// -/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it. -/// JBIG2 is what such a file would use: a few hundred bytes can ask for seconds of decoding, and an -/// image that shares `/JBIG2Globals` decodes them again. So those images share this allowance, and -/// past it they aren't decoded. A caller then treats them as it does an image it can't read, and +/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it: +/// a few hundred bytes of JBIG2 can ask for seconds of decoding, 4 KB of Flate inside Flate for a +/// gigabyte, and an image that shares `/JBIG2Globals` decodes them again. So images share this +/// allowance, whatever they do with what they decode and whether or not it works, and past it they +/// aren't decoded. A caller then treats them as it does an image it can't read, and /// [`ImageBudget::refused`] says how many there were. +/// +/// JPEG and JPEG 2000 data aren't charged: their decoders don't run here, and what one asks for +/// grows with its length, as the allowance does. #[derive(Debug)] pub struct ImageBudget { - jbig2_work: u64, + work: u64, refused: usize, } @@ -48,28 +54,46 @@ impl ImageBudget { Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE))) } - /// An allowance of `jbig2_work` steps of JBIG2 decoding. - pub fn with_work(jbig2_work: u64) -> Self { - Self { jbig2_work, refused: 0 } + /// An allowance of `work` steps of decoding. + pub fn with_work(work: u64) -> Self { + Self { work, refused: 0 } } /// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance. pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { - let decoded = doc.decoded_image_spending(s, max, &mut self.jbig2_work); - if decoded.is_err() && self.jbig2_work == 0 && matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { - self.refused += 1; - } + let decoded = doc.decoded_image_spending(s, max, &mut self.work); + self.count_refused(s, &decoded); decoded } - /// The JBIG2 images refused for lack of allowance. + /// Image `s` through its filters alone, charged to the allowance: [`Stream::decoded_within`] + /// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an + /// image that has no fax or JBIG2 decoder to go through. + pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { + let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work); + self.count_refused(s, &decoded); + decoded + } + + /// An image that failed with nothing left was refused, unless it has a filter that costs + /// nothing here, which fails whatever is left. + fn count_refused(&mut self, s: &Stream, decoded: &Result, CosError>) { + use pdfcraft_filters::Filter; + let charged = + s.readable_filters().is_ok_and(|chain| chain.iter().all(|(f, _)| f.is_decodable() || matches!(f, Filter::Jbig2 | Filter::CcittFax))); + if decoded.is_err() && self.work == 0 && charged { + self.refused += 1; + } + } + + /// The decodes refused for lack of allowance: an image read twice over is counted twice. pub fn refused(&self) -> usize { self.refused } - /// The steps of JBIG2 decoding still allowed. + /// The steps of decoding still allowed. pub fn work_left(&self) -> u64 { - self.jbig2_work + self.work } } @@ -216,8 +240,9 @@ mod image_tests { Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()) } - /// Each JBIG2 image spends what it took from one allowance, and those past it are refused and - /// counted. Other images aren't charged, and a failure of theirs isn't counted as one of these. + /// Each image spends what it took from one allowance, and those past it are refused and + /// counted, whatever their filters. What can't be decoded here costs nothing, and fails + /// without being one of these. #[test] fn the_images_of_an_operation_share_its_allowance() { let doc = Document::new_empty(); @@ -236,12 +261,180 @@ mod image_tests { assert!(budget.decode(&doc, &image, 1 << 20).is_err()); assert_eq!(budget.refused(), refused); } - let flate = Stream::flate(Dict::new(), b"samples"); - assert_eq!(budget.decode(&doc, &flate, 1 << 20).unwrap(), b"samples", "not JBIG2: no charge"); + assert!(budget.decode(&doc, &Stream::flate(Dict::new(), b"samples"), 1 << 20).is_err()); + assert!(budget.decode(&doc, &Stream::from_raw(Dict::new(), b"samples".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.refused(), 4, "other images share it"); + for name in ["NoSuchFilter", "DCTDecode", "JPXDecode"] { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name(name)); + assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.refused(), 4, "{name} isn't one of these"); + } let mut dict = Dict::new(); - dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter")); + dict.set(b"Filter".to_vec(), Object::Int(5)); assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); - assert_eq!(budget.refused(), 2, "not JBIG2: not one of these"); + assert_eq!(budget.refused(), 4, "a /Filter that can't be read isn't either"); + } + + /// What a file of images each inflating a megabyte asks for: 4 KB of Flate inside Flate can ask + /// for a gigabyte, and these aren't JBIG2, so without the allowance nothing would stop them. + #[test] + fn filter_bombs_stop_at_the_allowance() { + let doc = Document::new_empty(); + let samples = vec![0; 1 << 20]; + let inner = pdfcraft_filters::encode_flate(&samples); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("FlateDecode")])); + let bomb = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&inner)); + let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64; + let mut budget = ImageBudget::with_work(2 * cost + 1); + for left in [cost + 1, 1] { + assert_eq!(budget.decode(&doc, &bomb, 1 << 20).unwrap(), samples); + assert_eq!(budget.work_left(), left); + } + assert!(budget.decode(&doc, &bomb, 1 << 20).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1), "the first it can't pay for ends it"); + } + + /// Filters in front of a JBIG2 decoder pay as they run, on top of what the decoder takes, and + /// one that fails has still paid: nothing about the JBIG2 data is known by then, and each image + /// of a file can do this. + #[test] + fn filters_in_front_of_jbig2_pay_whether_or_not_they_work() { + let doc = Document::new_empty(); + let alone = jbig2(None); + let mut budget = ImageBudget::with_work(u64::MAX); + let samples = budget.decode(&doc, &alone, 1 << 20).unwrap(); + let decoding = u64::MAX - budget.work_left(); + let behind = |raw: Vec| { + let mut dict = alone.dict.clone(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("JBIG2Decode")])); + Stream::from_raw(dict, raw) + }; + + let image = behind(pdfcraft_filters::encode_flate(&alone.raw)); + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples); + assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64); + + // Past this image's limit: an error, with what finding out took paid and the rest left. + let bomb = behind(pdfcraft_filters::encode_flate(&vec![0; 1 << 16])); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode(&doc, &bomb, 1000).is_err()); + assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + 1000); + assert_eq!(budget.refused(), 0, "that image's limit isn't the run's"); + + // A small allowance is a limit too: it stops there, and the image is refused. + let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + 10); + assert!(budget.decode(&doc, &bomb, 1 << 20).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + } + + /// Globals that are read again for every image pay every time, and fail having paid. Kept + /// ones have no filters left to undo, so they pay for being read and nothing more. + #[test] + fn globals_pay_for_their_filters_each_time_they_are_decoded() { + let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let raw = pdfcraft_filters::encode_flate(&skipped); + let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + }; + let spent = |doc: &Document, s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(doc, s, 1 << 20).unwrap(); + u64::MAX - budget.work_left() + }; + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(dict, raw.clone()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let image = doc.add(Object::Stream(jbig2(Some(Object::Dict(parms))))); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap(); + + // Added in memory, so not kept: the filters are undone for each read. + let s = stream(&doc, image); + let first = spent(&doc, &s); + assert_eq!(spent(&doc, &s), first, "not kept, so paid for again"); + + // Read from a file, so kept: the second read has no filters to undo. + let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let doc = Document::open(std::sync::Arc::new(file)).unwrap(); + let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap(); + let s = stream(&doc, image); + let filters = (raw.len() + skipped.len()) as u64; + assert_eq!(spent(&doc, &s), first, "the first read undoes them"); + assert_eq!(spent(&doc, &s), first - filters, "kept"); + + // Globals that fail pay too, with the image not refused: these go past their own limit. + let over = pdfcraft_filters::encode_flate(&vec![0; crate::MAX_JBIG2_GLOBALS + 1]); + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let globals = doc.add(Object::Stream(Stream::from_raw(dict, over.clone()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let image = jbig2(Some(Object::Dict(parms))); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode(&doc, &image, 1 << 20).unwrap_err().to_string().contains("globals")); + assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64); + assert_eq!(budget.refused(), 0); + } + + /// A fax image pays for what comes before its decoder and for the rows it asks for, and past + /// the allowance it is refused like any other. + #[test] + fn fax_images_share_the_allowance() { + let doc = Document::new_empty(); + let (w, h) = (4096u32, 64u32); + let coded = pdfcraft_filters::encode_g4(&vec![0xFF; 512 * h as usize], w, h).unwrap(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(w))); + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(i64::from(w))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(h))); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded)); + let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64; + let mut budget = ImageBudget::with_work(cost); + assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize); + assert_eq!(budget.work_left(), 0, "exactly that"); + assert!(budget.decode(&doc, &image, 1 << 20).is_err()); + assert_eq!(budget.refused(), 1); + } + + /// An image read through its filters alone reads parameters given by reference, is charged, and + /// is refused like the others. It isn't given to a decoder. + #[test] + fn filters_alone_are_charged_too() { + let mut doc = Document::new_empty(); + let p = pdfcraft_filters::Params { predictor: 12, columns: 4, ..pdfcraft_filters::Params::default() }; + let rows = vec![7u8; 400]; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(4)); + let parms = doc.add(Object::Dict(parms)); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + let raw = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &rows).unwrap(); + let image = Stream::from_raw(dict, raw); + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(budget.decode_filters(&doc, &image, 1 << 20).unwrap(), rows); + let cost = u64::MAX - budget.work_left(); + // The tag byte of each of its 100 rows counts, which the predictor takes off. + assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64); + let mut budget = ImageBudget::with_work(cost - 1); + assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode_filters(&doc, &jbig2(None), 1 << 20).is_err()); + assert_eq!(budget.work_left(), u64::MAX, "no decoder here, so nothing was read"); } /// The images of a file share its globals, so an image refused doesn't read them: this one's @@ -258,7 +451,7 @@ mod image_tests { let read = ImageBudget::with_work(u64::MAX).decode(&doc, &image, 1 << 20).unwrap_err().to_string(); assert!(read.contains("globals"), "{read}"); let refused = ImageBudget::with_work(0).decode(&doc, &image, 1 << 20).unwrap_err().to_string(); - assert!(refused.contains("more JBIG2 decoding than"), "{refused}"); + assert!(refused.contains("more image decoding than"), "{refused}"); } /// A longer file gets more, by its length, and an empty one the fixed part. @@ -276,6 +469,8 @@ mod image_tests { const PAGE: u64 = 17_000_000; const _: () = assert!(IMAGE_WORK >= 1_000 * PAGE); const _: () = assert!(IMAGE_WORK_PER_BYTE * 2_100 >= PAGE); + // And that a stream can be decoded about 8 times at Flate's best, 1,032 to one, and the byte in. + const _: () = assert!(IMAGE_WORK_PER_BYTE >= 7 * 1_033); } #[cfg(test)] diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index d9f8026a61..9d47fd9bd3 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -298,18 +298,19 @@ impl Document { self.decoded_image_spending(s, max, &mut unbounded) } - /// [`Document::decoded_image_within`], a JBIG2 image spending from `work`, what an operation - /// may still do across its images ([`ImageBudget`](crate::ImageBudget)). With nothing left a - /// JBIG2 image is refused before its globals are read. + /// [`Document::decoded_image_within`], spending from `work`, what an operation may still do + /// across its images ([`ImageBudget`](crate::ImageBudget)): each filter as it runs, a JBIG2 + /// image's globals when they have to be read, and the fax or JBIG2 decoder. With nothing left an + /// image is refused before anything is read, its globals included. pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); let chain = s.readable_filters().map_err(filter)?; // The filters before the image codec, such as ASCII85Decode, are undone first. - let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)]| match before { + let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)], work: &mut u64| match before { [] => Ok(Cow::Borrowed(&s.raw[..])), - _ => pdfcraft_filters::decode_tolerant(before, &s.raw, max).map(|(data, _)| Cow::Owned(data)).map_err(filter), + _ => pdfcraft_filters::decode_tolerant_within(before, &s.raw, max, work).map(Cow::Owned).map_err(filter), }; // `/Width` and `/Height` are read as given directly, as every caller reads them first: one // given by reference has the image refused there, before it gets here. @@ -319,11 +320,11 @@ impl Document { return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into())); }; // Nothing to read for what can't be decoded: the images of a file share its globals. - let globals = if *work == 0 { None } else { self.jbig2_globals(s)? }; - let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before)? }; + let globals = if *work == 0 { None } else { self.jbig2_globals(s, work)? }; + let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? }; return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter); } - let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; + let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) }; // As many rows as the image has: a /Rows below /Height would cut the picture short, and // rows past /Height aren't drawn. let p = pdfcraft_filters::CcittParams { rows: 0, ..p }; @@ -332,8 +333,8 @@ impl Document { } let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0)); - let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?; - pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter) + let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before), work)?; + pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter) } /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) @@ -347,8 +348,9 @@ impl Document { /// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they /// are another object, which can change while this one doesn't), has its filters undone once a /// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That - /// saves the filters only: the segments it holds are decoded again for every image. - fn jbig2_globals(&self, s: &Stream) -> Result>>, CosError> { + /// saves the filters only: the segments it holds are decoded again for every image. Undoing + /// the filters spends from `work`, so globals read for every image pay every time. + fn jbig2_globals(&self, s: &Stream, work: &mut u64) -> Result>>, CosError> { let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) }; let held = match entry { Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num), @@ -360,7 +362,7 @@ impl Document { let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) }; let readable = self.with_direct_parms(g); let held = held.filter(|_| matches!(readable, Cow::Borrowed(_))); - let mut data = readable.decoded_within(MAX_JBIG2_GLOBALS).map_err(|e| { + let mut data = readable.decoded_spending(MAX_JBIG2_GLOBALS, work).map_err(|e| { let detail = match e { CosError::Filter(detail) => detail, e => e.to_string(), diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 400fc29962..5d437faa82 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -306,6 +306,14 @@ impl Stream { self.decode_chain(max, decode) } + /// [`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> { + let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string()); + let chain = self.readable_filters().map_err(filter)?; + pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter) + } + /// The stream decoded through its filters by `decode`, or copied when it has none, within /// `max` and [`MAX_DECODED`]. fn decode_chain( diff --git a/crates/create/README.md b/crates/create/README.md index 7b629a56e4..46281772e4 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -32,8 +32,8 @@ other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, a whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are. -Past what one export may decode of JBIG2, which its images share, an image is skipped, and so is one whose mask is past it, which -would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that +Past what one export may decode, which its images share, filters and fax and JBIG2 decoders alike (`ImageBudget`; JPEG data isn't +counted), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that is more, since they stay in memory until it ends. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 9631aa0c91..6019a0d093 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -165,7 +165,7 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) }; // Without its mask the image would come out opaque, showing what the mask hides. if budget.refused() > refused { - return Err("more JBIG2 decoding than one operation may do".into()); + return Err("more image decoding than one operation may do".into()); } let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 55d0612637..17197b91e9 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2264,7 +2264,7 @@ fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() { let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(cost), usize::MAX); assert_eq!(export.images.len(), 1); assert_eq!(export.skipped.len(), 1); - assert!(export.skipped[0].2.contains("more JBIG2 decoding than"), "{:?}", export.skipped); + assert!(export.skipped[0].2.contains("more image decoding than"), "{:?}", export.skipped); // What an export holds is bounded too: the files are all in memory until it ends. let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(u64::MAX), 1); assert_eq!(export.images.len(), 1, "the first goes past it"); @@ -2276,6 +2276,42 @@ fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() { assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err()); } +/// An image that isn't JBIG2 shares the allowance too: a file of filter bombs would hold an export +/// as long as one of JBIG2. What isn't decoded here, a JPEG, is still exported. +#[test] +fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let image = |filter: &str, raw: Vec| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name(filter)); + Stream::from_raw(dict, raw) + }; + let flate = image("FlateDecode", pdfcraft_filters::encode_flate(&[0x80; 4])); + let mut cost = ImageBudget::with_work(u64::MAX); + cost.decode(&Document::new_empty(), &flate, 1 << 20).unwrap(); + let cost = u64::MAX - cost.work_left(); + + let mut doc = Document::new_empty(); + let (a, b) = (doc.add(Object::Stream(flate.clone())), doc.add(Object::Stream(flate))); + let mut budget = ImageBudget::with_work(cost); + assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok()); + let refused = crate::extract::image_file_within(&doc, b, &mut budget).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); + assert_eq!(budget.refused(), 1); + + let jpeg = image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])); + let mut bytes = Vec::new(); + jpeg.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Jpeg).unwrap(); + let jpeg = doc.add(Object::Stream(image("DCTDecode", bytes))); + assert!(crate::extract::image_file_within(&doc, jpeg, &mut budget).is_ok(), "nothing charged"); + assert_eq!(budget.refused(), 1); +} + /// A mask past the allowance can't be left out as an unreadable one is, or the image comes out /// opaque, showing what the mask hides: the image goes, with the reason. #[test] @@ -2290,17 +2326,21 @@ fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() { mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); let mut doc = Document::new_empty(); let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()))); + // A JPEG, which nothing charges, so only its mask can be what stops it. + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(1, 1, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); let mut image = Dict::new(); image.set(b"Subtype".to_vec(), Object::name("Image")); image.set(b"Width".to_vec(), Object::Int(1)); image.set(b"Height".to_vec(), Object::Int(1)); image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); - image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"Filter".to_vec(), Object::name("DCTDecode")); image.set(b"SMask".to_vec(), Object::Ref(mask)); - let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); + let image = doc.add(Object::Stream(Stream::from_raw(image, jpeg))); assert!(crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok()); let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err(); - assert!(refused.contains("more JBIG2 decoding than"), "{refused}"); + assert!(refused.contains("more image decoding than"), "{refused}"); } /// A large honest file keeps its images: what an export may hold grows with the file past the fixed diff --git a/crates/filters/README.md b/crates/filters/README.md index 59403e4a2b..dcf070c4c1 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -21,6 +21,7 @@ decode(chain, data, max_output) -> Result, FilterError> decode_counted(chain, data, max_output) -> Result<(Vec, usize /*decoded*/), (FilterError, usize /*decoded*/)> decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError> decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> +decode_tolerant_within(chain, data, max_output, work: &mut u64) -> Result, FilterError> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS @@ -35,6 +36,7 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first. +- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_jbig2_within` and `decode_ccitt_within` are, all three spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. What `work` can't pay for spends all of it, and gives `Unsupported`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs index 7c188d701c..a839c24c02 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -123,6 +123,22 @@ pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result Result, FilterError> { + let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows)); + let stride = u64::try_from(p.columns).unwrap_or(0).div_ceil(8); + crate::pay(work, stride.saturating_mul(u64::from(rows)).saturating_add((data.len() as u64).saturating_mul(CODE_WORK)))?; + decode_ccitt(data, p, max_rows) +} + /// Whether CCITT fax `data` starts with an end-of-line code: a 1 after 11 zeros or more, fill /// included. T.4 sends one before the first row when it sends them at all, and no other code starts /// with more than eight zeros. @@ -189,6 +205,42 @@ impl hayro_ccitt::Decoder for Rows { mod tests { use super::*; + /// A run pays for the rows an image asks for and the code it reads, before any of it is + /// decoded: rows the data doesn't hold are paid for too, as a few bytes can ask for any number. + #[test] + fn a_run_of_images_pays_for_the_rows_it_asks_for() { + let (w, h) = (4096u32, 64u32); + let stride = (w as usize).div_ceil(8); + let data = encode_g4(&vec![0xFF; stride * h as usize], w, h).unwrap(); + let p = CcittParams { k: -1, columns: i64::from(w), ..CcittParams::default() }; + let whole = decode_ccitt(&data, &p, h).unwrap(); + let cost = (whole.len() + 4 * data.len()) as u64; + let mut work = cost; + assert_eq!(decode_ccitt_within(&data, &p, h, &mut work).unwrap(), whole, "exactly enough"); + assert_eq!(work, 0); + let mut work = cost + 7; + decode_ccitt_within(&data, &p, h, &mut work).unwrap(); + assert_eq!(work, 7, "only what it took"); + let mut work = cost - 1; + let refused = decode_ccitt_within(&data, &p, h, &mut work).unwrap_err(); + assert_eq!(refused.to_string(), crate::work_spent().to_string(), "a step short"); + assert_eq!(work, 0, "spends all that was left"); + // Nothing left: not read at all, so bad columns aren't reported as bad columns. + let bad = CcittParams { columns: 0, ..p }; + assert!(decode_ccitt_within(&data, &bad, h, &mut 0).is_err_and(|e| e.to_string() == crate::work_spent().to_string())); + // No code at all, for the same rows. + let mut work = u64::MAX; + decode_ccitt_within(&[], &p, h, &mut work).unwrap(); + assert_eq!(u64::MAX - work, whole.len() as u64); + // `/Rows` below what is asked for, and no rows. + let mut work = u64::MAX; + decode_ccitt_within(&data, &CcittParams { rows: 3, ..p }, h, &mut work).unwrap(); + assert_eq!(u64::MAX - work, (3 * stride + 4 * data.len()) as u64); + let mut work = u64::MAX; + decode_ccitt_within(&data, &p, 0, &mut work).unwrap(); + assert_eq!(u64::MAX - work, 4 * data.len() as u64); + } + /// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black. pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec> { struct Rows(Vec>, Vec, usize); diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index fe3148ff10..9a3ed9fbde 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -38,11 +38,10 @@ pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, ro if rows == 0 { return Ok(Vec::new()); } - let spent = || FilterError::Unsupported("more JBIG2 decoding than one operation may do".into()); if *work == 0 { - return Err(spent()); + return Err(crate::work_spent()); } - hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| spent())?; + hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| crate::work_spent())?; let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail }; let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?; let (width, height) = (image.width(), image.height()); @@ -59,7 +58,7 @@ pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, ro } let mut out = Rows::new(width, height, false); // Stopped by what was left, it isn't a damaged image, whatever the decoder calls it. - image.decode_within(&mut out, work).map_err(|e| if *work == 0 { spent() } else { corrupt(e.to_string()) })?; + image.decode_within(&mut out, work).map_err(|e| if *work == 0 { crate::work_spent() } else { corrupt(e.to_string()) })?; Ok(out.out) } diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 6dc4813f78..bcafc132bd 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -28,7 +28,7 @@ mod runlength; use std::borrow::Cow; -pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, decode_ccitt_within, encode_g4}; pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. @@ -91,7 +91,8 @@ impl Filter { matches!(self, Filter::Dct | Filter::Jpx | Filter::Jbig2 | Filter::CcittFax) } - fn is_decodable(&self) -> bool { + /// True for the general-purpose filters, which a chain can hold ([`decode`]). + pub fn is_decodable(&self) -> bool { matches!(self, Filter::Flate | Filter::Lzw | Filter::AsciiHex | Filter::Ascii85 | Filter::RunLength) } } @@ -143,13 +144,33 @@ impl Failure { pub(crate) type Step = Result, Failure>; +/// What decoding says when a run of images has nothing left to spend on it. +pub(crate) fn work_spent() -> FilterError { + FilterError::Unsupported("more image decoding than one operation may do".into()) +} + +/// Takes `steps` from `work`, what a run of images may still cost. What it can't pay for spends +/// all of it. +pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> { + match work.checked_sub(steps) { + Some(left) => { + *work = left; + Ok(()) + } + None => { + *work = 0; + Err(work_spent()) + } + } +} + /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give /// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than /// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { - run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e) + run(chain, data, max_output, None, true).map(|(v, _, _)| v).map_err(|(e, _)| e) } /// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages @@ -157,42 +178,83 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res /// bytes, on success and on failure. A stage's output counts even when a later stage or its /// predictor shrinks it. pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, usize), (FilterError, usize)> { - run(chain, data, max_output, true, true).map(|(v, _, spent)| (v, spent)) + run(chain, data, max_output, Some(max_output), true).map(|(v, _, spent)| (v, spent)) } /// Like [`decode`], but on corrupt data returns what was decoded before the error and /// reports `partial = true` (the behaviour viewers expect). The remaining filters of the /// chain still run on the partial data. Limit and unsupported errors are still errors. pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> { - run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) + run(chain, data, max_output, None, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) } /// [`decode_tolerant`] bounded and counted as [`decode_counted`] is. pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool, usize), (FilterError, usize)> { - run(chain, data, max_output, false, true) + run(chain, data, max_output, Some(max_output), false) +} + +/// [`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, and one for each byte a stage +/// decodes, so a chain that fails, or a stage that goes past `max_output`, has paid for what it did. +/// No stage decodes past what is left: a chain that `work` can't pay for spends all of it and stops +/// there, so with nothing left nothing is read. A chain that can't be decoded at all costs nothing, +/// whatever is left, so what an image's filters are is known from the error. +/// +/// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`. +pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { + decodable(chain)?; + pay(work, data.len() as u64)?; + if chain.is_empty() { + return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) }; + } + let left = usize::try_from(*work).unwrap_or(usize::MAX); + match run(chain, data, max_output, Some(left), false) { + Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out), + // The stage stopped at the lower of the two, which took as much to find out. + Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => { + pay(work, (decoded as u64).saturating_add(max_output as u64))?; + Err(FilterError::LimitExceeded(max_output)) + } + Err((FilterError::LimitExceeded(_), _)) => { + *work = 0; + Err(work_spent()) + } + Err((e, decoded)) => pay(work, decoded as u64).and(Err(e)), + } } /// Longest filter chain decoded. Real streams use one to three filters; every stage may produce /// up to the output limit, so the chain length multiplies the work. pub const MAX_CHAIN: usize = 8; -/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and -/// the bytes decoded before it. `max` bounds each stage, or with `cumulative` all of them -/// together. -fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool, cumulative: bool) -> Result<(Vec, bool, usize), (FilterError, usize)> { +/// Whether a chain is one [`run`] takes, and if not, the error. +fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> { if chain.len() > MAX_CHAIN { - return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0)); + return Err(FilterError::Unsupported(format!("a chain of {} filters", chain.len()))); } - if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { - return Err((FilterError::Unsupported(f.name().to_owned()), 0)); + match chain.iter().find(|(f, _)| !f.is_decodable()) { + Some((f, _)) => Err(FilterError::Unsupported(f.name().to_owned())), + None => Ok(()), } +} + +/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and +/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together. +fn run( + chain: &[(Filter, Params)], + data: &[u8], + max: usize, + total: Option, + strict: bool, +) -> Result<(Vec, bool, usize), (FilterError, usize)> { + decodable(chain).map_err(|e| (e, 0))?; let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; let mut spent = 0usize; for (filter, params) in chain { #[cfg(test)] STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); - let limit = if cumulative { max.saturating_sub(spent) } else { max }; + let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent))); let (step, work) = decode_one(filter, params, &cur, limit); spent = spent.saturating_add(work); cur = Cow::Owned(match step { @@ -428,6 +490,9 @@ mod tests { assert_eq!(decode(&chain, &data, max).map(|d| d.len()).ok(), Some(1 << 19)); assert!(matches!(decode_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max)); assert_eq!(decode_counted(&chain, &data, 2 << 20).map(|(_, spent)| spent).ok(), Some((1 << 20) + (1 << 19))); + let tolerant = decode_tolerant_counted(&chain, &data, 2 << 20).map(|(out, partial, spent)| (out.len(), partial, spent)); + assert_eq!(tolerant.ok(), Some((1 << 19, false, (1 << 20) + (1 << 19)))); + assert!(matches!(decode_tolerant_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max)); } /// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it, @@ -446,4 +511,123 @@ mod tests { assert!(matches!(e, FilterError::Corrupt { .. }), "{e}"); assert_eq!(spent, bad.len()); } + + fn refused(e: &FilterError) -> bool { + e.to_string() == work_spent().to_string() + } + + /// A chain pays for what it reads and what each stage decodes, down to the byte: Flate inflates + /// hex that ASCIIHex halves, so a stage counts when a later one shrinks it. + #[test] + fn a_chain_spends_down_to_the_byte() { + let hex = b"ab".repeat(1 << 10); + let data = encode_flate(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + let whole = decode(&chain, &data, 1 << 20).unwrap(); + let cost = (data.len() + hex.len() + whole.len()) as u64; + let mut work = u64::MAX; + assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole); + assert_eq!(u64::MAX - work, cost); + let mut work = cost; + assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole, "exactly enough"); + assert_eq!(work, 0); + let mut work = cost + 7; + decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(); + assert_eq!(work, 7, "only what it took"); + let mut work = cost - 1; + assert!(refused(&decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap_err()), "a step short"); + assert_eq!(work, 0, "spends all that was left"); + // Each stage has `max_output` to itself, as in a plain decode: together they are past it. + let mut work = u64::MAX; + assert_eq!(decode_tolerant_within(&chain, &data, hex.len(), &mut work).unwrap(), whole); + } + + /// No stage runs past what is left: the first stage of this bomb stops there, so the second + /// isn't given anything to read, and with too little to read it nothing runs at all. + #[test] + fn a_chain_stops_at_what_is_left() { + let inner = encode_flate(&vec![0; 1 << 20]); + let bomb = encode_flate(&inner); + let chain = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())]; + let read = |work: u64| { + super::STAGE_BYTES.with(|c| c.set(0)); + let mut work = work; + assert!(refused(&decode_tolerant_within(&chain, &bomb, usize::MAX, &mut work).unwrap_err())); + assert_eq!(work, 0); + super::STAGE_BYTES.with(|c| c.get()) + }; + assert_eq!(read((bomb.len() + inner.len()) as u64 + 10), bomb.len() + inner.len(), "the second stage stops"); + assert_eq!(read(bomb.len() as u64 + 10), bomb.len(), "the first stops, and the second never runs"); + assert_eq!(read(bomb.len() as u64 - 1), 0, "it can't pay to be read"); + } + + /// Past `max_output` the chain has failed, and hasn't run out of anything: it pays what it took + /// to find out, and what is left stays. Whichever limit a stage stops at is the one reported. + #[test] + fn a_stage_past_its_limit_pays_for_finding_out() { + let data = encode_flate(&vec![0; 1 << 16]); + let chain = [(Filter::Flate, Params::default())]; + let mut work = u64::MAX; + assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000)))); + assert_eq!(u64::MAX - work, data.len() as u64 + 1000); + // The later stage of two, which pays for the first as well. + let bomb = encode_flate(&data); + let two = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())]; + let mut work = u64::MAX; + assert!(matches!(decode_tolerant_within(&two, &bomb, data.len(), &mut work), Err(FilterError::LimitExceeded(_)))); + assert_eq!(u64::MAX - work, (bomb.len() + 2 * data.len()) as u64); + // Both limits at once: a run that can't go on is what a caller needs to hear. + let mut work = data.len() as u64 + 1000; + assert!(refused(&decode_tolerant_within(&chain, &data, 1000, &mut work).unwrap_err())); + assert_eq!(work, 0); + let mut work = data.len() as u64 + 1001; + assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000)))); + assert_eq!(work, 1, "the image's limit, with a step to spare"); + } + + /// What a damaged stream decoded is paid for, however the decode ends. + #[test] + fn a_chain_that_fails_has_paid_for_what_it_did() { + let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect(); + let mut cut = encode_flate(&data); + cut.truncate(cut.len() / 2); + let chain = [(Filter::Flate, Params::default())]; + let (partial, _) = decode_tolerant(&chain, &cut, 1 << 20).unwrap(); + assert!(!partial.is_empty()); + let mut work = u64::MAX; + assert_eq!(decode_tolerant_within(&chain, &cut, 1 << 20, &mut work).unwrap(), partial); + assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64); + // A predictor that comes to nothing, after all of this was inflated for it. + let p = Params { predictor: 99, ..Params::default() }; + let data = encode_flate(&vec![0; 1 << 16]); + let mut work = u64::MAX; + assert!(decode_tolerant_within(&[(Filter::Flate, p)], &data, 1 << 20, &mut work).unwrap().is_empty()); + assert_eq!(u64::MAX - work, data.len() as u64 + (1 << 16)); + } + + /// With nothing left nothing is read. What can't be decoded at all never costs anything, and + /// says so whatever is left. Without filters, what is read is the data. + #[test] + fn what_a_chain_is_shows_whatever_is_left() { + let flate = [(Filter::Flate, Params::default())]; + let mut work = 0; + assert!(refused(&decode_tolerant_within(&flate, b"garbage", 1 << 20, &mut work).unwrap_err())); + let long = vec![(Filter::Flate, Params::default()); MAX_CHAIN + 1]; + for chain in [vec![(Filter::Dct, Params::default())], vec![(Filter::Jbig2, Params::default())], long] { + for left in [0, 100] { + let mut work = left; + let e = decode_tolerant_within(&chain, b"data", 1 << 20, &mut work).unwrap_err(); + assert!(matches!(e, FilterError::Unsupported(_)) && !refused(&e), "{e}"); + assert_eq!(work, left); + } + } + let mut work = 100; + assert_eq!(decode_tolerant_within(&[], b"data", 4, &mut work).unwrap(), b"data"); + assert_eq!(work, 96); + assert!(matches!(decode_tolerant_within(&[], b"data", 3, &mut work), Err(FilterError::LimitExceeded(3)))); + assert_eq!(work, 92); + let mut work = 3; + assert!(refused(&decode_tolerant_within(&[], b"data", 4, &mut work).unwrap_err())); + assert_eq!(work, 0); + } } diff --git a/crates/optimize/README.md b/crates/optimize/README.md index d6083733da..a30e3b197b 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -100,9 +100,11 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 - images, with bounded shared globals. All of a run's JBIG2 images share one allowance of - decoding (`pdfcraft_cos::ImageBudget`): about 1,000 scanned pages' worth, plus more for each - byte of the file. Images past it stay as they are, and `Report::images_refused` counts them. + images, with bounded shared globals. All of a run's images share one allowance of decoding + (`pdfcraft_cos::ImageBudget`), filters and fax and JBIG2 decoders alike, JPEG data aside: about + 1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream + about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused` + counts them. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index c069de2f95..55aca519f0 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -152,7 +152,7 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize { } /// The run's pixels as one image, top row first: `(pixels, width, height)`. -fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u32)> { +fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]); let width = run.iter().map(|f| f.width).max()?; // Pixel rows per unit of height: the finest fragment's. @@ -176,7 +176,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 } let obj = doc.get(f.image); let Object::Stream(s) = &*obj else { return None }; - let px = pixels(doc, s, n)?; + let px = pixels(doc, s, n, budget)?; let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); } @@ -230,7 +230,13 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> } /// Merge the fragment runs of one page; returns how many fragments were merged. -fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { +fn merge_page( + doc: &mut Document, + page: ObjRef, + report: &mut Report, + progress: &mut dyn FnMut(Stage) -> bool, + images: &mut ImageBudget, +) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; // A page whose content isn't all known is left alone: rewriting it would drop the rest. let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; @@ -286,7 +292,7 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & if !progress(Stage::Fragments) { return Err(OptimizeError::Cancelled); } - let Some((px, w, h)) = merge(doc, &run, n) else { + let Some((px, w, h)) = merge(doc, &run, n, images) else { out.extend(ops.get(i..j).unwrap_or_default().iter().cloned()); i = j; continue; @@ -320,9 +326,15 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & Ok(merged) } -pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<(), OptimizeError> { +pub(crate) fn run( + doc: &mut Document, + pages: &[ObjRef], + report: &mut Report, + progress: &mut dyn FnMut(Stage) -> bool, + budget: &mut ImageBudget, +) -> Result<(), OptimizeError> { for p in pages { - report.fragments_merged += merge_page(doc, *p, report, progress)?; + report.fragments_merged += merge_page(doc, *p, report, progress, budget)?; } Ok(()) } @@ -343,7 +355,42 @@ mod tests { Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, ]; - assert!(merge(&Document::new_empty(), &run, 3).is_none()); + assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + } + + /// Merging reads the images the pass after it reads, from the same allowance: with nothing + /// left the strips stay as they are, and the image that couldn't be read is counted. + #[test] + fn merging_spends_the_runs_allowance() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(8)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mut xobjects = Dict::new(); + let mut drawn = String::new(); + for row in 0..8 { + let r = doc.add(Object::Stream(Stream::flate(image.clone(), &[row * 30; 8]))); + xobjects.set(format!("I{row}").into_bytes(), Object::Ref(r)); + drawn.push_str(&format!("q 80 0 0 10 0 {} cm /I{row} Do Q\n", 70 - 10 * i32::from(row))); + } + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes()))); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + let mut budget = ImageBudget::with_work(0); + assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0); + assert_eq!(budget.refused(), 1); + let mut budget = ImageBudget::with_work(u64::MAX); + assert_eq!(merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 8); + assert!(budget.work_left() < u64::MAX); } /// Many images drawn on top of each other are not one picture; looking for runs among them reads @@ -370,7 +417,7 @@ mod tests { page.set(b"Resources".to_vec(), Object::Dict(resources)); let page = doc.add(Object::Dict(page)); GROUP_READS.with(|c| c.set(0)); - let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX)); assert_eq!(merged.ok(), Some(0)); let reads = GROUP_READS.with(|c| c.get()); assert!(reads <= 40 * 4 * groups, "{reads} group reads"); diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index cd2317590d..5f2aba8e5a 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -479,7 +479,7 @@ fn count_decoded(_n: u64) { /// every row, and the image would be written back wrong. `None` for a JPEG whose colour transform /// readers may disagree about ([`ycc_encoded`]), and for data that decodes past what the samples /// can need ([`allowance`]). -pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { +pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBudget) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { return None; @@ -494,7 +494,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { count_decoded(u64::from(w) * u64::from(h)); (v.len() == bytes).then_some(v) } else { - let v = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?; + let v = budget.decode_filters(doc, s, allowance(bytes, h)?).ok()?; Some(v.get(..bytes)?.to_vec()) } } @@ -700,7 +700,14 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co /// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution. /// Returns the new image and its new soft mask, if the result is smaller. -fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> { +fn process( + doc: &Document, + s: &Stream, + ppi: f64, + settings: &ImageSettings, + n: usize, + budget: &mut ImageBudget, +) -> Option<(Stream, Option<(ObjRef, Stream)>)> { let d = &s.dict; if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n)) || d.contains(b"Mask") @@ -732,7 +739,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us } else { (w, h) }; - let px = pixels(doc, s, n)?; + let px = pixels(doc, s, n, budget)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; // A soft mask must keep the image's dimensions: resample it with the image (lossless). @@ -745,7 +752,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); - let mpx = pixels(doc, m, 1)?; + let mpx = pixels(doc, m, 1, budget)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); } @@ -888,7 +895,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8); // Through its parameters given by reference too: without a PNG predictor's tag bytes taken // off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted. - let samples = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?; + let samples = budget.decode_filters(doc, s, allowance(row_len.checked_mul(hu)?, h)?).ok()?; Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed))) } @@ -1175,24 +1182,14 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) } +/// One pass over the document's images, within `budget`. An image past it stays as it is, and is +/// counted in the report. pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], settings: &Settings, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool, -) -> Result<(), OptimizeError> { - let mut budget = ImageBudget::new(doc); - run_within(doc, pages, settings, report, progress, &mut budget) -} - -/// [`run`] within `budget`. An image past it stays as it is, and is counted in the report. -fn run_within( - doc: &mut Document, - pages: &[ObjRef], - settings: &Settings, - report: &mut Report, - progress: &mut dyn FnMut(Stage) -> bool, budget: &mut ImageBudget, ) -> Result<(), OptimizeError> { let (ppi, mut defaults, read) = walk_pages(doc, pages); @@ -1222,6 +1219,7 @@ fn run_within( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; + let refused = budget.refused(); // Packed from more bits a sample, it is lossless at one bit, but the grey or colour // settings can make it smaller still (below). let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); @@ -1242,7 +1240,9 @@ fn run_within( continue; } let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); - let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); + // Read to be packed and again to be resampled, it is still one image. + report.images_refused += usize::from(budget.refused() > refused); // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft // mask resampled with it. let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { @@ -1282,7 +1282,6 @@ fn run_within( doc.set(mr, Object::Stream(m)); } } - report.images_refused = budget.refused(); if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } @@ -1317,13 +1316,16 @@ mod dimension_tests { dict.set(b"Width".to_vec(), Object::Int(width)); dict.set(b"Height".to_vec(), Object::Int(height)); let stream = Stream::from_raw(dict, vec![0; 4]); - assert!(pixels(&Document::new_empty(), &stream, channels).is_none(), "{width} x {height} x {channels}"); + assert!( + pixels(&Document::new_empty(), &stream, channels, &mut ImageBudget::with_work(u64::MAX)).is_none(), + "{width} x {height} x {channels}" + ); } let mut dict = Dict::new(); dict.set(b"Width".to_vec(), Object::Int(2)); dict.set(b"Height".to_vec(), Object::Int(2)); let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); - assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4])); + assert_eq!(pixels(&Document::new_empty(), &stream, 1, &mut ImageBudget::with_work(u64::MAX)), Some(vec![1, 2, 3, 4])); } /// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped, @@ -1393,7 +1395,7 @@ mod dimension_tests { let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }; let mut report = Report::default(); let mut budget = ImageBudget::with_work(2 * cost); - run_within(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); assert_eq!(report.images_refused, 1); assert_eq!(budget.work_left(), 0, "two decoded and the third refused"); assert_eq!(report.images_resampled, 2); @@ -1401,6 +1403,111 @@ mod dimension_tests { assert_eq!(changed, 2, "the third is as it was"); } + /// Images that aren't JBIG2 share the run's allowance too, both on the way to being resampled + /// and on the way to being packed to a bit a sample: a file of filter bombs would hold a run + /// as long as one of JBIG2, and those past it stay as they are. + #[test] + fn flate_images_past_the_runs_allowance_stay_as_they_are() { + // Noise, resampled, or black and white, packed. + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..256 * 256) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let black_and_white: Vec = noise.iter().map(|v| if v & 1 == 0 { 0 } else { 255 }).collect(); + for (name, samples, settings) in [ + ("resampled", &noise, Settings::default()), + ( + "packed", + &black_and_white, + Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }, + ), + ] { + let page_of = |images: usize| { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(256)); + dict.set(b"Height".to_vec(), Object::Int(256)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = Stream::flate(dict, samples); + let refs: Vec = (0..images).map(|_| doc.add(Object::Stream(image.clone()))).collect(); + let mut xobjects = Dict::new(); + let mut drawn = String::from("q 10 0 0 10 0 0 cm "); + for (i, r) in refs.iter().enumerate() { + xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r)); + drawn.push_str(&format!("/I{i} Do ")); + } + drawn.push('Q'); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes()))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + (doc, page, refs, image) + }; + let (mut doc, page, ..) = page_of(1); + let mut budget = ImageBudget::with_work(u64::MAX); + let mut report = Report::default(); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + let cost = u64::MAX - budget.work_left(); + assert!(cost > 0, "{name}"); + assert_eq!(report.images_refused, 0, "{name}"); + + let (mut doc, page, refs, image) = page_of(3); + let mut budget = ImageBudget::with_work(2 * cost); + let mut report = Report::default(); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + assert_eq!(report.images_refused, 1, "{name}: {report:?}"); + assert_eq!(budget.work_left(), 0, "{name}"); + let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count(); + assert_eq!(changed, 2, "{name}: the third is as it was"); + } + } + + /// A soft mask resampled with its image is read from the same allowance as the image. + #[test] + fn a_soft_mask_resampled_with_its_image_spends_the_allowance() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..256 * 256) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + let image = |space: &str| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(256)); + dict.set(b"Height".to_vec(), Object::Int(256)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name(space)); + Stream::flate(dict, &noise) + }; + let mut doc = Document::new_empty(); + let mask = image("DeviceGray"); + let cost = (mask.raw.len() + noise.len()) as u64; + let masked = doc.add(Object::Stream(mask)); + let mut with_mask = image("DeviceGray"); + with_mask.dict.set(b"SMask".to_vec(), Object::Ref(masked)); + let spent = |s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + process(&doc, s, 720.0, &Settings::default().gray, 1, &mut budget).expect("resampled, and smaller"); + u64::MAX - budget.work_left() + }; + assert_eq!(spent(&with_mask), spent(&image("DeviceGray")) + cost); + } + /// Decoded only to be resampled, what can't be averaged whatever it holds isn't decoded, so it /// takes nothing from the images that can be, and isn't counted as refused past the allowance. #[test] @@ -1448,9 +1555,9 @@ mod dimension_tests { Stream::from_raw(dict, data.clone()) }; DECODED_PIXELS.with(|c| c.set(0)); - assert!(pixels(&Document::new_empty(), &stream(10, 10), 1).is_none()); + assert!(pixels(&Document::new_empty(), &stream(10, 10), 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); - assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1, &mut ImageBudget::with_work(u64::MAX)).map(|p| p.len()), Some(1000 * 1000)); } } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 92671d2381..90d109a809 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -279,7 +279,7 @@ pub struct Report { pub images: usize, pub images_resampled: usize, pub images_recompressed: usize, - /// JBIG2 images left as they are: decoding them all would take more than one run may spend. + /// Images left as they are: decoding them all would take more than one run may spend. pub images_refused: usize, /// Encoded image bytes before and after (images that changed). pub image_bytes_before: usize, @@ -331,10 +331,12 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; + // Merging fragments and the pass after it read the same images, so they share the allowance. + let mut budget = pdfcraft_cos::ImageBudget::new(doc); if settings.merge_fragments { - fragments::run(doc, &pages, &mut report, progress)?; + fragments::run(doc, &pages, &mut report, progress, &mut budget)?; } - images::run(doc, &pages, settings, &mut report, progress)?; + images::run(doc, &pages, settings, &mut report, progress, &mut budget)?; if !progress(Stage::CleanUp) { return Err(OptimizeError::Cancelled); } diff --git a/crates/redact/README.md b/crates/redact/README.md index 726f387376..04fcd788fb 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -33,7 +33,8 @@ clear_marks(&mut doc, None)?; // remove marks without app and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - A run that would decode more JBIG2 than one redaction may (`pdfcraft_cos::ImageBudget`) is refused + A run that would decode more of its images than one redaction may (`pdfcraft_cos::ImageBudget`, + filters and fax and JBIG2 decoders alike) is refused and changes nothing: such an image can't be cleared, and removing it whole would take what no mark covers with it. - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a547193dce..e5ff4621f7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -780,6 +780,28 @@ fn a_jbig2_image_past_the_runs_allowance_stops_the_run() { assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok()); } +/// A Flate image past the run's allowance stops the run as a JBIG2 image does: removed whole, it +/// would take what no mark covers with it, unreported. A JPEG, which clearing doesn't read, is +/// removed whole as it always was, and spends nothing. +#[test] +fn a_flate_image_past_the_runs_allowance_stops_the_run() { + let dict = "/Type /XObject /Subtype /Image /Width 2 /Height 2 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"; + let page = || one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, pdfcraft_filters::encode_flate(&[9; 4]))]); + let mut doc = page(); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}"); + let mut doc = page(); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok()); + + let dict = "/Type /XObject /Subtype /Image /Width 2 /Height 2 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /DCTDecode"; + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"not read")]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)).unwrap(); + assert_eq!(report.images_removed, 1); +} + /// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy, /// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by /// reference, behind another filter with its globals at its own place, or as a mask, its decode diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index def0b97e37..f125e26506 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7409,7 +7409,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] -notes = "All of a run's JBIG2 images share one allowance of decoding (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +notes = "All of a run's images share one allowance of decoding, filters included and JPEG data aside (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" From 696728686f4733935418bd921e103831363d3688 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 23:10:44 +0100 Subject: [PATCH 078/562] M2.6: Optimize's LZW pass spends the run's allowance, and Cancel reaches it Storing LZW streams with Flate decoded image streams outside the allowance, with a limit for each stream and no total, and nothing asked whether to go on: a 593 KB file of 100 images took 113 s with none refused, and images refused by the image pass were still rewritten. The pass now pays for what it decodes and for storing and checking it, at 8 steps a byte since Flate takes up to 78 ns a byte, and streams past that stay LZW. An image is counted as refused only when it stayed as it was: one packed before the allowance ran out on its second read was rewritten. doc_reduce reports images_refused as doc_optimize does, and the app's action says so in its log. A refused image no longer reads as an unsupported filter: FilterError::Spent says what happened. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/automation/src/dispatch.rs | 4 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 2 + crates/cos/src/budget.rs | 45 +++++++++++++- crates/engine/src/actions.rs | 21 ++++++- crates/engine/src/session/files.rs | 10 +-- crates/engine/src/tests.rs | 2 +- crates/filters/README.md | 5 +- crates/filters/src/ccitt.rs | 4 +- crates/filters/src/jbig2/decode.rs | 10 +-- crates/filters/src/lib.rs | 34 ++++++++--- crates/optimize/README.md | 3 +- crates/optimize/src/fragments.rs | 2 +- crates/optimize/src/images.rs | 78 +++++++++++++++++++++++- crates/optimize/src/lib.rs | 53 +++++++++++++--- crates/optimize/tests/optimize.rs | 36 +++++++++++ parity/acrobat-features.toml | 2 +- 17 files changed, 268 insertions(+), 45 deletions(-) diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 36e0d00454..0057704e72 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -294,13 +294,13 @@ impl Automation { let id = self.doc(&a)?.id; let before = self.doc(&a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; - let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + let (bytes, merged, images_refused) = self.session.reduced_bytes(id).map_err(failed)?; // As in the app, a copy that isn't smaller is not written (#490). let written = bytes.len() < before; if written { write_atomic(&path, &bytes)?; } - json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged, "images_refused": images_refused }) } "doc_optimize" => self.doc_optimize(&a)?, "doc_initial_view" => self.doc_initial_view(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b78acbe0f9..285bf9b1df 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1340,7 +1340,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. Images past what one run may decode stay as they are, counted in images_refused. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6a5765b911..d31f19b045 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6478,10 +6478,12 @@ fn optimizing_through_tools() { let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" })); assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); assert_eq!(reduced["written"], true); + assert_eq!(reduced["images_refused"], 0); assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); // Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off). let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" })); + assert_eq!(v14["images_refused"], 0); assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}"); let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap(); assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm")); diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index b2ea9a82fd..dbf809328d 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -30,7 +30,7 @@ pub const MAX_PAGE_FORMS: usize = 50_000; type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; /// What one operation over a document's images may still spend decoding them: exporting them, -/// optimizing them, clearing them for redaction. +/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction. /// /// An image has its own limit, which doesn't bound a file of thousands of images made to reach it: /// a few hundred bytes of JBIG2 can ask for seconds of decoding, 4 KB of Flate inside Flate for a @@ -75,6 +75,24 @@ impl ImageBudget { decoded } + /// `data` through `chain`, any corruption being an error ([`pdfcraft_filters::decode_within`]), + /// for a stream a run rewrites rather than an image it reads, charged to the allowance. + pub fn decode_chain( + &mut self, + chain: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)], + data: &[u8], + max: usize, + ) -> Result, FilterError> { + pdfcraft_filters::decode_within(chain, data, max, &mut self.work) + } + + /// Pays `steps` of other work on decoded data, or spends all there is and says so. + pub fn spend(&mut self, steps: u64) -> bool { + let left = self.work.checked_sub(steps); + self.work = left.unwrap_or(0); + left.is_some() + } + /// An image that failed with nothing left was refused, unless it has a filter that costs /// nothing here, which fails whatever is left. fn count_refused(&mut self, s: &Stream, decoded: &Result, CosError>) { @@ -408,6 +426,31 @@ mod image_tests { assert_eq!(budget.refused(), 1); } + /// A stream a run rewrites is read strictly and charged, and what the caller does with it after + /// comes out of the same allowance: what can't be paid for spends all of it. + #[test] + fn a_rewritten_stream_shares_the_allowance() { + use pdfcraft_filters::{Filter, FilterError, Params}; + let samples = vec![7u8; 1000]; + let raw = pdfcraft_filters::encode_flate(&samples); + let chain = [(Filter::Flate, Params::default())]; + let cost = (raw.len() + samples.len()) as u64; + let mut budget = ImageBudget::with_work(cost + 10); + assert_eq!(budget.decode_chain(&chain, &raw, 1 << 20).unwrap(), samples); + assert_eq!(budget.work_left(), 10); + assert!(budget.spend(4)); + assert_eq!(budget.work_left(), 6); + assert!(budget.spend(6), "exactly what is left"); + assert_eq!(budget.work_left(), 0); + assert!(budget.spend(0)); + let mut budget = ImageBudget::with_work(5); + assert!(!budget.spend(6)); + assert_eq!(budget.work_left(), 0, "spends all of it"); + assert!(matches!(budget.decode_chain(&chain, &raw, 1 << 20), Err(FilterError::Spent))); + let cut = raw.get(..raw.len() / 2).unwrap(); + assert!(matches!(ImageBudget::with_work(u64::MAX).decode_chain(&chain, cut, 1 << 20), Err(FilterError::Corrupt { .. })), "strictly"); + } + /// An image read through its filters alone reads parameters given by reference, is charged, and /// is refused like the others. It isn't given to a decoder. #[test] diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f7d11d47ed..3e942f6a2f 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -146,6 +146,11 @@ pub struct FileResult { pub log: Vec, } +/// What an action's log says of the images a Reduce File Size step left, if there are any. +fn images_refused_note(images: usize) -> Option { + (images > 0).then(|| format!("Reduce file size: {images} image(s) stayed as they were, past what one run may decode")) +} + fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> Result, EditError> { let pages = s.get(id).map_or(0, |d| d.info.pages.len()); let all: Vec = (0..pages).collect(); @@ -156,9 +161,10 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } Step::ReduceFileSize => { - let (bytes, _) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?; + let (bytes, report) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?; let before = s.get(id).map_or(0, |d| d.bytes.len()); log.push(format!("Reduce file size: {} → {} bytes", before, bytes.len())); + log.extend(images_refused_note(report.optimize.images_refused)); let name = s.get(id).map(|d| d.name.clone()).unwrap_or_default(); s.close(id); let new = s.open(name, None, bytes, None).map_err(|e| EditError::Reopen(e.to_string()))?; @@ -222,3 +228,16 @@ pub fn run_on(action: &Action, name: &str, bytes: Arc>, mut progress: im let bytes = if d.is_signed() { s.save_bytes(id) } else { s.save_full_bytes(id) }.map_err(|e| e.to_string())?; Ok(FileResult { name: name.to_string(), bytes, log }) } + +#[cfg(test)] +mod tests { + use super::images_refused_note; + + /// A log line only for images that stayed, saying how many. + #[test] + fn the_log_says_when_images_stayed_as_they_were() { + assert_eq!(images_refused_note(0), None); + assert!(images_refused_note(1).is_some_and(|note| note.contains("1 image(s)"))); + assert!(images_refused_note(3).is_some_and(|note| note.contains("3 image(s)"))); + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 34d75e5c82..553ddd6aa3 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -71,12 +71,12 @@ impl Session { /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, /// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as /// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused - /// objects dropped, objects packed into compressed object streams. Returns the bytes and how - /// many objects were merged. The open document is not changed (Acrobat saves the reduced copy - /// as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + /// objects dropped, objects packed into compressed object streams. Returns the bytes, how + /// many objects were merged and how many images stayed as they were, past what one run may + /// decode. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize, usize), EditError> { let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) + Ok((bytes, report.merged, report.optimize.images_refused)) } /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e575de9f8b..503e718586 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3569,7 +3569,7 @@ fn create_and_reduce() { ) .unwrap(); let full = s2.save_full_bytes(id2).unwrap(); - let (reduced, _) = s2.reduced_bytes(id2).unwrap(); + let (reduced, ..) = s2.reduced_bytes(id2).unwrap(); assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len()); let mut s3 = Session::new(); let r = s3.open("r.pdf", None, reduced, None).unwrap(); diff --git a/crates/filters/README.md b/crates/filters/README.md index dcf070c4c1..90e1485177 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -15,13 +15,14 @@ Filter::from_name(b"FlateDecode" | b"Fl" | ...) -> Filter // full names and in Filter::name(&self) -> &str; Filter::is_image_codec(&self) -> bool pub struct Params { predictor, colors, bits_per_component, columns, early_change } // i64s; Default = 1, 1, 8, 1, 1 -pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) } +pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String), Spent } decode(chain, data, max_output) -> Result, FilterError> decode_counted(chain, data, max_output) -> Result<(Vec, usize /*decoded*/), (FilterError, usize /*decoded*/)> decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError> decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> decode_tolerant_within(chain, data, max_output, work: &mut u64) -> Result, FilterError> +decode_within(chain, data, max_output, work: &mut u64) -> Result, FilterError> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS @@ -36,7 +37,7 @@ encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit row - Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first. -- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_jbig2_within` and `decode_ccitt_within` are, all three spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. What `work` can't pay for spends all of it, and gives `Unsupported`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error. +- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_within` (its strict form), `decode_jbig2_within` and `decode_ccitt_within` are, all spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. What `work` can't pay for spends all of it, and gives `Spent`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs index a839c24c02..a7138bd485 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -223,11 +223,11 @@ mod tests { assert_eq!(work, 7, "only what it took"); let mut work = cost - 1; let refused = decode_ccitt_within(&data, &p, h, &mut work).unwrap_err(); - assert_eq!(refused.to_string(), crate::work_spent().to_string(), "a step short"); + assert!(matches!(refused, FilterError::Spent), "a step short: {refused}"); assert_eq!(work, 0, "spends all that was left"); // Nothing left: not read at all, so bad columns aren't reported as bad columns. let bad = CcittParams { columns: 0, ..p }; - assert!(decode_ccitt_within(&data, &bad, h, &mut 0).is_err_and(|e| e.to_string() == crate::work_spent().to_string())); + assert!(decode_ccitt_within(&data, &bad, h, &mut 0).is_err_and(|e| matches!(e, FilterError::Spent))); // No code at all, for the same rows. let mut work = u64::MAX; decode_ccitt_within(&[], &p, h, &mut work).unwrap(); diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 9a3ed9fbde..d067df4d6b 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -39,9 +39,9 @@ pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, ro return Ok(Vec::new()); } if *work == 0 { - return Err(crate::work_spent()); + return Err(FilterError::Spent); } - hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| crate::work_spent())?; + hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| FilterError::Spent)?; let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail }; let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?; let (width, height) = (image.width(), image.height()); @@ -58,7 +58,7 @@ pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, ro } let mut out = Rows::new(width, height, false); // Stopped by what was left, it isn't a damaged image, whatever the decoder calls it. - image.decode_within(&mut out, work).map_err(|e| if *work == 0 { crate::work_spent() } else { corrupt(e.to_string()) })?; + image.decode_within(&mut out, work).map_err(|e| if *work == 0 { FilterError::Spent } else { corrupt(e.to_string()) })?; Ok(out.out) } @@ -159,10 +159,10 @@ mod tests { decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(); assert_eq!(work, 7, "only what it took"); let mut work = cost - 1; - assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_))), "a step short"); + assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Spent)), "a step short"); assert_eq!(work, 0, "spends all that was left"); // Nothing left: not read at all, so this garbage isn't reported as garbage. - assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_)))); + assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Spent))); // Too little even for its page. let mut work = 3; assert!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).is_err()); diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index bcafc132bd..03cad12f79 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -124,6 +124,9 @@ pub enum FilterError { LimitExceeded(usize), #[error("unsupported filter: {0}")] Unsupported(String), + /// A run of images has nothing left to spend ([`decode_tolerant_within`]). + #[error("more image decoding than one operation may do")] + Spent, } /// A decoder failure carrying whatever was decoded before the error. @@ -144,11 +147,6 @@ impl Failure { pub(crate) type Step = Result, Failure>; -/// What decoding says when a run of images has nothing left to spend on it. -pub(crate) fn work_spent() -> FilterError { - FilterError::Unsupported("more image decoding than one operation may do".into()) -} - /// Takes `steps` from `work`, what a run of images may still cost. What it can't pay for spends /// all of it. pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> { @@ -159,7 +157,7 @@ pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> { } None => { *work = 0; - Err(work_spent()) + Err(FilterError::Spent) } } } @@ -202,13 +200,23 @@ pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_outp /// /// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`. pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { + spending(chain, data, max_output, work, false) +} + +/// [`decode`], spending from `work` as [`decode_tolerant_within`] does: corrupt data is an error, +/// having paid for what was decoded before it. +pub fn decode_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result, FilterError> { + spending(chain, data, max_output, work, true) +} + +fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64, strict: bool) -> Result, FilterError> { decodable(chain)?; pay(work, data.len() as u64)?; if chain.is_empty() { return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) }; } let left = usize::try_from(*work).unwrap_or(usize::MAX); - match run(chain, data, max_output, Some(left), false) { + match run(chain, data, max_output, Some(left), strict) { Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out), // The stage stopped at the lower of the two, which took as much to find out. Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => { @@ -217,7 +225,7 @@ pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_outpu } Err((FilterError::LimitExceeded(_), _)) => { *work = 0; - Err(work_spent()) + Err(FilterError::Spent) } Err((e, decoded)) => pay(work, decoded as u64).and(Err(e)), } @@ -513,7 +521,7 @@ mod tests { } fn refused(e: &FilterError) -> bool { - e.to_string() == work_spent().to_string() + matches!(e, FilterError::Spent) } /// A chain pays for what it reads and what each stage decodes, down to the byte: Flate inflates @@ -597,6 +605,14 @@ mod tests { let mut work = u64::MAX; assert_eq!(decode_tolerant_within(&chain, &cut, 1 << 20, &mut work).unwrap(), partial); assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64); + // Read strictly it is an error, for the same work. + let mut work = u64::MAX; + assert!(matches!(decode_within(&chain, &cut, 1 << 20, &mut work), Err(FilterError::Corrupt { .. }))); + assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64); + let whole = encode_flate(&data); + let mut work = u64::MAX; + assert_eq!(decode_within(&chain, &whole, 1 << 20, &mut work).unwrap(), data); + assert_eq!(u64::MAX - work, (whole.len() + data.len()) as u64); // A predictor that comes to nothing, after all of this was inflated for it. let p = Params { predictor: 99, ..Params::default() }; let data = encode_flate(&vec![0; 1 << 16]); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index a30e3b197b..12b4a17640 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -110,7 +110,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going layer, that layer is removed when the result is smaller (the inner filters keep their encoded bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In - streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without + streams that use LZW encoding, use Flate instead"), from the run's allowance, past which they + stay LZW: a predictor carries over to Flate without `EarlyChange`, filters applied before the LZW one go with it and those after it keep their bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 55aca519f0..560e38ff1e 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -359,7 +359,7 @@ mod tests { } /// Merging reads the images the pass after it reads, from the same allowance: with nothing - /// left the strips stay as they are, and the image that couldn't be read is counted. + /// left the strips stay as they are, and the budget shows the read it refused. #[test] fn merging_spends_the_runs_allowance() { let mut doc = Document::new_empty(); diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 5f2aba8e5a..b501f6b675 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1241,8 +1241,6 @@ pub(crate) fn run( } let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget)); - // Read to be packed and again to be resampled, it is still one image. - report.images_refused += usize::from(budget.refused() > refused); // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft // mask resampled with it. let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { @@ -1268,7 +1266,11 @@ pub(crate) fn run( doc.set(r, Object::Stream(new)); continue; } - let Some((new, smask)) = processed else { continue }; + let Some((new, smask)) = processed else { + // Read to be packed and again to be resampled, it is still one image, and it stayed. + report.images_refused += usize::from(budget.refused() > refused); + continue; + }; let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); @@ -1473,6 +1475,76 @@ mod dimension_tests { } } + /// An image the allowance ran out on between being packed and being resampled is rewritten, + /// as the packed one, so it isn't one that stayed as it was. + #[test] + fn a_packed_image_isnt_counted_as_refused() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let samples: Vec = (0..256 * 256) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + if seed & 1 == 0 { 0 } else { 255 } + }) + .collect(); + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(256)); + dict.set(b"Height".to_vec(), Object::Int(256)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = Stream::flate(dict, &samples); + let read = (image.raw.len() + samples.len()) as u64; + let original = doc.add(Object::Stream(image.clone())); + let mut xobjects = Dict::new(); + xobjects.set(b"I0".to_vec(), Object::Ref(original)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 10 0 0 10 0 0 cm /I0 Do Q"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() }; + let mut report = Report::default(); + let mut budget = ImageBudget::with_work(read); + run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap(); + assert_eq!(budget.refused(), 1, "its second read was refused"); + assert_eq!(report.images_refused, 0, "{report:?}"); + assert_eq!(report.images_recompressed + report.images_resampled, 1, "{report:?}"); + assert!(matches!(&*doc.get(original), Object::Stream(s) if *s != image)); + } + + /// An image the run has no use for stayed as it was too, but nothing refused it: this one is + /// too small to compress and drawn at no more than its size. + #[test] + fn an_image_left_alone_isnt_counted_as_refused() { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(8)); + dict.set(b"Height".to_vec(), Object::Int(8)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(dict, &[0x80; 64]))); + let mut xobjects = Dict::new(); + xobjects.set(b"I0".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 72 0 0 72 0 0 cm /I0 Do Q"))); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(page)); + let mut report = Report::default(); + run(&mut doc, &[page], &Settings::default(), &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((report.images, report.images_refused), (1, 0), "{report:?}"); + } + /// A soft mask resampled with its image is read from the same allowance as the image. #[test] fn a_soft_mask_resampled_with_its_image_spends_the_allowance() { diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 90d109a809..e70aa5e71d 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -36,7 +36,7 @@ mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; -use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; +use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; @@ -331,8 +331,8 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; - // Merging fragments and the pass after it read the same images, so they share the allowance. - let mut budget = pdfcraft_cos::ImageBudget::new(doc); + // Merging fragments, the image pass and the LZW pass read the same images, so they share it. + let mut budget = ImageBudget::new(doc); if settings.merge_fragments { fragments::run(doc, &pages, &mut report, progress, &mut budget)?; } @@ -432,7 +432,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: let r = ObjRef { num, generation: doc.generation(num) }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; - if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) { + // Each can take a second or more, of a pass that has no other way to stop. + if s.filters().iter().any(|(f, _)| *f == Filter::Lzw) && !progress(Stage::CleanUp) { + return Err(OptimizeError::Cancelled); + } + if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED, &mut budget) { doc.set(r, Object::Stream(replacement)); report.lzw_streams += 1; } @@ -467,14 +471,17 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: /// Largest decoded LZW stream converted, as for a page's content: the data and its check are /// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes. const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED; +/// The steps of a run's allowance a decoded byte costs to store with Flate and read back: up to +/// 78 ns a byte to encode and 4 to check, at the 6 to 16 ns of a step. +const LZW_REWRITE_WORK: u64 = 8; /// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding, /// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its /// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and /// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or -/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is -/// left as it is. -fn lzw_to_flate(s: &Stream, max: usize) -> Option { +/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes or +/// what `budget` has left, is left as it is. +fn lzw_to_flate(s: &Stream, max: usize, budget: &mut ImageBudget) -> Option { // An external file's data isn't in `raw`; cross-reference and object streams are rewritten // on save, so converting them would change nothing in the file. if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) { @@ -517,7 +524,10 @@ fn lzw_to_flate(s: &Stream, max: usize) -> Option { if !parms.get(..=lzw)?.iter().all(direct) { return None; } - let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?; + let data = budget.decode_chain(stages, &s.raw, max).ok()?; + if !budget.spend((data.len() as u64).saturating_mul(LZW_REWRITE_WORK)) { + return None; + } let flate = Params { early_change: 1, ..params.clone() }; let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?; // The predictor's encoder must give back exactly what its decoder read (a short last row, @@ -601,8 +611,31 @@ mod tests { let mut dict = Dict::new(); dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); let s = Stream::from_raw(dict, raw); - assert!(lzw_to_flate(&s, data.len() - 1).is_none()); - let converted = lzw_to_flate(&s, data.len()).unwrap(); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(lzw_to_flate(&s, data.len() - 1, &mut budget).is_none()); + let converted = lzw_to_flate(&s, data.len(), &mut budget).unwrap(); assert_eq!(converted.decoded_strict().unwrap(), data); } + + /// Rewriting a stream spends the run's allowance: reading it, decoding it, and storing and + /// checking what it decoded. One the allowance can't pay for stays LZW, and ends the rewriting. + #[test] + fn lzw_streams_spend_the_runs_allowance() { + let data = b"0 0 m 612 792 l S\n".repeat(64); + let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); + let s = Stream::from_raw(dict, raw); + let cost = (s.raw.len() + data.len()) as u64 + data.len() as u64 * LZW_REWRITE_WORK; + let mut budget = ImageBudget::with_work(cost + 7); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_some()); + assert_eq!(budget.work_left(), 7); + let mut budget = ImageBudget::with_work(cost); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_some(), "exactly enough"); + assert_eq!(budget.work_left(), 0); + let mut budget = ImageBudget::with_work(cost - 1); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_none(), "a step short"); + assert_eq!(budget.work_left(), 0, "and the next stream isn't read"); + assert!(lzw_to_flate(&s, data.len(), &mut budget).is_none()); + } } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 9bbd959c69..0254d64a34 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3398,6 +3398,42 @@ fn lzw_streams_become_flate_and_decode_to_the_same_bytes() { assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream"); } +/// A stream can take a second or more to store again, so a cancel lands between any two of them, +/// and nothing runs after it. +#[test] +fn a_cancel_lands_during_the_lzw_pass() { + use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress}; + let build = |streams: usize| { + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + for _ in 0..streams { + doc.add(Object::Stream(Stream::from_raw(d.clone(), lzw(&lzw_ops(), &Params::default())))); + } + page(&mut doc, &[], "0 0 1 1 re f"); + doc + }; + let asked = |streams: usize| { + let mut asked = 0; + optimize_with_progress(&mut build(streams), &Settings::default(), &mut |s| { + asked += usize::from(s == Stage::CleanUp); + true + }) + .unwrap(); + asked + }; + let none = asked(0); + assert_eq!(asked(3), none + 3, "once a stream"); + let mut left = none + 1; + let mut doc = build(3); + let r = optimize_with_progress(&mut doc, &Settings::default(), &mut |s| { + left -= usize::from(s == Stage::CleanUp); + left > 0 + }); + assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}"); + assert_eq!(left, 0, "nothing runs after it"); +} + #[test] fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() { let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f125e26506..20eac4656f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7408,7 +7408,7 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] +evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] notes = "All of a run's images share one allowance of decoding, filters included and JPEG data aside (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] From ade8ebbdb493b133eba69dfced87a000272a2181 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 00:20:43 +0100 Subject: [PATCH 079/562] M2.6: a run pays for what a stream costs, not what it holds Four ways round the allowance, each measured by a review of hostile input: A Flate stream read as zlib that fails at once, and then as raw deflate that decodes everything before failing, gave nothing back and so was charged nothing, here and in DecodeBudget: 110 s for a 387 KB file, all decoded. What the retry decoded now counts, though the data is still dropped as it was. Fax and 1-bit Flate images paid by the byte for work done by the pixel: 19 bytes can be a blank page of 100 million pixels, about a second each. Every image decoded here now pays for its pixels first, as JBIG2 already did. A JPEG paid nothing, and what one asks for doesn't grow with its length: 156 bytes declared 80 megapixels, 72 s for an 8 KB file. A JPEG pays for its samples before it is decoded, in Optimize and in an export that has to decode one for its mask. Merging fragments paid for reading strips and not for the picture it built: 393 s for 1,154 bytes. It pays for the picture before allocating it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/cos/README.md | 4 +- crates/cos/src/budget.rs | 118 +++++++++++++++++++++++++++++-- crates/create/README.md | 4 +- crates/create/src/extract.rs | 6 +- crates/create/src/tests.rs | 47 +++++++++++- crates/filters/src/flate.rs | 5 +- crates/filters/src/lib.rs | 44 +++++++++--- crates/optimize/README.md | 3 +- crates/optimize/src/fragments.rs | 24 +++++-- crates/optimize/src/images.rs | 26 ++++++- crates/redact/README.md | 2 +- parity/acrobat-features.toml | 2 +- 12 files changed, 251 insertions(+), 34 deletions(-) diff --git a/crates/cos/README.md b/crates/cos/README.md index ff192d2e6e..a20ee99ff0 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -94,6 +94,6 @@ recognisable in a diff and byte-stable when re-serialized. (`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB, their filters undone once a document for the images that share them, up to 64 MiB kept, while their segments are decoded for every image, and `ImageBudget` for an operation over many images, - which share one allowance of decoding, filters and fax and JBIG2 decoders alike, and are refused - past it); + which share one allowance of decoding, filters, decoders and pixels alike, and are refused past + it); DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index dbf809328d..8b9fb37135 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -25,6 +25,10 @@ pub const MAX_PAGE_DECODED: usize = 256 << 20; /// Form XObjects one page's content walk may enter, each entry counted again. pub const MAX_PAGE_FORMS: usize = 50_000; +/// The steps a sample of a JPEG costs: a few bytes can hold a page of one grey, and decoding, +/// resampling and storing it took about 20 to 45 ns a sample. +const JPEG_WORK: u64 = 2; + /// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or /// the error, each with that count. type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; @@ -39,8 +43,10 @@ type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usiz /// aren't decoded. A caller then treats them as it does an image it can't read, and /// [`ImageBudget::refused`] says how many there were. /// -/// JPEG and JPEG 2000 data aren't charged: their decoders don't run here, and what one asks for -/// grows with its length, as the allowance does. +/// An image also pays for its pixels, as what reads it goes through them one by one however few +/// bytes hold them: blank rows of fax or Flate take almost none. A JPEG pays for its samples when +/// it is decoded ([`ImageBudget::pay_jpeg`]), and is otherwise not charged, as JPEG 2000 isn't: +/// their decoders don't run here. #[derive(Debug)] pub struct ImageBudget { work: u64, @@ -61,6 +67,7 @@ impl ImageBudget { /// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance. pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { + self.pay_size(s)?; let decoded = doc.decoded_image_spending(s, max, &mut self.work); self.count_refused(s, &decoded); decoded @@ -70,6 +77,7 @@ impl ImageBudget { /// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an /// image that has no fax or JBIG2 decoder to go through. pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { + self.pay_size(s)?; let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work); self.count_refused(s, &decoded); decoded @@ -93,13 +101,33 @@ impl ImageBudget { left.is_some() } + /// Pays for `pixels` a decode goes on to work through, or spends all there is, and the image + /// is refused. + pub fn pay_pixels(&mut self, pixels: u64) -> bool { + let paid = self.spend(pixels); + self.refused += usize::from(!paid); + paid + } + + /// Pays for decoding a JPEG of `samples`, as [`ImageBudget::pay_pixels`] does. + pub fn pay_jpeg(&mut self, samples: u64) -> bool { + self.pay_pixels(samples.saturating_mul(JPEG_WORK)) + } + + /// Pays for the pixels of image `s`, unless it is JBIG2, whose decoder pays for its page, or + /// isn't decoded here. + fn pay_size(&mut self, s: &Stream) -> Result<(), CosError> { + if !charged(s) || matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + return Ok(()); + } + let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()).unwrap_or(0); + if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height"))) { Ok(()) } else { Err(CosError::Filter(FilterError::Spent.to_string())) } + } + /// An image that failed with nothing left was refused, unless it has a filter that costs /// nothing here, which fails whatever is left. fn count_refused(&mut self, s: &Stream, decoded: &Result, CosError>) { - use pdfcraft_filters::Filter; - let charged = - s.readable_filters().is_ok_and(|chain| chain.iter().all(|(f, _)| f.is_decodable() || matches!(f, Filter::Jbig2 | Filter::CcittFax))); - if decoded.is_err() && self.work == 0 && charged { + if decoded.is_err() && self.work == 0 && charged(s) { self.refused += 1; } } @@ -115,6 +143,12 @@ impl ImageBudget { } } +/// Whether `s` takes only filters and decoders this charges. +fn charged(s: &Stream) -> bool { + use pdfcraft_filters::Filter; + s.readable_filters().is_ok_and(|chain| chain.iter().all(|(f, _)| f.is_decodable() || matches!(f, Filter::Jbig2 | Filter::CcittFax))) +} + /// What one operation may still decode and enter across all the streams it reads. /// /// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose @@ -418,7 +452,8 @@ mod image_tests { dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded)); - let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64; + let pixels = u64::from(w) * u64::from(h); + let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + pixels; let mut budget = ImageBudget::with_work(cost); assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize); assert_eq!(budget.work_left(), 0, "exactly that"); @@ -426,6 +461,60 @@ mod image_tests { assert_eq!(budget.refused(), 1); } + /// An image pays for its pixels however few bytes hold them, or a blank page of fax or Flate + /// would cost a run a second for a few bytes' worth of allowance; a JBIG2 image doesn't pay + /// twice, and one this doesn't decode pays nothing. Past what is left it isn't read. + #[test] + fn an_image_pays_for_its_pixels() { + let doc = Document::new_empty(); + let blank = |filter: Option<&str>, data: Vec| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(4096)); + dict.set(b"Height".to_vec(), Object::Int(1024)); + if let Some(filter) = filter { + dict.set(b"Filter".to_vec(), Object::name(filter)); + } + Stream::from_raw(dict, data) + }; + let rows = vec![0xFFu8; 512 * 1024]; + let flate = blank(Some("FlateDecode"), pdfcraft_filters::encode_flate(&rows)); + let cost = (flate.raw.len() + rows.len()) as u64 + 4096 * 1024; + let mut budget = ImageBudget::with_work(cost); + assert_eq!(budget.decode(&doc, &flate, 1 << 24).unwrap(), rows); + assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that"); + // Its filters alone pay the same. + let mut budget = ImageBudget::with_work(cost); + assert_eq!(budget.decode_filters(&doc, &flate, 1 << 24).unwrap(), rows); + assert_eq!(budget.work_left(), 0); + // Short of its pixels it isn't read: this much would have paid for the filters. + let mut budget = ImageBudget::with_work(4096 * 1024 - 1); + assert!(budget.decode(&doc, &flate, 1 << 24).unwrap_err().to_string().contains("more image decoding than")); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + let mut budget = ImageBudget::with_work(4096 * 1024 - 1); + assert!(budget.decode_filters(&doc, &flate, 1 << 24).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + // What isn't decoded here pays for nothing, and fails as it would. + let mut budget = ImageBudget::with_work(10); + assert!(budget.decode(&doc, &blank(Some("DCTDecode"), b"data".to_vec()), 1 << 24).is_err()); + assert_eq!((budget.work_left(), budget.refused()), (10, 0)); + // Unfiltered data pays too, and a size that isn't one is no size. + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, &blank(None, rows.clone()), 1 << 24).unwrap(); + assert_eq!(u64::MAX - budget.work_left(), rows.len() as u64 + 4096 * 1024); + let mut odd = Dict::new(); + odd.set(b"Width".to_vec(), Object::Int(-4)); + odd.set(b"Height".to_vec(), Object::Int(9)); + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, &Stream::from_raw(odd, vec![1, 2]), 1 << 24).unwrap(); + assert_eq!(u64::MAX - budget.work_left(), 2); + // A JPEG pays twice over for each sample, once decoded. + let mut budget = ImageBudget::with_work(20); + assert!(budget.pay_jpeg(9)); + assert_eq!((budget.work_left(), budget.refused()), (2, 0)); + assert!(!budget.pay_jpeg(2)); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + } + /// A stream a run rewrites is read strictly and charged, and what the caller does with it after /// comes out of the same allowance: what can't be paid for spends all of it. #[test] @@ -571,6 +660,21 @@ mod tests { assert_eq!(budget.remaining(), (10 << 20) - partial); } + /// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it, + /// gives nothing back, and each reference still pays for what it decoded. + #[test] + fn a_decode_that_gives_nothing_back_still_pays() { + let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9]; + data.extend(vec![0u8; 0x0601]); + data.push(0x07); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let stream = Stream::from_raw(dict, data); + let mut budget = DecodeBudget::new(10 * 0x0601, 1); + assert!(budget.decode(&stream).unwrap().is_empty()); + assert_eq!(budget.remaining(), 9 * 0x0601); + } + /// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage /// that inflates to hex text, which ASCIIHex then halves, charges both. #[test] diff --git a/crates/create/README.md b/crates/create/README.md index 46281772e4..39a47bc21f 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -32,8 +32,8 @@ other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, a whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are. -Past what one export may decode, which its images share, filters and fax and JBIG2 decoders alike (`ImageBudget`; JPEG data isn't -counted), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that +Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG only when a mask +means decoding it), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that is more, since they stay in memory until it ends. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 6019a0d093..408f9b13ff 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -7,6 +7,7 @@ use std::collections::HashSet; use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream}; +use pdfcraft_filters::FilterError; /// One image, ready to write. #[derive(Clone, Debug, PartialEq)] @@ -165,7 +166,7 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) }; // Without its mask the image would come out opaque, showing what the mask hides. if budget.refused() > refused { - return Err("more image decoding than one operation may do".into()); + return Err(FilterError::Spent.to_string()); } let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. @@ -184,6 +185,9 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat } let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; let (samples, bpc) = if jpeg { + if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64)) { + return Err(FilterError::Spent.to_string()); + } // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an // indexed one (not valid), is written as is, without its transparency. let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() }; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 17197b91e9..3d4a0387b0 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2312,6 +2312,51 @@ fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() { assert_eq!(budget.refused(), 1); } +/// A JPEG is written as it is, at no cost, unless a mask means decoding it: that pays for its +/// samples, and past the allowance the image goes, with the reason, as it would come out without +/// its transparency otherwise. +#[test] +fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let image_dict = |extra: Option<(&[u8], Object)>| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(4)); + d.set(b"Height".to_vec(), Object::Int(4)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + if let Some((key, value)) = extra { + d.set(key.to_vec(), value); + } + d + }; + let mut doc = Document::new_empty(); + let mut mask = Dict::new(); + mask.set(b"Subtype".to_vec(), Object::name("Image")); + mask.set(b"Width".to_vec(), Object::Int(4)); + mask.set(b"Height".to_vec(), Object::Int(4)); + mask.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0x80; 16]))); + let masked = doc.add(Object::Stream(Stream::from_raw(image_dict(Some((b"SMask", Object::Ref(mask)))), jpeg.clone()))); + let plain = doc.add(Object::Stream(Stream::from_raw(image_dict(None), jpeg))); + + let mut budget = ImageBudget::with_work(0); + assert!(crate::extract::image_file_within(&doc, plain, &mut budget).is_ok(), "written as it is"); + assert_eq!(budget.refused(), 0); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(crate::extract::image_file_within(&doc, masked, &mut budget).is_ok()); + let cost = u64::MAX - budget.work_left(); + assert_eq!(cost, 16 + 16 + 2 * 4 * 4 * 3, "the mask's bytes and pixels, and the JPEG's samples twice"); + let mut budget = ImageBudget::with_work(cost - 1); + let refused = crate::extract::image_file_within(&doc, masked, &mut budget).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); + assert_eq!(budget.refused(), 1); +} + /// A mask past the allowance can't be left out as an unreadable one is, or the image comes out /// opaque, showing what the mask hides: the image goes, with the reason. #[test] @@ -2326,7 +2371,7 @@ fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() { mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); let mut doc = Document::new_empty(); let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap()))); - // A JPEG, which nothing charges, so only its mask can be what stops it. + // A JPEG, read for nothing until its mask is, so only the mask can be what stops it. let mut jpeg = Vec::new(); image::RgbImage::from_pixel(1, 1, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); let mut image = Dict::new(); diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs index dac0851e07..1c38f72a53 100644 --- a/crates/filters/src/flate.rs +++ b/crates/filters/src/flate.rs @@ -28,8 +28,9 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step { // A header that only looked valid: retry the whole input as raw deflate. Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) { // Keep the original corruption diagnostic only if the retry is corrupt - // too. A limit error must remain fatal even for tolerant decoding. - Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f), + // too. A limit error must remain fatal even for tolerant decoding. What the + // retry decoded isn't kept, but a caller charging for work has to hear of it. + Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(Failure { discarded: retry.partial.len(), ..f }), retry => retry, }, r => r, diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 03cad12f79..9fb0cd4d77 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -133,15 +133,17 @@ pub enum FilterError { pub(crate) struct Failure { pub(crate) error: FilterError, pub(crate) partial: Vec, + /// Bytes decoded on the way that `partial` doesn't hold: they took as long. + pub(crate) discarded: usize, } impl Failure { pub(crate) fn corrupt(filter: &'static str, detail: impl Into, partial: Vec) -> Self { - Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial } + Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial, discarded: 0 } } pub(crate) fn limit(max: usize) -> Self { - Failure { error: FilterError::LimitExceeded(max), partial: Vec::new() } + Failure { error: FilterError::LimitExceeded(max), partial: Vec::new(), discarded: 0 } } } @@ -267,7 +269,7 @@ fn run( spent = spent.saturating_add(work); cur = Cow::Owned(match step { Ok(v) => v, - Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { + Err(Failure { error: FilterError::Corrupt { .. }, partial: out, .. }) if !strict => { partial = true; out } @@ -286,15 +288,15 @@ fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Ste Filter::AsciiHex => counted(asciihex::decode(data, max)), Filter::Ascii85 => counted(ascii85::decode(data, max)), Filter::RunLength => counted(runlength::decode(data, max)), - other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), 0), + other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new(), discarded: 0 }), 0), } } -/// A step with the bytes it produced, its output or its partial output. +/// A step with the bytes it produced: its output, or its partial output and what it threw away. fn counted(step: Step) -> (Step, usize) { let n = match &step { Ok(v) => v.len(), - Err(f) => f.partial.len(), + Err(f) => f.partial.len().saturating_add(f.discarded), }; (step, n) } @@ -306,12 +308,12 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usi let (step, n) = counted(step); let step = match step { Ok(v) => predictor::decode(params, v, name), - Err(Failure { error, partial }) => { + Err(Failure { error, partial, discarded }) => { let partial = match predictor::decode(params, partial, name) { Ok(v) => v, Err(f) => f.partial, }; - Err(Failure { error, partial }) + Err(Failure { error, partial, discarded }) } }; (step, n) @@ -621,6 +623,32 @@ mod tests { assert_eq!(u64::MAX - work, data.len() as u64 + (1 << 16)); } + /// A stream that reads as zlib only until it fails, and as raw deflate until that fails too, + /// gives back no data, and has still decoded everything the retry did. + #[test] + fn a_retry_that_fails_pays_for_what_it_decoded() { + // Stored as raw deflate: this many bytes, then a block of a reserved type. As zlib: the + // same reserved type at once, so the first reading fails with nothing. + let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9]; + data.extend(vec![0u8; 0x0601]); + data.push(0x07); + let chain = [(Filter::Flate, Params::default())]; + let (out, partial) = decode_tolerant(&chain, &data, 1 << 20).unwrap(); + assert!(out.is_empty() && partial, "what is read stays as it was"); + let (_, _, spent) = decode_tolerant_counted(&chain, &data, 1 << 20).unwrap(); + assert_eq!(spent, 0x0601); + let (e, spent) = decode_counted(&chain, &data, 1 << 20).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{e}"); + assert_eq!(spent, 0x0601); + let mut work = u64::MAX; + assert!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap().is_empty()); + assert_eq!(u64::MAX - work, (data.len() + 0x0601) as u64); + // Through a predictor, which would otherwise lose the count. + let p = Params { predictor: 12, columns: 4, ..Params::default() }; + let (_, _, spent) = decode_tolerant_counted(&[(Filter::Flate, p)], &data, 1 << 20).unwrap(); + assert_eq!(spent, 0x0601); + } + /// With nothing left nothing is read. What can't be decoded at all never costs anything, and /// says so whatever is left. Without filters, what is read is the data. #[test] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 12b4a17640..b71c2c0277 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -101,7 +101,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2 images, with bounded shared globals. All of a run's images share one allowance of decoding - (`pdfcraft_cos::ImageBudget`), filters and fax and JBIG2 decoders alike, JPEG data aside: about + (`pdfcraft_cos::ImageBudget`), filters, decoders and pixels alike, JPEG samples and merged + pictures too: about 1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused` counts them. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 560e38ff1e..f74b091984 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -167,7 +167,12 @@ fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) - return None; } let row = (width as usize).checked_mul(n)?; - let mut out = vec![255u8; row.checked_mul(height as usize)?]; + // What this builds isn't what its strips cost to read: four small ones can ask for all of it. + let bytes = row.checked_mul(height as usize)?; + if !budget.pay_pixels(u64::try_from(bytes).ok()?) { + return None; + } + let mut out = vec![255u8; bytes]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -370,10 +375,11 @@ mod tests { image.set(k.as_bytes().to_vec(), v); } image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); - let mut xobjects = Dict::new(); - let mut drawn = String::new(); + let (mut xobjects, mut drawn, mut raw) = (Dict::new(), String::new(), 0); for row in 0..8 { - let r = doc.add(Object::Stream(Stream::flate(image.clone(), &[row * 30; 8]))); + let strip = Stream::flate(image.clone(), &[row * 30; 8]); + raw += strip.raw.len() as u64; + let r = doc.add(Object::Stream(strip)); xobjects.set(format!("I{row}").into_bytes(), Object::Ref(r)); drawn.push_str(&format!("q 80 0 0 10 0 {} cm /I{row} Do Q\n", 70 - 10 * i32::from(row))); } @@ -388,9 +394,15 @@ mod tests { let mut budget = ImageBudget::with_work(0); assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0); assert_eq!(budget.refused(), 1); + // Reading each strip, and building the picture of 8 rows of 8 they make. + let cost = raw + 8 * (8 + 8) + 8 * 8; let mut budget = ImageBudget::with_work(u64::MAX); - assert_eq!(merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 8); - assert!(budget.work_left() < u64::MAX); + assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 8); + assert_eq!(u64::MAX - budget.work_left(), cost); + // Enough for the strips and not for the picture: nothing is built. + let mut budget = ImageBudget::with_work(cost - 1); + assert_eq!(merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0); + assert_eq!(budget.refused(), 1); } /// Many images drawn on top of each other are not one picture; looking for runs among them reads diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b501f6b675..a72f0fc277 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -490,6 +490,9 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBud // never get here: the colour space check excludes them. The JPEG's own size must be the // dictionary's: `jpeg_samples` refuses another from its header, before any pixels. let ycc = n == 3 && ycc_encoded(doc, s)?; + if !budget.pay_jpeg(u64::try_from(bytes).ok()?) { + return None; + } let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?; count_decoded(u64::from(w) * u64::from(h)); (v.len() == bytes).then_some(v) @@ -1496,7 +1499,7 @@ mod dimension_tests { dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); let image = Stream::flate(dict, &samples); - let read = (image.raw.len() + samples.len()) as u64; + let read = (image.raw.len() + samples.len()) as u64 + 256 * 256; let original = doc.add(Object::Stream(image.clone())); let mut xobjects = Dict::new(); xobjects.set(b"I0".to_vec(), Object::Ref(original)); @@ -1545,6 +1548,25 @@ mod dimension_tests { assert_eq!((report.images, report.images_refused), (1, 0), "{report:?}"); } + /// A JPEG pays for its samples before it is decoded, however few bytes it takes: past what is + /// left it isn't, and counts as refused. + #[test] + fn a_jpeg_pays_for_its_samples() { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(8)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let jpeg = image_stream(&dict, &[120; 16 * 8 * 3], 3, 16, 8, Compression::Jpeg(60), false).expect("a JPEG"); + let doc = Document::new_empty(); + let mut budget = ImageBudget::with_work(2 * 16 * 8 * 3); + assert_eq!(pixels(&doc, &jpeg, 3, &mut budget).map(|p| p.len()), Some(16 * 8 * 3)); + assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that"); + let mut budget = ImageBudget::with_work(2 * 16 * 8 * 3 - 1); + assert!(pixels(&doc, &jpeg, 3, &mut budget).is_none()); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); + } + /// A soft mask resampled with its image is read from the same allowance as the image. #[test] fn a_soft_mask_resampled_with_its_image_spends_the_allowance() { @@ -1568,7 +1590,7 @@ mod dimension_tests { }; let mut doc = Document::new_empty(); let mask = image("DeviceGray"); - let cost = (mask.raw.len() + noise.len()) as u64; + let cost = (mask.raw.len() + noise.len()) as u64 + 256 * 256; let masked = doc.add(Object::Stream(mask)); let mut with_mask = image("DeviceGray"); with_mask.dict.set(b"SMask".to_vec(), Object::Ref(masked)); diff --git a/crates/redact/README.md b/crates/redact/README.md index 04fcd788fb..dddf17bf24 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -34,7 +34,7 @@ clear_marks(&mut doc, None)?; // remove marks without app twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; A run that would decode more of its images than one redaction may (`pdfcraft_cos::ImageBudget`, - filters and fax and JBIG2 decoders alike) is refused + filters, decoders and pixels alike) is refused and changes nothing: such an image can't be cleared, and removing it whole would take what no mark covers with it. - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 20eac4656f..d9b73b0489 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7409,7 +7409,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] -notes = "All of a run's images share one allowance of decoding, filters included and JPEG data aside (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" From 7bfdfd293a9a5e1b13a6233a792c764a2b706c8c Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 01:01:22 +0100 Subject: [PATCH 080/562] M2.6: image export pays for a JPEG mask, and reads a JPEG's size first A JPEG that was itself a mask paid nothing, and images can share one: 300 images in 66 KB took 135 s with none refused, about 0.45 s for 220 bytes. It now pays for its samples each time an image reads it. Export priced a JPEG by its dictionary and decoded the size in its header, comparing them only afterwards: an image of one pixel over a header of 500 megapixels cost 2 steps and 0.3 s. Both paths now read the header first and leave a JPEG that disagrees undecoded, as Optimize and the CMYK path do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/create/README.md | 4 +- crates/create/src/extract.rs | 38 +++++++++++++++--- crates/create/src/tests.rs | 78 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/create/README.md b/crates/create/README.md index 39a47bc21f..91e5121978 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -32,8 +32,8 @@ other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, a whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded), or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are. -Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG only when a mask -means decoding it), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that +Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG when it has to be +decoded, as a mask or to apply one, and not when it is written as it is), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that is more, since they stay in memory until it ends. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 408f9b13ff..416f19cab0 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -285,6 +285,32 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> { (w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h)) } +#[cfg(test)] +thread_local! { + /// JPEGs decoded whole, for the tests that keep one from being decoded for nothing. + pub(crate) static JPEGS_DECODED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary +/// says: an image of one pixel can hold a header of 500 megapixels. +fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> { + use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions}; + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + dec.dimensions() +} + +/// JPEG `data`, decoded, if its header says it is `w` × `h`. +fn jpeg_of_size(data: &[u8], w: usize, h: usize) -> Option { + if jpeg_size(data)? != (w, h) { + return None; + } + #[cfg(test)] + JPEGS_DECODED.with(|n| n.set(n.get() + 1)); + image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok() +} + /// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB, /// with the space they are in. fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec, Space)> { @@ -293,10 +319,7 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)), _ => {} } - let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?; - if img.width() as usize != w || img.height() as usize != h { - return None; - } + let img = jpeg_of_size(data, w, h)?; Some(match space { Space::Gray => (img.into_luma8().into_raw(), Space::Gray), _ => (img.into_rgb8().into_raw(), Space::Rgb), @@ -521,8 +544,11 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut Image let f = filters(m); let mut px = match f.last().map(Vec::as_slice) { Some(b"DCTDecode" | b"DCT") if f.len() == 1 => { - let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8(); - (img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())? + // Past the allowance the image goes too: `image` sees this one refused. + if jpeg_size(&m.raw)? != (mw, mh) || !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64)) { + return None; + } + jpeg_of_size(&m.raw, mw, mh)?.into_luma8().into_raw() } Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None, _ => { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 3d4a0387b0..9a4d91e29d 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2357,6 +2357,84 @@ fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() { assert_eq!(budget.refused(), 1); } +/// A JPEG that is itself a mask pays for its samples, each time an image reads it: images can +/// share one, and only the images are ever seen once. +#[test] +fn a_jpeg_mask_pays_for_its_samples() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut jpeg = Vec::new(); + image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let mut mask = Dict::new(); + mask.set(b"Subtype".to_vec(), Object::name("Image")); + mask.set(b"Width".to_vec(), Object::Int(4)); + mask.set(b"Height".to_vec(), Object::Int(4)); + mask.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + mask.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let mut doc = Document::new_empty(); + let mask = doc.add(Object::Stream(Stream::from_raw(mask, jpeg))); + let mut image = Dict::new(); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok()); + assert_eq!(u64::MAX - budget.work_left(), 2 * 4 * 4 + 2, "its samples twice, and the image's byte and pixel"); + // Just short of what the mask costs: it isn't decoded, and takes the image with it. + let mut budget = ImageBudget::with_work(2 * 4 * 4 - 1); + let refused = crate::extract::image_file_within(&doc, image, &mut budget).unwrap_err(); + assert!(refused.contains("more image decoding than"), "{refused}"); + assert_eq!((budget.work_left(), budget.refused()), (0, 1)); +} + +/// What decoding a JPEG takes goes by its header, so that is what has to agree with the +/// dictionary before it is decoded: one image of a pixel can hold a page of 500 megapixels. The +/// file comes out as it is either way, so what shows is whether it was decoded. +#[test] +fn a_jpeg_larger_than_its_dictionary_says_isnt_decoded() { + use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream}; + let mut jpeg = Vec::new(); + image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + // A colour key means decoding it, to find the pixels it hides. + let keyed = |side: i64| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0); 6])); + Stream::from_raw(d, jpeg.clone()) + }; + let decodes = |doc: &Document, image: pdfcraft_cos::ObjRef| { + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + assert!(crate::extract::image_file_within(doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok()); + crate::extract::JPEGS_DECODED.with(std::cell::Cell::get) + }; + let mut doc = Document::new_empty(); + let (honest, lying) = (doc.add(Object::Stream(keyed(4))), doc.add(Object::Stream(keyed(1)))); + assert_eq!(decodes(&doc, honest), 1); + assert_eq!(decodes(&doc, lying), 0); + // The same as a mask: read as none, without being decoded. + let mut mask = keyed(1); + mask.dict.remove(b"Mask"); + let mask = doc.add(Object::Stream(mask)); + let mut image = Dict::new(); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(1)); + image.set(b"Height".to_vec(), Object::Int(1)); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); + assert_eq!(decodes(&doc, image), 0); +} + /// A mask past the allowance can't be left out as an unreadable one is, or the image comes out /// opaque, showing what the mask hides: the image goes, with the reason. #[test] From 2fb88feaf75cc43e12535e522714eca6d2b054dd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 01:28:08 +0100 Subject: [PATCH 081/562] M2.6: a JPEG pays for being read before its header is Reading a JPEG's header was free, and it can be most of the file: a mask of 1 MiB of tables that disagreed with its dictionary, shared by 4,000 images, took 101 s with none refused, and that time grows as the square of the file. A JPEG now pays 4 steps a byte before its tables are parsed, on every path, and a mask's header is read once. That is a bound and not a speed-up: the same file of 4,000 images fits the allowance and still takes 97 s, and 12,000 stop after 10,233 in 250 s. A chain that can't run pays nothing for its pixels, and the docs say what past the allowance isn't counted: LZW streams the image pass didn't read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/automation/src/tools.rs | 4 +-- crates/cos/src/budget.rs | 53 +++++++++++++++++++++++++++------- crates/create/README.md | 2 +- crates/create/src/extract.rs | 4 +-- crates/create/src/tests.rs | 21 ++++++++++---- crates/optimize/README.md | 2 +- crates/optimize/src/images.rs | 7 +++-- parity/acrobat-features.toml | 4 +-- 8 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 285bf9b1df..f25e268258 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1340,7 +1340,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. Images past what one run may decode stay as they are, counted in images_refused. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( @@ -1442,7 +1442,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 8b9fb37135..75ed03ea1a 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -28,6 +28,9 @@ pub const MAX_PAGE_FORMS: usize = 50_000; /// The steps a sample of a JPEG costs: a few bytes can hold a page of one grey, and decoding, /// resampling and storing it took about 20 to 45 ns a sample. const JPEG_WORK: u64 = 2; +/// The steps a byte of a JPEG costs to read: its tables took 24 ns a byte before a size was known, +/// and images can share one, so reading it again for each has to count. +const JPEG_READ_WORK: u64 = 4; /// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or /// the error, each with that count. @@ -77,7 +80,9 @@ impl ImageBudget { /// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an /// image that has no fax or JBIG2 decoder to go through. pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { - self.pay_size(s)?; + if s.filters().iter().all(|(f, _)| f.is_decodable()) { + self.pay_size(s)?; + } let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work); self.count_refused(s, &decoded); decoded @@ -109,9 +114,11 @@ impl ImageBudget { paid } - /// Pays for decoding a JPEG of `samples`, as [`ImageBudget::pay_pixels`] does. - pub fn pay_jpeg(&mut self, samples: u64) -> bool { - self.pay_pixels(samples.saturating_mul(JPEG_WORK)) + /// Pays for reading JPEG `data` and decoding its `samples`, as [`ImageBudget::pay_pixels`] does. + /// A caller pays before its tables are parsed, which can be most of the file. + pub fn pay_jpeg(&mut self, samples: u64, data: &[u8]) -> bool { + let reading = (data.len() as u64).saturating_mul(JPEG_READ_WORK); + self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading)) } /// Pays for the pixels of image `s`, unless it is JBIG2, whose decoder pays for its page, or @@ -143,10 +150,15 @@ impl ImageBudget { } } -/// Whether `s` takes only filters and decoders this charges. +/// Whether `s` can be decoded here and so charged: general filters, a fax or JBIG2 decoder last at +/// most. Those decoders anywhere else can't run. fn charged(s: &Stream) -> bool { use pdfcraft_filters::Filter; - s.readable_filters().is_ok_and(|chain| chain.iter().all(|(f, _)| f.is_decodable() || matches!(f, Filter::Jbig2 | Filter::CcittFax))) + s.readable_filters().is_ok_and(|chain| { + chain.split_last().is_none_or(|((last, _), before)| { + (last.is_decodable() || matches!(last, Filter::Jbig2 | Filter::CcittFax)) && before.iter().all(|(f, _)| f.is_decodable()) + }) + }) } /// What one operation may still decode and enter across all the streams it reads. @@ -507,11 +519,11 @@ mod image_tests { let mut budget = ImageBudget::with_work(u64::MAX); budget.decode(&doc, &Stream::from_raw(odd, vec![1, 2]), 1 << 24).unwrap(); assert_eq!(u64::MAX - budget.work_left(), 2); - // A JPEG pays twice over for each sample, once decoded. - let mut budget = ImageBudget::with_work(20); - assert!(budget.pay_jpeg(9)); - assert_eq!((budget.work_left(), budget.refused()), (2, 0)); - assert!(!budget.pay_jpeg(2)); + // A JPEG pays twice over for each sample, and four times for each byte it is read from. + let mut budget = ImageBudget::with_work(30); + assert!(budget.pay_jpeg(9, b"ab")); + assert_eq!((budget.work_left(), budget.refused()), (4, 0)); + assert!(!budget.pay_jpeg(0, b"ab")); assert_eq!((budget.work_left(), budget.refused()), (0, 1)); } @@ -567,6 +579,25 @@ mod image_tests { let mut budget = ImageBudget::with_work(u64::MAX); assert!(budget.decode_filters(&doc, &jbig2(None), 1 << 20).is_err()); assert_eq!(budget.work_left(), u64::MAX, "no decoder here, so nothing was read"); + // Nor for a fax image, nor for a decoder that isn't last, however large they say they are. + for chain in [vec!["CCITTFaxDecode"], vec!["CCITTFaxDecode", "FlateDecode"], vec!["JBIG2Decode", "FlateDecode"]] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(1000)); + dict.set(b"Height".to_vec(), Object::Int(1000)); + dict.set(b"Filter".to_vec(), Object::Array(chain.iter().map(|f| Object::name(f)).collect())); + let image = Stream::from_raw(dict, b"data".to_vec()); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err()); + assert_eq!(budget.work_left(), u64::MAX, "{chain:?}"); + } + // Through `decode` a decoder that isn't last pays nothing either; one that is does. + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(1000)); + dict.set(b"Height".to_vec(), Object::Int(1000)); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("JBIG2Decode"), Object::name("FlateDecode")])); + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err()); + assert_eq!(budget.work_left(), u64::MAX); } /// The images of a file share its globals, so an image refused doesn't read them: this one's diff --git a/crates/create/README.md b/crates/create/README.md index 91e5121978..f72f70aa26 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -33,7 +33,7 @@ whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into t or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are. Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG when it has to be -decoded, as a mask or to apply one, and not when it is written as it is), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that +decoded, as a mask, to apply one, or to undo a CMYK file's inversion, and not when it is written as it is), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that is more, since they stay in memory until it ends. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 416f19cab0..264af5444b 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -185,7 +185,7 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat } let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? }; let (samples, bpc) = if jpeg { - if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64)) { + if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) { return Err(FilterError::Spent.to_string()); } // Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an @@ -545,7 +545,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut Image let mut px = match f.last().map(Vec::as_slice) { Some(b"DCTDecode" | b"DCT") if f.len() == 1 => { // Past the allowance the image goes too: `image` sees this one refused. - if jpeg_size(&m.raw)? != (mw, mh) || !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64)) { + if !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64), &m.raw) { return None; } jpeg_of_size(&m.raw, mw, mh)?.into_luma8().into_raw() diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9a4d91e29d..7b4e8649fa 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2312,8 +2312,8 @@ fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() { assert_eq!(budget.refused(), 1); } -/// A JPEG is written as it is, at no cost, unless a mask means decoding it: that pays for its -/// samples, and past the allowance the image goes, with the reason, as it would come out without +/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that +/// pays for its samples, and past the allowance the image goes, with the reason, as it would come out without /// its transparency otherwise. #[test] fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() { @@ -2341,6 +2341,7 @@ fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() { mask.set(b"BitsPerComponent".to_vec(), Object::Int(8)); mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0x80; 16]))); + let doc_jpeg_len = jpeg.len(); let masked = doc.add(Object::Stream(Stream::from_raw(image_dict(Some((b"SMask", Object::Ref(mask)))), jpeg.clone()))); let plain = doc.add(Object::Stream(Stream::from_raw(image_dict(None), jpeg))); @@ -2350,7 +2351,8 @@ fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() { let mut budget = ImageBudget::with_work(u64::MAX); assert!(crate::extract::image_file_within(&doc, masked, &mut budget).is_ok()); let cost = u64::MAX - budget.work_left(); - assert_eq!(cost, 16 + 16 + 2 * 4 * 4 * 3, "the mask's bytes and pixels, and the JPEG's samples twice"); + let read = 4 * doc_jpeg_len as u64; + assert_eq!(cost, 16 + 16 + 2 * 4 * 4 * 3 + read, "the mask's bytes and pixels, the JPEG's samples twice and its reading"); let mut budget = ImageBudget::with_work(cost - 1); let refused = crate::extract::image_file_within(&doc, masked, &mut budget).unwrap_err(); assert!(refused.contains("more image decoding than"), "{refused}"); @@ -2372,6 +2374,7 @@ fn a_jpeg_mask_pays_for_its_samples() { mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); mask.set(b"Filter".to_vec(), Object::name("DCTDecode")); let mut doc = Document::new_empty(); + let read = 4 * jpeg.len() as u64; let mask = doc.add(Object::Stream(Stream::from_raw(mask, jpeg))); let mut image = Dict::new(); image.set(b"Subtype".to_vec(), Object::name("Image")); @@ -2383,9 +2386,9 @@ fn a_jpeg_mask_pays_for_its_samples() { let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); let mut budget = ImageBudget::with_work(u64::MAX); assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok()); - assert_eq!(u64::MAX - budget.work_left(), 2 * 4 * 4 + 2, "its samples twice, and the image's byte and pixel"); + assert_eq!(u64::MAX - budget.work_left(), 2 * 4 * 4 + read + 2, "its samples twice, its reading, and the image's byte and pixel"); // Just short of what the mask costs: it isn't decoded, and takes the image with it. - let mut budget = ImageBudget::with_work(2 * 4 * 4 - 1); + let mut budget = ImageBudget::with_work(2 * 4 * 4 + read - 1); let refused = crate::extract::image_file_within(&doc, image, &mut budget).unwrap_err(); assert!(refused.contains("more image decoding than"), "{refused}"); assert_eq!((budget.work_left(), budget.refused()), (0, 1)); @@ -2433,6 +2436,14 @@ fn a_jpeg_larger_than_its_dictionary_says_isnt_decoded() { image.set(b"SMask".to_vec(), Object::Ref(mask)); let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80]))); assert_eq!(decodes(&doc, image), 0); + // Reading the header to find that out is paid for: a mask can be mostly tables, shared by + // every image of a file, and with nothing left it isn't read. + let mut budget = ImageBudget::with_work(u64::MAX); + assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok()); + assert_eq!(u64::MAX - budget.work_left(), 2 + 4 * jpeg.len() as u64 + 2, "its one declared sample twice, its bytes, the image's byte and pixel"); + let mut budget = ImageBudget::with_work(0); + assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_err()); + assert_eq!(budget.refused(), 1, "the image goes, with its mask unread"); } /// A mask past the allowance can't be left out as an unreadable one is, or the image comes out diff --git a/crates/optimize/README.md b/crates/optimize/README.md index b71c2c0277..d7f8869c12 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -105,7 +105,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going pictures too: about 1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused` - counts them. + counts those the image step read. Streams it only stores with Flate (below) aren't counted. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index a72f0fc277..ebdad5396f 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -490,7 +490,7 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBud // never get here: the colour space check excludes them. The JPEG's own size must be the // dictionary's: `jpeg_samples` refuses another from its header, before any pixels. let ycc = n == 3 && ycc_encoded(doc, s)?; - if !budget.pay_jpeg(u64::try_from(bytes).ok()?) { + if !budget.pay_jpeg(u64::try_from(bytes).ok()?, &s.raw) { return None; } let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?; @@ -1559,10 +1559,11 @@ mod dimension_tests { dict.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); let jpeg = image_stream(&dict, &[120; 16 * 8 * 3], 3, 16, 8, Compression::Jpeg(60), false).expect("a JPEG"); let doc = Document::new_empty(); - let mut budget = ImageBudget::with_work(2 * 16 * 8 * 3); + let cost = 2 * 16 * 8 * 3 + 4 * jpeg.raw.len() as u64; + let mut budget = ImageBudget::with_work(cost); assert_eq!(pixels(&doc, &jpeg, 3, &mut budget).map(|p| p.len()), Some(16 * 8 * 3)); assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that"); - let mut budget = ImageBudget::with_work(2 * 16 * 8 * 3 - 1); + let mut budget = ImageBudget::with_work(cost - 1); assert!(pixels(&doc, &jpeg, 3, &mut budget).is_none()); assert_eq!((budget.work_left(), budget.refused()), (0, 1)); } diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d9b73b0489..a8d2ce3f32 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7189,7 +7189,7 @@ status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_images_behind_another_filter_export_as_their_picture"] -notes = "each image once (page resources and nested forms), up to what one export may decode of JBIG2 and hold of files (2 GiB or twice the document), images past that left out with the reason; JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." +notes = "each image once (page resources and nested forms), up to what one export may decode and hold of files (2 GiB or twice the document), images past that left out with the reason; JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)." [[feature]] id = "create.export-html" @@ -7409,7 +7409,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/optimize/src/images.rs::flate_images_past_the_runs_allowance_stay_as_they_are", "crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"] -notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." +notes = "All of a run's images share one allowance of decoding, filters, decoders and pixels alike (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are, those the image pass read being counted in the report, which doc_optimize and doc_reduce return and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples." [[feature]] id = "optimize.only-if-smaller" From 6734f9f3b4ef5c2fcc76265bb21949c57a3ca88d Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:33:17 +1100 Subject: [PATCH 082/562] fix(ui): Format text fields read as fields, line up, and fit the Tools pane (#5634) The font, line spacing, paragraph spacing, scale and character spacing fields get an outline and fill, with one box round each number and its arrow. Line and paragraph spacing text is centred, and the two-column rows end at the font list's right edge. The Tools pane has equal 14 pt margins and a minimum width worked out from its widest Format text row, so the alignment button is no longer cut off. The Edit and tool-detail rows stop short of the floating scrollbar, through a helper shared with All tools (#5133). Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/edit.rs | 2 + crates/ui-egui/src/panels/format_text_ui.rs | 195 +++++++++++++------- crates/ui-egui/src/panels/side_panels.rs | 9 +- crates/ui-egui/src/panels/tools.rs | 14 +- crates/ui-egui/tests/it/content.rs | 28 +++ 5 files changed, 176 insertions(+), 72 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e95db85a72..57d8870397 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -55,6 +55,7 @@ pub(super) fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g // Format text at the top while text is selected or being added. format_section(app, ui, t); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + super::tools::clear_of_scrollbar(ui); widgets::section_title(ui, tl!("Modify page")); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; @@ -185,6 +186,7 @@ pub(super) fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens use pdfcraft_engine::{StampGroup, StampKind}; ui.label(egui::RichText::new(tl!("Choose a stamp, then click on the page to place it.")).small().color(t.text_faint)); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + super::tools::clear_of_scrollbar(ui); for (group, title) in [ (StampGroup::Dynamic, tl!("Dynamic")), (StampGroup::SignHere, tl!("Sign Here")), diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index eccef2aa79..dd60b3bcc8 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -102,6 +102,18 @@ const LABEL_ICON: f32 = 22.0; /// The button beside a number field that opens its list of common values: just an arrow, so the /// field keeps the room its value needs. const PRESET_LIST: f32 = 22.0; +/// The room a number field keeps for its value ("-5.0 pt"), beside the arrow in its box. +const NUMBER_MIN: f32 = 52.0; +/// The least width the panel's widest rows need, which sets how narrow the Tools pane may be +/// dragged. Size and alignment: the size field and its arrow (in their box), the swatch, the +/// divider, three 30 pt buttons, and the gaps between those six. The spacing rows: two icons and +/// two number fields with their arrows (in a box each), the space between the columns, and four +/// gaps. +pub(crate) const MIN_ROW: f32 = { + let size_row = (40.0 + PRESET_LIST + 2.0) + 28.0 + 9.0 + 3.0 * 30.0 + 5.0 * GAP; + let spacing_row = 2.0 * LABEL_ICON + 2.0 * (NUMBER_MIN + PRESET_LIST + 2.0) + 8.0 + 4.0 * GAP; + if size_row > spacing_row { size_row } else { spacing_row } +}; /// What can be typed: horizontal scale in percent, character spacing in points. const SCALE_RANGE: std::ops::RangeInclusive = 10.0..=400.0; const CHAR_SPACING_RANGE: std::ops::RangeInclusive = -5.0..=50.0; @@ -126,52 +138,54 @@ pub(crate) fn panel( // The font, across the panel. let width = ui.available_width(); - craft_ui::ComboBox::from_id_salt("format-font") - .selected_text(fonts.shown.as_str()) - .width(width) - .show_ui(ui, |ui| { - if let Some(sources) = fonts.sources { - if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { - out.font = Some(FontChoice::KeepSource); - } - for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { - if ui.selectable_label(false, face).clicked() { - out.font = Some(FontChoice::Resource(resource.clone())); + outlined(ui, |ui| { + craft_ui::ComboBox::from_id_salt("format-font") + .selected_text(fonts.shown.as_str()) + .width(width) + .show_ui(ui, |ui| { + if let Some(sources) = fonts.sources { + if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { + out.font = Some(FontChoice::KeepSource); } - } - ui.separator(); - } - for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { - if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { - out.font = Some(FontChoice::Family(f)); - } - } - if fonts.sources.is_some() { - ui.separator(); - if ui.selectable_label(false, tl!("Load font…")).clicked() { - out.font = Some(FontChoice::Load); - } - #[cfg(not(target_arch = "wasm32"))] - ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { - Some(list) if list.is_empty() => { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - Some(list) => { - for font in list { - if ui.button(&font.face).clicked() { - out.font = Some(FontChoice::Installed(font.path)); - ui.close(); - } + for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { + if ui.selectable_label(false, face).clicked() { + out.font = Some(FontChoice::Resource(resource.clone())); } } - None => { - ui.label(tl!("Finding document fonts…")); + ui.separator(); + } + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { + out.font = Some(FontChoice::Family(f)); } - }); - } - }) - .response - .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))); + } + if fonts.sources.is_some() { + ui.separator(); + if ui.selectable_label(false, tl!("Load font…")).clicked() { + out.font = Some(FontChoice::Load); + } + #[cfg(not(target_arch = "wasm32"))] + ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { + Some(list) if list.is_empty() => { + ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + } + Some(list) => { + for font in list { + if ui.button(&font.face).clicked() { + out.font = Some(FontChoice::Installed(font.path)); + ui.close(); + } + } + } + None => { + ui.label(tl!("Finding document fonts…")); + } + }); + } + }) + .response + .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))) + }); ui.add_space(4.0); // Size and colour | bullets, numbering and alignment. @@ -253,25 +267,31 @@ pub(crate) fn panel( let e = extras.as_deref_mut().unwrap_or(&mut fallback); ui.add_enabled_ui(editing, |ui| { row(ui, |ui| { - labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); + let label = labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); let own = tl!("Auto").to_string(); let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; - craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { - ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); - for v in LINE_SPACINGS { - ui.selectable_value(&mut e.line_spacing, v, number(v)); - } + outlined(ui, |ui| { + let list = craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(centred(ui, &shown)).width(half).show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + list.response.labelled_by(label.id); }); ui.add_space(8.0); ui.add_enabled_ui(false, |ui| { - labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); + let label = labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); - let _ = craft_ui::ComboBox::from_id_salt("format-paragraph-spacing") - .selected_text("0") - .width(half) - .show_ui(ui, |_| {}) - .response - .on_disabled_hover_text(tip); + let _ = outlined(ui, |ui| { + craft_ui::ComboBox::from_id_salt("format-paragraph-spacing") + .selected_text(centred(ui, "0")) + .width(half) + .show_ui(ui, |_| {}) + .response + .labelled_by(label.id) + .on_disabled_hover_text(tip) + }); }); }); }); @@ -296,6 +316,22 @@ pub(crate) fn panel( out } +/// Runs `add` with the panel's fields outlined and filled, so a list or number field reads as one +/// beside the plain icon buttons around it. +fn outlined(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.scope(|ui| { + let t = Tokens::get(ui.ctx()); + let w = &mut ui.visuals_mut().widgets; + w.inactive.weak_bg_fill = t.field; + w.inactive.bg_stroke = Stroke::new(1.0, t.border); + w.hovered.bg_stroke = Stroke::new(1.0, t.text_muted); + // A list's text, pad and arrow touch: its gaps would widen it past the column it is given. + ui.spacing_mut().icon_spacing = 0.0; + add(ui) + }) + .inner +} + /// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it. fn row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let size = vec2(ui.available_width(), ROW); @@ -344,15 +380,45 @@ struct Number<'a> { suffix: &'static str, } +/// A combo box's selected text, centred in its button. The button lays out the text and then its +/// arrow, and every growing atom takes an equal share of the room left: a pad as wide as the arrow +/// before the text balances the arrow after it, and the text centres itself in its own share. +fn centred<'a>(ui: &egui::Ui, text: &str) -> egui::Atoms<'a> { + use egui::AtomExt as _; + let pad = egui::Atom::custom(egui::IdSalt::new("combo-pad"), vec2(ui.spacing().icon_width, 0.0)).atom_grow(true); + egui::Atoms::new((pad, egui::Atom::from(text.to_string()).atom_grow(true).atom_align(egui::Align2::CENTER_CENTER))) +} + +/// The arrow that lists a field's common values, centred on the 26-point field beside it. +fn preset_arrow(ui: &mut egui::Ui, label: &str) -> egui::Response { + let (slot, _) = ui.allocate_exact_size(vec2(PRESET_LIST, 26.0), Sense::hover()); + let at = Rect::from_center_size(slot.center(), vec2(PRESET_LIST, PRESET_LIST)); + ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| icons::button(ui, "chevron-down", PRESET_LIST, false, label)).inner +} + +/// One box round a value and its arrow, like a list's, so the pair reads as a single control. +fn boxed(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { + let t = Tokens::get(ui.ctx()); + let edge = if ui.is_enabled() { t.border } else { t.border.gamma_multiply(0.5) }; + egui::Frame::new().fill(t.field).stroke(Stroke::new(1.0, edge)).corner_radius(CornerRadius::same(4)).show(ui, |ui| { + let w = &mut ui.visuals_mut().widgets; + for state in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { + state.bg_stroke = Stroke::NONE; + } + w.inactive.weak_bg_fill = Color32::TRANSPARENT; + ui.spacing_mut().item_spacing.x = 0.0; + ui.spacing_mut().button_padding.x = 2.0; + add(ui); + }); +} + /// A number field with an arrow beside it that lists common values, in `width` all told: type any /// value in its range, or pick one. Both are named by `label`: the icon before them and its text. fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) { - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + boxed(ui, |ui| { let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix); - let height = ui.spacing().interact_size.y; - ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0); - let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1); + ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), 26.0], field).labelled_by(label.0); + let list = preset_arrow(ui, label.1); egui::Popup::menu(&list).show(|ui| { for &v in presets { if ui.selectable_label(*n.value == v, number(v)).clicked() { @@ -379,15 +445,14 @@ fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Respon /// Commit a typed size once (Enter or leaving the field), rather than one undo step per key. /// The bounds match AddedText validation, so the panel also accepts coloured text below 8 pt. fn font_size_input(ui: &mut egui::Ui, size: &mut f64) { - ui.scope(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + boxed(ui, |ui| { let id = ui.make_persistent_id("font-size-input"); let (previous, mut text) = ui.data(|d| d.get_temp::<(u64, String)>(id)).unwrap_or_else(|| (size.to_bits(), size.to_string())); if previous != size.to_bits() { text = size.to_string(); } - let field = egui::TextEdit::singleline(&mut text).id(id).desired_width(40.0); - let response = ui.add_sized([40.0, ui.spacing().interact_size.y], field).accessible_name(tl!("Font size:")); + let field = egui::TextEdit::singleline(&mut text).id(id).desired_width(40.0).frame(egui::Frame::NONE).align(egui::Align2::CENTER_CENTER); + let response = ui.add_sized([40.0, 26.0], field).accessible_name(tl!("Font size:")); let cancel = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { text = size.to_string(); @@ -399,7 +464,7 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64) { text = size.to_string(); } let before = *size; - let list = icons::button(ui, "chevron-down", PRESET_LIST, false, tl!("Font size")); + let list = preset_arrow(ui, tl!("Font size")); egui::Popup::menu(&list).show(|ui| { for value in SIZES { if ui.selectable_label(*size == value, format!("{value} pt")).clicked() { diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index 71c3baa648..1fdebdfaa1 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -17,7 +17,10 @@ use crate::{Mode, PdfCraftApp}; pub const TOOLS_WIDTH: f32 = 272.0; /// Default width of the side panel. pub const SIDE_WIDTH: f32 = 330.0; -const TOOLS_RANGE: RangeInclusive = 240.0..=520.0; +/// The Tools pane's own margins, left and right (14 pt each, and a pt of border), around what a panel lays out. +const TOOLS_GUTTERS: f32 = 30.0; +/// No narrower than Format text's widest row, so nothing in it is cut off. +const TOOLS_RANGE: RangeInclusive = (crate::format_text_ui::MIN_ROW + TOOLS_GUTTERS)..=520.0; const SIDE_RANGE: RangeInclusive = 260.0..=520.0; /// Version of the saved `side_panels` value. const VERSION: u64 = 1; @@ -481,11 +484,11 @@ mod tests { json!([]), json!([ ["inspector", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 380.0}, "stack": null, "sibling": ["canvas"]}], - ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 250.0}, "stack": null, "sibling": ["canvas"]}] + ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 300.0}, "stack": null, "sibling": ["canvas"]}] ]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 380.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 9d106e38b3..02b86ec688 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -17,7 +17,7 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if app.left == LeftPanel::Tool("organize") && app.active.and_then(|i| app.views.get(i)).is_some_and(|v| !v.organize) { app.left = crate::organize_panel::workspace_panel(app.mode); } - egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( + egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 14, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( ui, |ui| match app.left { // Organize pages has its own panel whenever the grid shows (with a document open). @@ -58,9 +58,15 @@ pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, lead: Opt (go_back, close) } -/// Width kept free at the right of the tool list for the floating scrollbar. +/// Width kept free at the right of a scrolling list for the floating scrollbar. const SCROLLBAR_GUTTER: f32 = 18.0; +/// The scrollbar floats over the right edge: keeps the rows of the scroll area `ui` is in (and +/// their rounded hover wash) clear of it. +pub(super) fn clear_of_scrollbar(ui: &mut egui::Ui) { + ui.set_max_width((ui.available_width() - SCROLLBAR_GUTTER).max(0.0)); +} + /// The All tools sidebar: Expand (the full view) and Close, then the tool list, the first few /// tools until View more shows them all. fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { @@ -73,8 +79,7 @@ fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { app.left_open = false; } egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - // The scrollbar floats over the right edge: keep the rows (and their rounded hover wash) clear of it. - ui.set_max_width((ui.available_width() - SCROLLBAR_GUTTER).max(0.0)); + clear_of_scrollbar(ui); ui.add_space(2.0); let shown = if app.all_tools_expanded { TOOL_GROUPS.len() } else { COLLAPSED_TOOLS.min(TOOL_GROUPS.len()) }; for g in &TOOL_GROUPS[..shown] { @@ -210,6 +215,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static let marks = if footer { app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()) } else { 0 }; let list_h = if footer { (ui.available_height() - 52.0).max(80.0) } else { ui.available_height() }; egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| { + clear_of_scrollbar(ui); ui.add_space(2.0); for s in g.sections { widgets::section_title(ui, tl!(s.title)); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index cb29ba99be..f7f595c2dd 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -721,6 +721,34 @@ fn format_text_rows_line_up_and_fit_the_panel() { } } +/// The Tools pane can't be dragged narrower than Format text needs: at its smallest width no +/// control reaches past the font list, which spans the panel. +#[test] +fn format_text_fits_the_tools_pane_at_its_narrowest() { + use egui::accesskit::Role; + let mut h = harness(); + h.state_mut().side_panels.tools_width = 0.0; + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let font = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Helvetica")).map(|n| n.rect()).expect("the font list"); + let rects = ["Alignment", "Colour", "Font size", "Character spacing", "Horizontal scale", "Line spacing", "Paragraph spacing"] + .into_iter() + .flat_map(|l| h.query_all_by_label(l).map(move |n| (l, n.rect()))) + .chain(h.query_all_by_role(Role::ComboBox).map(|n| ("list", n.rect()))) + .collect::>(); + for (label, r) in rects { + assert!(r.right() <= font.right() + 0.5, "{label} {r:?} reaches past the font list ({font:?})"); + } + // The rows below scroll, and the scrollbar floats over their right edge: they stop short of it. + let image = h.get_by_label("Image").rect(); + assert!(image.right() <= font.right() - 17.0, "the Image row {image:?} runs under the scrollbar ({font:?})"); + // The right-hand column ends where the font list does, as the left one starts where it does. + for label in ["Paragraph spacing", "Character spacing"] { + let right = h.query_all_by_label(label).map(|n| n.rect().right()).fold(f32::MIN, f32::max); + assert!((right - font.right()).abs() < 1.5, "{label} ends at {right}, the font list at {}", font.right()); + } +} + #[test] fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { use egui_kittest::kittest::NodeT; From 6365e2ed476aa9ddd1b7bb137f32af6e6575f142 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:42:02 +1100 Subject: [PATCH 083/562] fix(ui): centre the quick-tools bar in the margin beside the page (#5635) The bar stood a fixed 14 pt from the side panel, so its gap to the page changed with zoom and window size. It now centres in the margin between the panel and the page when the margin holds it, within the first 100 pt; otherwise it keeps 14 pt in. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 4 +++- crates/ui-egui/src/canvas/bars.rs | 39 ++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5625587a69..62fc39ccae 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3486,7 +3486,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { - quick_bar(app, avail, ui); + // The page nearest the left edge, whose margin the bar sits in. + let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min); + quick_bar(app, avail, page_left, ui); } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 2472366672..c5b4586946 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -276,10 +276,23 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te }); } -/// The floating quick-action bar at the left edge of the document area. -pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { +const QUICK_BAR_WIDTH: f32 = 46.0; +const QUICK_BAR_EDGE: f32 = 14.0; + +/// How far from the side panel the quick-action bar stands, given the width of the margin between +/// the panel and the page: centred in it when it holds the bar, else 14 pt in (a page filling the +/// width). +fn quick_bar_offset(margin: f32) -> f32 { + // shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel. + let margin = margin.min(100.0); + if margin >= QUICK_BAR_WIDTH + 2.0 * 8.0 { (margin - QUICK_BAR_WIDTH) / 2.0 } else { QUICK_BAR_EDGE } +} + +/// The floating quick-action bar in the margin between the side panel and the page. +pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let pos = area.left_top() + vec2(14.0, 14.0); + let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left())); + let pos = area.left_top() + vec2(x, QUICK_BAR_EDGE); egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) @@ -496,3 +509,23 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { }); }); } + +#[cfg(test)] +mod tests { + use super::quick_bar_offset; + + #[test] + fn the_quick_bar_centres_in_the_margin_that_holds_it() { + assert_eq!(quick_bar_offset(72.0), 13.0); + assert_eq!(quick_bar_offset(62.0), 8.0); + // A wide margin centres in its first 100 pt, not all of it. + assert_eq!(quick_bar_offset(400.0), 27.0); + } + + #[test] + fn the_quick_bar_keeps_14_pt_in_when_the_margin_cannot_hold_it() { + for margin in [61.9, 30.0, 0.0, -50.0, f32::NEG_INFINITY] { + assert_eq!(quick_bar_offset(margin), 14.0, "{margin}"); + } + } +} From 889472f16f643c726bd41d7cf978573712f3fba4 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:49:25 +1100 Subject: [PATCH 084/562] fix(ui): the rail's page box fits, and the zoom field looks like a field (#5636) The page box lost its right edge to the rail. The page and zoom fields now share one helper that boxes them and sizes them to the rail, with the same 12 pt font. Zoom out and zoom in are small buttons side by side under the zoom field. The rail's side padding is 4 pt, not 6. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 63 ++++++++++++++++++------------ crates/ui-egui/tests/it/content.rs | 16 ++++++++ 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa6233c133..4ead302998 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -531,6 +531,23 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Padding inside the rail's page and zoom fields. +const RAIL_FIELD_MARGIN: egui::Vec2 = egui::vec2(1.0, 4.0); + +/// A field the rail can type in: boxed, and the same size whatever it holds, so the page and zoom +/// fields match and fit the rail's width. Sized outright: a text field is at least a control wide. +fn rail_field(ui: &mut egui::Ui, t: &Tokens, edit: egui::TextEdit<'_>) -> egui::Response { + egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(5)) + .show(ui, |ui| { + ui.spacing_mut().interact_size.x = 0.0; + ui.add_sized([34.0, 22.0], edit) + }) + .inner +} + /// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the /// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -573,20 +590,12 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut page_display = None; let rail = Rail::new(egui::Id::unique("rail")) .edge(Edge::End) - .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); - } // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); @@ -606,18 +615,29 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } + // Out and in side by side right under the zoom field, so they are found with it. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 17.0, false, &tip).clicked() { + view.zoom_step(false); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 17.0, false, &tip).clicked() { + view.zoom_step(true); + } + }); let id = egui::Id::unique("zoom-input"); let draft = id.with("draft"); let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); let edit = egui::TextEdit::singleline(&mut typed) .id(id) - .desired_width(40.0) - .align(egui::Align2::CENTER_TOP) - .font(theme::medium(11.0)) - .text_color(t.text_muted) + .align(egui::Align2::CENTER_CENTER) + .font(theme::medium(12.0)) + .text_color(t.text) .frame(egui::Frame::NONE) - .margin(vec2(2.0, 2.0)); - let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + .margin(RAIL_FIELD_MARGIN); + let r = rail_field(ui, &t, edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); if r.gained_focus() { let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); @@ -677,16 +697,11 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } let edit = egui::TextEdit::singleline(&mut view.page_input) .id(page_input_id) - .desired_width(34.0) - .align(egui::Align2::CENTER_TOP) + .align(egui::Align2::CENTER_CENTER) .font(theme::medium(12.0)) - .margin(vec2(2.0, 4.0)); - let r = egui::Frame::NONE - .fill(t.field) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(5)) - .show(ui, |ui| ui.add(edit)) - .inner + .frame(egui::Frame::NONE) + .margin(RAIL_FIELD_MARGIN); + let r = rail_field(ui, &t, edit) .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")) .accessible_name(tl!("Current page")); if r.lost_focus() { diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index cb29ba99be..61c86644e4 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1425,3 +1425,19 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() { h.run_steps(3); 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"); } + +/// 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). +#[test] +fn rail_page_and_zoom_fields_match_and_fit_the_rail() { + let mut h = harness(); + h.run_steps(3); + let rail = h.get_by_label("Bookmarks").rect(); + let (page, zoom) = (h.get_by_label("Current page").rect(), h.get_by_label("Zoom").rect()); + assert_eq!((page.width(), page.height()), (zoom.width(), zoom.height()), "{page:?} vs {zoom:?}"); + for r in [page, zoom] { + assert!(r.left() >= rail.left() - 2.0 && r.right() <= rail.right() + 2.0, "{r:?} is cut off by the rail ({rail:?})"); + assert!((r.center().x - rail.center().x).abs() < 1.0, "{r:?} is off the rail's centre ({rail:?})"); + } +} From db0383b97c5b55cfe99718e95c64a20cb15b8e0f Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:12:07 +1100 Subject: [PATCH 085/562] fix(ui): the quick bar centres by its rendered width, and a NaN margin keeps 14 pt (#5635) Fill & Sign's Text and Date tools widen the bar, so the fit check and the centring now use last frame's width of the bar. A NaN margin used to count as 100 pt through `f32::min`. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/bars.rs | 36 ++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index c5b4586946..8fed30ffea 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -276,24 +276,29 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te }); } +/// The bar's narrowest form: one 32 pt button, 6 pt margins and a 1 pt border each side. const QUICK_BAR_WIDTH: f32 = 46.0; const QUICK_BAR_EDGE: f32 = 14.0; /// How far from the side panel the quick-action bar stands, given the width of the margin between -/// the panel and the page: centred in it when it holds the bar, else 14 pt in (a page filling the -/// width). -fn quick_bar_offset(margin: f32) -> f32 { +/// the panel and the page and the bar's own width: centred in the margin when it holds the bar, +/// else 14 pt in (a page filling the width). +fn quick_bar_offset(margin: f32, width: f32) -> f32 { // shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel. - let margin = margin.min(100.0); - if margin >= QUICK_BAR_WIDTH + 2.0 * 8.0 { (margin - QUICK_BAR_WIDTH) / 2.0 } else { QUICK_BAR_EDGE } + // `f32::min` returns the other operand for NaN, which would pass the fit check below. + let margin = if margin.is_nan() { 0.0 } else { margin.min(100.0) }; + if margin >= width + 2.0 * 8.0 { (margin - width) / 2.0 } else { QUICK_BAR_EDGE } } /// The floating quick-action bar in the margin between the side panel and the page. pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left())); + let id = egui::Id::unique("quick-bar"); + // Last frame's bar: Fill & Sign's Text and Date tools add a wider row of controls to it. + let width = ui.ctx().memory(|m| m.area_rect(id)).map_or(QUICK_BAR_WIDTH, |bar| bar.width().max(QUICK_BAR_WIDTH)); + let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left()), width); let pos = area.left_top() + vec2(x, QUICK_BAR_EDGE); - egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) .stroke(Stroke::new(1.0, t.border)) @@ -516,16 +521,23 @@ mod tests { #[test] fn the_quick_bar_centres_in_the_margin_that_holds_it() { - assert_eq!(quick_bar_offset(72.0), 13.0); - assert_eq!(quick_bar_offset(62.0), 8.0); + assert_eq!(quick_bar_offset(72.0, 46.0), 13.0); + assert_eq!(quick_bar_offset(62.0, 46.0), 8.0); // A wide margin centres in its first 100 pt, not all of it. - assert_eq!(quick_bar_offset(400.0), 27.0); + assert_eq!(quick_bar_offset(400.0, 46.0), 27.0); + } + + #[test] + fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() { + // Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it. + assert_eq!(quick_bar_offset(120.0, 70.0), 15.0); + assert_eq!(quick_bar_offset(72.0, 70.0), 14.0); } #[test] fn the_quick_bar_keeps_14_pt_in_when_the_margin_cannot_hold_it() { - for margin in [61.9, 30.0, 0.0, -50.0, f32::NEG_INFINITY] { - assert_eq!(quick_bar_offset(margin), 14.0, "{margin}"); + for margin in [61.9, 30.0, 0.0, -50.0, f32::NEG_INFINITY, f32::NAN] { + assert_eq!(quick_bar_offset(margin, 46.0), 14.0, "{margin}"); } } } From 8f1d23d991ff7445d6c7f0874db82393ec41f7e4 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:15:43 +1100 Subject: [PATCH 086/562] test(ui): the Format text narrow-pane test checks the font list against the pane's content edge (#5634) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/it/content.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index f7f595c2dd..c4d2223824 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -739,6 +739,9 @@ fn format_text_fits_the_tools_pane_at_its_narrowest() { for (label, r) in rects { assert!(r.right() <= font.right() + 0.5, "{label} {r:?} reaches past the font list ({font:?})"); } + // The font list ends where the pane's own content does: the header's Close button is its right edge. + let close = h.get_by_label("Close panel").rect(); + assert!((font.right() - close.right()).abs() < 1.5, "the font list {font:?} is not at the pane's content edge ({close:?})"); // The rows below scroll, and the scrollbar floats over their right edge: they stop short of it. let image = h.get_by_label("Image").rect(); assert!(image.right() <= font.right() - 17.0, "the Image row {image:?} runs under the scrollbar ({font:?})"); From cab29b4f19efa8e0c9d807093e60d9c2a9d28b61 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:56:58 +1100 Subject: [PATCH 087/562] fix(ui): the rail is 49 pt wide with larger zoom buttons, and two position checks allow a pixel of rounding (#5636) The rail is a point wider, and its side padding is 1 pt, so the zoom out and in buttons are half the rail's inner width each (about 22 pt) and leave a pixel to the rail's edge. The wider rail moves the page by a pixel, which exposed two checks that depended on exact pixels: bookmark_position counted a page showing under a pixel at the window's top as the one the reader is at, so a bookmark made after a jump went to the previous page; and the sidebar scroll test compared positions to under a pixel, though page rectangles snap to whole pixels. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/navigation.rs | 4 +++- crates/ui-egui/src/chrome.rs | 9 ++++++--- crates/ui-egui/tests/it/view.rs | 8 ++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 731b2c2586..30732ad499 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -32,7 +32,9 @@ impl DocView { // of pages of different heights, whose shorter page can start below it), else the // nearest one below. let top = self.viewport_screen.top(); - let showing = || self.screen_xforms.iter().filter(|(_, xf)| xf.rect.bottom() > top); + // A page showing less than a pixel is rounding, not what the reader is looking at: a jump + // to the next page leaves the previous one's bottom edge within a pixel of the top. + let showing = || self.screen_xforms.iter().filter(|(_, xf)| xf.rect.bottom() > top + 1.0); let (page, xf) = showing() .filter(|(_, xf)| xf.rect.top() <= top) .min_by_key(|(p, _)| *p) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 4ead302998..eb3074b724 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -590,7 +590,8 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut page_display = None; let rail = Rail::new(egui::Id::unique("rail")) .edge(Edge::End) - .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(4, 8)).stroke(Stroke::new(1.0, t.divider))) + .width(49.0) + .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(1, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). @@ -618,12 +619,14 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // Out and in side by side right under the zoom field, so they are found with it. ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; + // Two buttons across the rail, each leaving a pixel to the rail's edge. + let side = (ui.available_width() / 2.0).floor(); let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 17.0, false, &tip).clicked() { + if icons::button(ui, "zoom-out", side, false, &tip).clicked() { view.zoom_step(false); } let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 17.0, false, &tip).clicked() { + if icons::button(ui, "zoom-in", side, false, &tip).clicked() { view.zoom_step(true); } }); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index aef5ddae73..4292744e42 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1909,7 +1909,8 @@ fn sidebar_collapse_and_resize_preserves_scroll_position() { let rect_after = v.page_screen_rect(2).expect("page 2 still on screen"); let vp_after = v.viewport_rect(); let rel_y_after = rect_after.min.y - vp_after.min.y; - assert!((rel_y_after - rel_y_before).abs() < 1.0, "page 2 scroll position preserved: before {rel_y_before}, after {rel_y_after}"); + // Page rectangles snap to whole pixels, so a different width can move the page by one. + assert!((rel_y_after - rel_y_before).abs() <= 1.0, "page 2 scroll position preserved: before {rel_y_before}, after {rel_y_after}"); // Reopen bookmarks panel: available width decreases, so fit-width zoom decreases. h.state_mut().set_option("panel", "bookmarks").unwrap(); @@ -1926,7 +1927,10 @@ fn sidebar_collapse_and_resize_preserves_scroll_position() { let rect_reopened = v.page_screen_rect(2).expect("page 2 still on screen"); let vp_reopened = v.viewport_rect(); let rel_y_reopened = rect_reopened.min.y - vp_reopened.min.y; - assert!((rel_y_reopened - rel_y_before).abs() < 1.0, "page 2 scroll position preserved on reopen: before {rel_y_before}, after {rel_y_reopened}"); + assert!( + (rel_y_reopened - rel_y_before).abs() <= 1.0, + "page 2 scroll position preserved on reopen: before {rel_y_before}, after {rel_y_reopened}" + ); } /// Synthetic unequal-height spreads, generated without font or image assets. From 461200e74a1583427fe8bcb46ba03343b0165178 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:37:23 +1100 Subject: [PATCH 088/562] fix(ui): bookmark_position keeps a sliver page when nothing else shows; the rail test checks the rail's real edges (#5636) A page showing under a pixel at the window's top is ignored in favour of the next page, but still chosen when no other page shows. The rail test no longer tolerates 2 pt of overflow: it compares the page and zoom fields with the rail's own edges. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/navigation.rs | 16 ++++++++++------ crates/ui-egui/tests/it/content.rs | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 30732ad499..b8a53d7c8b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -33,12 +33,16 @@ impl DocView { // nearest one below. let top = self.viewport_screen.top(); // A page showing less than a pixel is rounding, not what the reader is looking at: a jump - // to the next page leaves the previous one's bottom edge within a pixel of the top. - let showing = || self.screen_xforms.iter().filter(|(_, xf)| xf.rect.bottom() > top + 1.0); - let (page, xf) = showing() - .filter(|(_, xf)| xf.rect.top() <= top) - .min_by_key(|(p, _)| *p) - .or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0))))?; + // to the next page leaves the previous one's bottom edge within a pixel of the top. Such a + // sliver still counts when nothing else shows (the last page at the very end). + let pick = |slack: f32| { + let showing = || self.screen_xforms.iter().filter(move |(_, xf)| xf.rect.bottom() > top + slack); + showing() + .filter(|(_, xf)| xf.rect.top() <= top) + .min_by_key(|(p, _)| *p) + .or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0)))) + }; + let (page, xf) = pick(1.0).or_else(|| pick(0.0))?; let p = info.pages.get(*page)?; // Above the page (in the gap before it) is the page's own top. The window's vertical is // the view's y, or its x when the view is turned a quarter. diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 61c86644e4..55a78dfbab 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1433,11 +1433,13 @@ fn typing_in_one_tab_never_reaches_another_tabs_added_text() { fn rail_page_and_zoom_fields_match_and_fit_the_rail() { let mut h = harness(); h.run_steps(3); + // The rail is 49 pt wide at the window's right edge (1400 pt wide), inside a 1 pt border. + let (rail_left, rail_right) = (1400.0 - 49.0 + 1.0, 1400.0 - 1.0); let rail = h.get_by_label("Bookmarks").rect(); let (page, zoom) = (h.get_by_label("Current page").rect(), h.get_by_label("Zoom").rect()); assert_eq!((page.width(), page.height()), (zoom.width(), zoom.height()), "{page:?} vs {zoom:?}"); for r in [page, zoom] { - assert!(r.left() >= rail.left() - 2.0 && r.right() <= rail.right() + 2.0, "{r:?} is cut off by the rail ({rail:?})"); + assert!(r.left() >= rail_left && r.right() <= rail_right, "{r:?} is cut off by the rail ({rail_left}..{rail_right})"); assert!((r.center().x - rail.center().x).abs() < 1.0, "{r:?} is off the rail's centre ({rail:?})"); } } From 3896e6c30aa5e2ea9045554a64d6dd6250caff03 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:46:01 +1100 Subject: [PATCH 089/562] fix(ui): the rail's page and zoom fields are wide enough for "6400%", and the sliver size is named (#5636) At 36 pt the largest zoom was cut off at the right. The fields are 42 pt, set by RAIL_FIELD_SIZE and pinned in the rail test. The one-pixel tolerance in bookmark_position is the constant SLIVER. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/navigation.rs | 7 +++++-- crates/ui-egui/src/chrome.rs | 5 ++++- crates/ui-egui/tests/it/content.rs | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index b8a53d7c8b..8f88fff7a4 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -32,7 +32,7 @@ impl DocView { // of pages of different heights, whose shorter page can start below it), else the // nearest one below. let top = self.viewport_screen.top(); - // A page showing less than a pixel is rounding, not what the reader is looking at: a jump + // A page showing less than SLIVER is rounding, not what the reader is looking at: a jump // to the next page leaves the previous one's bottom edge within a pixel of the top. Such a // sliver still counts when nothing else shows (the last page at the very end). let pick = |slack: f32| { @@ -42,7 +42,7 @@ impl DocView { .min_by_key(|(p, _)| *p) .or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0)))) }; - let (page, xf) = pick(1.0).or_else(|| pick(0.0))?; + let (page, xf) = pick(SLIVER).or_else(|| pick(0.0))?; let p = info.pages.get(*page)?; // Above the page (in the gap before it) is the page's own top. The window's vertical is // the view's y, or its x when the view is turned a quarter. @@ -460,3 +460,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s /// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up /// keep this much of the previous screen in view. const KEY_SCROLL_LINE: f32 = 40.0; +/// How much of a page must show at the window's top edge to count as the page there, in points: less +/// is rounding, since page rectangles snap to whole pixels. +const SLIVER: f32 = 1.0; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index eb3074b724..eb795c833a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -531,6 +531,9 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// The size of the rail's page and zoom fields: wide enough for "6400%", the largest zoom, in the +/// 12 pt font, and inside the 45 pt the rail leaves. +const RAIL_FIELD_SIZE: [f32; 2] = [42.0, 22.0]; /// Padding inside the rail's page and zoom fields. const RAIL_FIELD_MARGIN: egui::Vec2 = egui::vec2(1.0, 4.0); @@ -543,7 +546,7 @@ fn rail_field(ui: &mut egui::Ui, t: &Tokens, edit: egui::TextEdit<'_>) -> egui:: .corner_radius(CornerRadius::same(5)) .show(ui, |ui| { ui.spacing_mut().interact_size.x = 0.0; - ui.add_sized([34.0, 22.0], edit) + ui.add_sized(RAIL_FIELD_SIZE, edit) }) .inner } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 55a78dfbab..c65ff47c88 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1438,6 +1438,8 @@ fn rail_page_and_zoom_fields_match_and_fit_the_rail() { let rail = h.get_by_label("Bookmarks").rect(); let (page, zoom) = (h.get_by_label("Current page").rect(), h.get_by_label("Zoom").rect()); assert_eq!((page.width(), page.height()), (zoom.width(), zoom.height()), "{page:?} vs {zoom:?}"); + // The size `RAIL_FIELD_SIZE` sets: wide enough for "6400%", the largest zoom. + assert_eq!((page.width(), page.height()), (42.0, 22.0), "{page:?}"); for r in [page, zoom] { assert!(r.left() >= rail_left && r.right() <= rail_right, "{r:?} is cut off by the rail ({rail_left}..{rail_right})"); assert!((r.center().x - rail.center().x).abs() < 1.0, "{r:?} is off the rail's centre ({rail:?})"); From 1bb7da1372b410774ec25f42db8382a70beca231 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:50:54 +1100 Subject: [PATCH 090/562] test(ui): quick_bar_offset is pinned at the margin that just holds the bar, a point less, and 1000 pt (#5635) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/bars.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 8fed30ffea..c3ddd4b9ef 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -282,7 +282,8 @@ const QUICK_BAR_EDGE: f32 = 14.0; /// How far from the side panel the quick-action bar stands, given the width of the margin between /// the panel and the page and the bar's own width: centred in the margin when it holds the bar, -/// else 14 pt in (a page filling the width). +/// else 14 pt in (a page filling the width). With no page on screen the caller passes a margin of 0, +/// which is 14 pt too. fn quick_bar_offset(margin: f32, width: f32) -> f32 { // shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel. // `f32::min` returns the other operand for NaN, which would pass the fit check below. @@ -527,6 +528,14 @@ mod tests { assert_eq!(quick_bar_offset(400.0, 46.0), 27.0); } + #[test] + fn the_margin_that_just_holds_the_bar_centres_and_a_point_less_does_not() { + // 46 pt of bar and 8 pt to spare each side: 62 pt. + assert_eq!(quick_bar_offset(62.0, 46.0), 8.0); + assert_eq!(quick_bar_offset(61.0, 46.0), 14.0); + assert_eq!(quick_bar_offset(1000.0, 46.0), 27.0); + } + #[test] fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() { // Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it. From 560acae86e09af63c69f981ad23a93ede30f555e Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:00:15 +1100 Subject: [PATCH 091/562] fix(ui): the sliver bookmark_position ignores is one physical pixel, not one point (#5636) Page rectangles snap to whole physical pixels, so what counts as rounding scales with the display: the view keeps last frame's pixels per point and the threshold is its inverse. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 4 ++++ crates/ui-egui/src/canvas/navigation.rs | 27 ++++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5625587a69..5b3ac3bfac 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -382,6 +382,8 @@ pub struct DocView { /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). screen_rects: Vec<(usize, Rect)>, screen_xforms: Vec<(usize, PageXform)>, + /// Pixels per point last frame, for telling a rounding sliver of a page from a visible one. + screen_ppp: f32, /// The scroll viewport on screen last frame. viewport_screen: Rect, /// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset. @@ -669,6 +671,7 @@ impl DocView { page_heights: info.pages.iter().map(|p| p.height).collect(), screen_rects: Vec::new(), screen_xforms: Vec::new(), + screen_ppp: 1.0, viewport_screen: Rect::NOTHING, zoom_anchor: None, shown: false, @@ -2010,6 +2013,7 @@ fn draw_pages( let origin = resp_rect.min - vec2(0.0, y_shift); let visible = viewport.translate(resp_rect.min.to_vec2()); view.screen_rects.clear(); + view.screen_ppp = ui.ctx().pixels_per_point(); view.screen_xforms.clear(); // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), // or a press they hold. The page modules read it rather than egui's raw pointer state. diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 8f88fff7a4..7f149d912b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -32,7 +32,7 @@ impl DocView { // of pages of different heights, whose shorter page can start below it), else the // nearest one below. let top = self.viewport_screen.top(); - // A page showing less than SLIVER is rounding, not what the reader is looking at: a jump + // A page showing less than a pixel is rounding, not what the reader is looking at: a jump // to the next page leaves the previous one's bottom edge within a pixel of the top. Such a // sliver still counts when nothing else shows (the last page at the very end). let pick = |slack: f32| { @@ -42,7 +42,7 @@ impl DocView { .min_by_key(|(p, _)| *p) .or_else(|| showing().min_by(|a, b| a.1.rect.top().total_cmp(&b.1.rect.top()).then(a.0.cmp(&b.0)))) }; - let (page, xf) = pick(SLIVER).or_else(|| pick(0.0))?; + let (page, xf) = pick(sliver(self.screen_ppp)).or_else(|| pick(0.0))?; let p = info.pages.get(*page)?; // Above the page (in the gap before it) is the page's own top. The window's vertical is // the view's y, or its x when the view is turned a quarter. @@ -460,6 +460,23 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s /// How far ↓ / ↑ scroll at Normal speed (Scroll speed halves or doubles it). Page Down / Page Up /// keep this much of the previous screen in view. const KEY_SCROLL_LINE: f32 = 40.0; -/// How much of a page must show at the window's top edge to count as the page there, in points: less -/// is rounding, since page rectangles snap to whole pixels. -const SLIVER: f32 = 1.0; +/// How much of a page must show at the window's top edge to count as the page there, in points: one +/// physical pixel, since page rectangles snap to whole pixels and less is rounding. +fn sliver(pixels_per_point: f32) -> f32 { + if pixels_per_point.is_finite() && pixels_per_point > 0.0 { 1.0 / pixels_per_point } else { 1.0 } +} + +#[cfg(test)] +mod tests { + use super::sliver; + + #[test] + fn a_sliver_is_one_physical_pixel() { + assert_eq!(sliver(1.0), 1.0); + assert_eq!(sliver(2.0), 0.5); + assert_eq!(sliver(0.5), 2.0); + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!(sliver(bad), 1.0, "{bad}"); + } + } +} From 9538640f561950f57fdc43e0267276cee6f0add7 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:19:23 +1100 Subject: [PATCH 092/562] fix(ui): a bookmark made in the page grid goes to the current page (#5636) The grid moves the viewport without refreshing where the pages are on screen, so bookmark_position now gives no position there. --- crates/ui-egui/src/canvas/navigation.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 7f149d912b..56df4f19bc 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -9,6 +9,11 @@ impl DocView { /// zoom are kept). `None` before the first frame has laid pages out, when the bookmark just /// goes to the current page. pub(crate) fn bookmark_position(&self, info: &DocInfo) -> Option<(usize, [Option; 2])> { + // The page grid moves the viewport without refreshing where the pages are on screen, so + // there is no position to read: the bookmark goes to the current page. + if self.organize { + return None; + } if let Some(sel) = self.selection && let Some(text) = self.texts.get(&sel.page) && let Some(p) = info.pages.get(sel.page) From 43202e10f9b1b738a29ccf6771162c259a0b448e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 08:47:33 +0100 Subject: [PATCH 093/562] M2.6: a page of many pictures stops at the export's allowance The allowance's sharing was tested on a few images. This runs a whole page through the export: the pictures past it are left out and counted, and the page is listed, which is what the warning goes by. A few large pictures all come through. The README gives the numbers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/cos/README.md | 3 +- crates/engine/src/tests.rs | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/cos/README.md b/crates/cos/README.md index a20ee99ff0..7b576f293f 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -95,5 +95,6 @@ recognisable in a diff and byte-stable when re-serialized. their filters undone once a document for the images that share them, up to 64 MiB kept, while their segments are decoded for every image, and `ImageBudget` for an operation over many images, which share one allowance of decoding, filters, decoders and pixels alike, and are refused past - it); + it: `IMAGE_WORK`, 2^34 steps, or 2 to 5 minutes of JBIG2 and about 1,000 pages scanned at + 300 dpi, plus `IMAGE_WORK_PER_BYTE`, 2^13 steps for each byte of the file); DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 503e718586..1e25a919cf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9196,6 +9196,62 @@ fn a_fields_own_script_may_not_save_as_or_show_a_panel() { assert_eq!(o.requests, [js::Request::SaveAs]); } +/// A page of `count` pictures of one flat grey, `side` pixels square, each an object of its own. +fn page_of_pictures(count: usize, side: usize) -> Vec { + let data = pdfcraft_filters::encode_flate(&vec![0x80; side * side]); + let names: String = (0..count).map(|i| format!("/Im{i} {} 0 R ", 5 + i)).collect(); + let content: String = (0..count).map(|i| format!("q 10 0 0 10 0 0 cm /Im{i} Do Q\n")).collect(); + 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 /MediaBox [0 0 200 300] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + for i in 0..count { + let dict = format!( + "{} 0 obj << /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >> stream\n", + 5 + i, + data.len() + ); + pdf.extend_from_slice(dict.as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + pdf +} + +/// The allowance is the export's, not each picture's: a page of many small pictures stops where it +/// runs out, the rest are left out and counted, and the page is listed, which is what the export's +/// warning goes by. A few large pictures are well within it, and all come through. 2,000 are enough +/// to show it: 10,000 behave the same, and take half a minute to read in a test build. +#[test] +fn a_page_of_many_pictures_stops_at_the_exports_allowance() { + let export = |pdf: Vec, work: &mut pdfcraft_cos::ImageBudget| { + let mut s = Session::new(); + let id = s.open("pictures.pdf", None, Arc::new(pdf), None).unwrap(); + s.get(id).unwrap().export_pages_within(true, 1 << 30, work) + }; + let mut one = pdfcraft_cos::ImageBudget::with_work(u64::MAX); + export(page_of_pictures(1, 2), &mut one); + let cost = u64::MAX - one.work_left(); + assert!(cost > 0); + + let mut work = pdfcraft_cos::ImageBudget::with_work(100 * cost); + let (pages, truncated) = export(page_of_pictures(2_000, 2), &mut work); + assert_eq!(pages[0].images.len(), 100); + assert_eq!(truncated, vec![0]); + assert_eq!(work.refused(), 1_900); + + let mut s = Session::new(); + let id = s.open("large.pdf", None, Arc::new(page_of_pictures(3, 1_000)), None).unwrap(); + let (pages, truncated) = s.get(id).unwrap().export_pages_checked(true, 1 << 30); + assert_eq!(pages[0].images.len(), 3); + assert_eq!(truncated, Vec::::new()); +} + /// A page whose forms go past the reading limit and whose picture goes past the image budget is /// listed once among the truncated pages. #[test] From 33d7a513018f87e201bafd74d3a2e6e3c8357ab5 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 08:46:26 +0100 Subject: [PATCH 094/562] M2: a reference in /Decode, /W or a palette that leads back to itself ends Colour spaces already had tests of this through their bases. `/Decode`, in the renderer and in image export, `/W`, `/W2` and an Indexed space's table hadn't: each read now has such a reference in its test, and ends. These hold that the read ends, not what value it comes to. A reference to a reference in a number array reads as the first number of that reference in the renderer (`/Decode [11 0 R 0]`, with 11 naming 6 and 6 holding 1, reads as 6). That is older than this branch, in `IndirectObject::read` for an object stored on its own and `ObjectStream::get` for one in an object stream. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/create/src/tests.rs | 9 ++++++++- crates/render/src/raster/tests.rs | 13 +++++++++++++ vendor/hayro-interpret/src/font/cid.rs | 9 +++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9c7e551142..10f9df38b9 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2221,7 +2221,8 @@ fn a_stencil_masks_decode_array_can_hold_references() { 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 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\ 4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\ - 5 0 obj 1 endobj\n6 0 obj 0 endobj\ntrailer << /Root 1 0 R >>\n%%EOF" + 5 0 obj 1 endobj\n6 0 obj 0 endobj\n7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" ); let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap(); let mut export = crate::extract::extract_images(&doc, &[0], 1); @@ -2236,4 +2237,10 @@ fn a_stencil_masks_decode_array_can_hold_references() { for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] { assert_eq!(export(decode), identity, "{decode}"); } + // A reference that only leads back to itself holds no number. The read ends, and the file comes + // out one way round or the other. + for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]"] { + let file = export(decode); + assert!(file == inverted || file == identity, "{decode}"); + } } diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 1144375d51..b996aed880 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -1062,6 +1062,9 @@ fn a_decode_array_with_references_in_it_is_read() { 5 0 obj << /Type /XObject /Subtype /Image /Width {width_of} /Height 1 /BitsPerComponent 8 /ColorSpace {space} /Decode {decode} /Filter /ASCIIHexDecode /Length {} >> stream\n{data}\nendstream endobj\n\ 6 0 obj 1 endobj\n\ 7 0 obj 0 endobj\n\ + 8 0 obj 8 0 R endobj\n\ + 9 0 obj 10 0 R endobj\n\ + 10 0 obj 9 0 R endobj\n\ trailer << /Root 1 0 R >>\n%%EOF", content.len(), data.len() @@ -1090,6 +1093,11 @@ fn a_decode_array_with_references_in_it_is_read() { for decode in ["[6 0 R 0 1 0 1 0]", "[1 0 6 0 R 0 1 0]", "[1 0 1 0 6 0 R 0]", "[6 0 R 7 0 R 6 0 R 7 0 R 6 0 R 7 0 R]"] { assert_eq!(render("1", decode, "/DeviceRGB", "204060>"), rgb, "{decode}"); } + // A reference that only leads back to itself holds no number. The read ends, and the image is + // drawn over the red under it. + for decode in ["[8 0 R 0]", "[1 8 0 R]", "[9 0 R 0]", "[1 9 0 R]", "[8 0 R 9 0 R]"] { + assert_ne!(render("1", decode, "/DeviceGray", "40>"), [255, 0, 0, 255], "{decode}"); + } } /// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they @@ -1193,6 +1201,11 @@ fn an_indexed_lookup_table_is_read_in_every_legal_form() { for space in ["[/Indexed /DeviceRGB 1 8 0 R]", "[/Indexed /DeviceRGB 1 99 0 R]", "[/Indexed /DeviceRGB 1]"] { let _ = render(space, paint, ""); } + // So does a table that only leads back to itself, and the read of it ends. + let cycle = "11 0 obj 11 0 R endobj 12 0 obj 13 0 R endobj 13 0 obj 12 0 R endobj"; + for space in ["[/Indexed /DeviceRGB 1 11 0 R]", "[/Indexed /DeviceRGB 1 12 0 R]"] { + let _ = render(space, paint, cycle); + } } #[test] fn watchdog_skips_a_stuck_page_and_keeps_rendering() { diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 341a8fa155..1c3afae193 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -712,6 +712,7 @@ mod width_tests { 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 10 10] >> endobj\n\ 4 0 obj 20 endobj\n5 0 obj {array} endobj\n6 0 obj 22 endobj\n\ + 7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ trailer << /Root 1 0 R >>\n%%EOF\n" ); Pdf::new(pdf.into_bytes()).unwrap() @@ -750,5 +751,13 @@ mod width_tests { let by_range = vec![(20, [-900.0, 400.0, 800.0]), (21, [-900.0, 400.0, 800.0]), (22, [-900.0, 400.0, 800.0])]; assert_eq!(vertical("[20 22 -900 400 800]"), by_range); assert_eq!(vertical("[4 0 R 22 -900 400 800]"), by_range); + + // A reference that only leads back to itself holds no number. The reads end. + for array in ["[7 0 R [500 600]]", "[8 0 R [500 600]]", "[20 7 0 R 700]", "[7 0 R 8 0 R 700]", "[20 [7 0 R 600]]"] { + widths(array); + } + for array in ["[7 0 R [-1000 500 880]]", "[8 0 R 22 -900 400 800]", "[20 [7 0 R 500 880]]"] { + vertical(array); + } } } From d90cf79a2a482c23b442dd5b3c88897aa9644eb2 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:02:38 +1100 Subject: [PATCH 095/562] fix(ui): the line spacing list keeps its column in every language (#5634) A longer "Auto" (Czech "Automaticky") pushed the list 46 pt past the Tools pane at its narrowest. It is now cut short, with the whole word in the list's menu and tooltip. A test checks the lists in every interface language the fonts can draw, and the narrow-pane test also runs at the largest interface size. --- crates/ui-egui/src/panels/format_text_ui.rs | 27 +++++++++++++------- crates/ui-egui/tests/it/content.rs | 28 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index dd60b3bcc8..7b2763bf32 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -271,13 +271,20 @@ pub(crate) fn panel( let own = tl!("Auto").to_string(); let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; outlined(ui, |ui| { - let list = craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(centred(ui, &shown)).width(half).show_ui(ui, |ui| { - ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); - for v in LINE_SPACINGS { - ui.selectable_value(&mut e.line_spacing, v, number(v)); - } - }); - list.response.labelled_by(label.id); + // The column is `half` wide in every language: a longer "Auto" is cut short (the + // menu and the tooltip have it whole). + ui.set_max_width(half); + let list = craft_ui::ComboBox::from_id_salt("format-line-spacing") + .selected_text(centred(ui, &shown)) + .width(half) + .wrap_mode(egui::TextWrapMode::Truncate) + .show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + list.response.labelled_by(label.id).on_hover_text(&shown); }); ui.add_space(8.0); ui.add_enabled_ui(false, |ui| { @@ -382,11 +389,13 @@ struct Number<'a> { /// A combo box's selected text, centred in its button. The button lays out the text and then its /// arrow, and every growing atom takes an equal share of the room left: a pad as wide as the arrow -/// before the text balances the arrow after it, and the text centres itself in its own share. +/// before the text balances the arrow after it, and the text centres itself in its own share. Text +/// longer than the list is wide (a translated "Auto") is cut short rather than widening it. fn centred<'a>(ui: &egui::Ui, text: &str) -> egui::Atoms<'a> { use egui::AtomExt as _; let pad = egui::Atom::custom(egui::IdSalt::new("combo-pad"), vec2(ui.spacing().icon_width, 0.0)).atom_grow(true); - egui::Atoms::new((pad, egui::Atom::from(text.to_string()).atom_grow(true).atom_align(egui::Align2::CENTER_CENTER))) + let text = egui::Atom::from(text.to_string()).atom_grow(true).atom_shrink(true).atom_align(egui::Align2::CENTER_CENTER); + egui::Atoms::new((pad, text)) } /// The arrow that lists a field's common values, centred on the 26-point field beside it. diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index c4d2223824..a798505551 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -727,6 +727,8 @@ fn format_text_rows_line_up_and_fit_the_panel() { fn format_text_fits_the_tools_pane_at_its_narrowest() { use egui::accesskit::Role; let mut h = harness(); + // At the largest interface size too: it scales points to pixels, and the layout is in points. + h.state_mut().set_option("ui-scale", "300").unwrap(); h.state_mut().side_panels.tools_width = 0.0; assert!(h.state_mut().execute("edit.text")); h.run_steps(3); @@ -752,6 +754,32 @@ fn format_text_fits_the_tools_pane_at_its_narrowest() { } } +/// The lists in Format text fit the narrowest Tools pane in every interface language: "Auto" in +/// the line spacing list is the one word in them that is translated, and in German, Ukrainian or +/// Lao it is twice as long. The font list spans the pane, so nothing may end past it. +#[test] +fn format_text_lists_fit_the_narrowest_tools_pane_in_every_language() { + use egui::accesskit::Role; + for lang in pdfcraft_ui_egui::i18n::Lang::all() { + let mut h = harness(); + // A script with no face on this machine (Lao without system fonts) can't be drawn at all. + if !h.ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(13.0), lang.name())) { + continue; + } + h.state_mut().set_option("language", lang.code()).unwrap(); + h.state_mut().side_panels.tools_width = 0.0; + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let lists: Vec = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).collect(); + // Found by its value (a font name, never translated), not as the widest list: an overrun + // could make another list the widest. + let font = h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some("Helvetica")).map(|n| n.rect()).expect("the font list"); + for r in &lists { + assert!(r.right() <= font.right() + 0.5, "{}: {r:?} reaches past the font list ({font:?})", lang.code()); + } + } +} + #[test] fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { use egui_kittest::kittest::NodeT; From 566007529516dddab991f4dfc54da664189dfd6d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 12:15:58 +0100 Subject: [PATCH 096/562] M8: the test of rows too wide for Group 4 says Group 4 refused them Flate coming out didn't show that: Group 4 could have been coded and lost on size. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9db24fff4e..c61e7e60f3 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -978,6 +978,7 @@ fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() { let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1; for (w, h) in [(3usize, 2usize), (wide, 2)] { let stride = w.div_ceil(8); + assert_eq!(pdfcraft_filters::encode_g4(&vec![0xFF; stride * h], w as u32, h as u32).is_ok(), w != wide, "{w} × {h}"); let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"); let mut doc = one_page( b"q 200 0 0 20 0 100 cm /Im Do Q", From 0e7e6a00205a3e44d1e16a83daaf9f258402c92a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 1 Nov 2026 13:13:29 +0100 Subject: [PATCH 097/562] Test: pick the camera panel's Crop pages button now that the quick toolbar has one Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/it/camera.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/camera.rs b/crates/ui-egui/tests/it/camera.rs index 99d44ec3b7..88d79d29c1 100644 --- a/crates/ui-egui/tests/it/camera.rs +++ b/crates/ui-egui/tests/it/camera.rs @@ -147,7 +147,8 @@ fn after_enhance_page_crop_and_organize_take_over() { h.get_by_label("Enhance page").click(); h.run_steps(4); assert_eq!(h.state().views[0].camera_done.map(|d| d.page), Some(0)); - h.get_by_label(button).click(); + // The quick toolbar also has a "Crop pages" button; the camera panel's pill is the first match. + h.get_all_by_label(button).next().unwrap().click(); h.run_steps(3); assert_eq!(h.state().quick_tool, then, "{button}"); assert_eq!(h.state().views[0].camera_done, None, "{button}"); From 35eaeca321597b44026f5f8a71bd0af9c9076e64 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 1 Nov 2026 13:35:14 +0100 Subject: [PATCH 098/562] ci: re-run checks Co-Authored-By: Claude Sonnet 5.5 From c14968edf191887be019506cef2bb052304e3558 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 12:15:57 +0100 Subject: [PATCH 099/562] M8: no stream of a saved file holds the pixels under a mark on a JBIG2 scan The tests of this checked that the JBIG2 code and its globals had gone, and that the pixels came out cleared. This reads every stream of the saved file as it would be shown, and searches them for pixels found only under the mark, and for pixels beside it, which have to stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 08faef69a7..96cc2f5064 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -736,6 +736,52 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() { assert_eq!((report.images_cleared, report.images_removed), (1, 0)); } +/// After a save no stream holds the pixels under a mark, read as it would be shown, JBIG2 included, +/// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels +/// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen +/// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding. +#[test] +fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { + const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E]; + const KEPT: [u8; 8] = [0x87, 0x2D, 0x4B, 0xE1, 0x78, 0xD2, 0xB4, 0x1F]; + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + let (w, h) = (512usize, 64usize); + let stride = w / 8; + let mut bits = vec![0xFFu8; stride * h]; + bits[stride * 2..stride * 2 + 8].copy_from_slice(&COVERED); + bits[stride * 2 + 16..stride * 2 + 24].copy_from_slice(&KEPT); + for top in (16..h - 8).step_by(10) { + for x in (4..w - 8).step_by(8) { + for (dy, row) in LETTER.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + let holds = |doc: &Document, pixels: &[u8]| { + doc.scan_objects().any(|(_, object)| match &*object { + Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)), + _ => false, + }) + }; + let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests"); + for coded in [symbols, generic] { + let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode"); + // Half a point a pixel, its top row at y 132. + let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]); + assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found"); + // Columns 0 to 99 of rows 0 to 7. + mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file"); + assert!(holds(&saved, &KEPT), "the pixels beside them stay"); + } +} + /// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it /// needn't read, holding `secret`; its 11-byte header gives the length of the data after it. fn jbig2_globals(secret: &[u8]) -> Vec { From 1b61121c66dcdc6b4ce1f133321fe971bc902a87 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 12:15:57 +0100 Subject: [PATCH 100/562] M2.6: a page's forms and pictures read their resources in place Each `Do` copied the page's `/XObject` dictionary, and each form its `/Resources`, `/Properties`, `/Font` and `/ExtGState`, or the resources it was drawn with if it had none. So Office export of one page took time in the square of what it drew: 31 s for 10,000 pictures, about 70 ms of it decoding, 30 s for 10,000 forms with no `/Resources`, and 16 minutes for one form with 100,000 keys in them drawn 50,000 times, in a test build. `FormCall` keeps the form and `resource_dict` reads a dictionary where it is, with an object of its own held. 10,000 pictures take about 0.15 s now, 10,000 forms 79 ms, and that form drawn 5,000 times 40 ms. The test holds to 10,000 pictures, not the 2,000 it could afford. A `gs` reads a direct graphics state in place too, in the text walk and in `page_background`, and a value of the wrong type isn't copied to find that out. A form that sets a font still reads the font each time it is drawn, as the fonts read are kept only for that draw: 3.5 ms a draw for one of 100,000 widths. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/edit/src/background.rs | 9 ++++-- crates/edit/src/images.rs | 20 ++++++------ crates/edit/src/tests.rs | 35 +++++++++++++++++++++ crates/edit/src/text.rs | 2 +- crates/edit/src/text/interp.rs | 13 +++++--- crates/edit/src/text/lines.rs | 57 +++++++++++++++++++++++++++------- crates/engine/src/tests.rs | 7 ++--- 7 files changed, 111 insertions(+), 32 deletions(-) diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index 7d745c0c10..376cb5a165 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -98,8 +98,13 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE } b"sc" | b"scn" => st.fill = colour(st.space, op), b"gs" => { - if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) { - apply_gstate(doc, &g, &mut st); + let mut held = None; + let gstate = match op.name(0).and_then(|n| gstates.get(n)) { + Some(g) => crate::text::dict_in_place(doc, g, &mut held), + None => None, + }; + if let Some(g) = gstate { + apply_gstate(doc, g, &mut st); } } b"re" => match op.nums::<4>() { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index f66e7f24ee..71ae49941d 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -316,34 +316,34 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi forms: &mut Forms, out: &mut Vec, ) -> Result<(), EditError> { - let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let mut held = None; + let xo = crate::text::resource_dict(doc, resources, b"XObject", &mut held); for (i, op) in ops.iter().enumerate() { st.apply(op)?; let ctm = st.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; + let drawn = xo.and_then(|xo| xo.get(name)); if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) { if let Some(r) = form.obj { forms.path.push(r); } let bounds = - xo.get(name).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then( - |b| { - let a = b.as_array()?; - let [x0, y0, x1, y1] = a.as_slice() else { return None }; - Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?])) - }, - ); + drawn.map(|o| doc.resolve(o)).and_then(|o| o.as_dict().and_then(|d| d.get(b"BBox")).map(|b| doc.resolve(b))).and_then(|b| { + let a = b.as_array()?; + let [x0, y0, x1, y1] = a.as_slice() else { return None }; + Some(form.ctm.bbox([x0.as_f64()?, y0.as_f64()?, x1.as_f64()?, y1.as_f64()?])) + }); let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4])); let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone()); let piece = stream(op); - walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?; + walk(doc, &parse(&form.data).ops, form.resources(resources), &mut inner, &|_| piece, forms, out)?; if form.obj.is_some() { forms.path.pop(); } continue; } - let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue }; + let Some(r) = drawn.and_then(Object::as_ref) else { continue }; let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; if s.dict.name(b"Subtype") != Some(b"Image") { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d45e983a2e..6df9ab19dd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4584,6 +4584,41 @@ fn reading_follows_form_xobjects() { assert!(own[0].is_form && own[0].object == Some(pdfcraft_cos::ObjRef::new(6, 0)), "{own:?}"); } +/// A page's `/XObject` and a form's `/Resources` are read wherever they are stored, and a form with +/// none of its own, or with something else there, uses the ones it was drawn with (PDF 1.1 files). +/// Each of these pages shows one line of text and one picture through one form. +#[test] +fn reading_follows_forms_however_their_resources_are_stored() { + let body = "BT /F1 12 Tf 72 700 Td (In the form) Tj ET q 20 0 0 10 5 5 cm /Im Do Q"; + let (font, direct) = ("/Font << /F1 5 0 R >>", "/XObject << /Fm 6 0 R /Im 7 0 R >>"); + for (page, form) in [ + (format!("<< {font} {direct} >>"), ""), + (format!("<< {font} /XObject 9 0 R >>"), ""), + (format!("<< /Font 10 0 R {direct} >>"), ""), + (format!("<< {font} {direct} >>"), "/Resources 99 0 R"), + (format!("<< {font} {direct} >>"), "/Resources 12"), + ("<< /XObject << /Fm 6 0 R >> >>".to_string(), "/Resources 8 0 R"), + ("<< /XObject 9 0 R >>".to_string(), "/Resources << /Font << /F1 5 0 R >> /XObject 9 0 R >>"), + ] { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources {page} /Contents 4 0 R >>"), + &stream("", "/Fm Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>", + &stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 600 800] {form}"), body), + &stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"), + "<< /Font << /F1 5 0 R >> /XObject 9 0 R >>", + "<< /Fm 6 0 R /Im 7 0 R >>", + "<< /F1 5 0 R >>", + ]); + let blocks = text::reading_blocks(&doc, 0).unwrap(); + let texts: Vec<(&str, &str)> = blocks.iter().map(|b| (b.text.as_str(), b.base_font.as_str())).collect(); + assert_eq!(texts, [("In the form", "Courier")], "{page} {form}"); + assert_eq!(images::reading_images(&doc, 0).unwrap().len(), 1, "{page} {form}"); + } +} + /// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but /// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging. #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 98824c7694..fdceaa9b03 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -53,7 +53,7 @@ use fallback::{ use fallback::{add_type3_font, is_type3_fallback}; use interp::{Origin, Paint, union}; use lines::page_dict; -pub(crate) use lines::{FormBudget, content_streams, content_streams_within, form_call, rewritten_contents, splice}; +pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice}; pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; pub use rewrite::{LineEdit, replace_line, rewrite_block}; use rewrite::{coincident_ops, rewrite_block_from}; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 2b55e51ef8..2a9f6da61a 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -773,8 +773,9 @@ pub(super) fn interpret( // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. logical_text: Option<&Dict>, ) -> Result, EditError> { - let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let (mut held_fonts, mut held_states, none) = (None, None, Dict::new()); + let fonts_res = super::resource_dict(doc, resources, b"Font", &mut held_fonts).unwrap_or(&none); + let states = super::resource_dict(doc, resources, b"ExtGState", &mut held_states).unwrap_or(&none); let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); @@ -881,8 +882,12 @@ pub(super) fn interpret( if let Some(name) = op.name(0) { select_gs(&mut ts.ext_gstate, name); } - let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); - if let Some(params) = params.as_deref().and_then(Object::as_dict) { + let mut held = None; + let params = match op.name(0).and_then(|name| states.get(name)) { + Some(p) => super::dict_in_place(doc, p, &mut held), + None => None, + }; + if let Some(params) = params { ts.gs.apply(doc, params); // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite()); diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index f811f34ea6..85209a5472 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -452,10 +454,41 @@ impl FormBudget { pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, - pub(crate) resources: Dict, + /// The form, and its `/Resources` when they are an object of their own, which is what + /// [`FormCall::resources`] reads. + form: Arc, + own: Option>, pub(crate) ctm: Matrix, } +impl FormCall { + /// The resources its content is read with: its own, or if it has none, `drawn_with`, the ones + /// it was drawn with (PDF 1.1 files). Read in place, as a page can draw a form thousands of times: + /// a copy for each took time in the square of them. + pub(crate) fn resources<'a>(&'a self, drawn_with: &'a Dict) -> &'a Dict { + let direct = || match &*self.form { + Object::Stream(s) => s.dict.get(b"Resources").and_then(Object::as_dict), + _ => None, + }; + self.own.as_deref().and_then(Object::as_dict).or_else(direct).unwrap_or(drawn_with) + } +} + +/// `o` as a dictionary, read in place for the same reason as [`FormCall::resources`]. `held` keeps it +/// when it is an object of its own. Nothing else is one, and [`Document::resolve`] would copy it. +pub(crate) fn dict_in_place<'a>(doc: &Document, o: &'a Object, held: &'a mut Option>) -> Option<&'a Dict> { + match o { + Object::Dict(dict) => Some(dict), + Object::Ref(_) => held.insert(doc.resolve(o)).as_dict(), + _ => None, + } +} + +/// The dictionary at `key` of `resources`, `/XObject` or `/Font`, say, as each form reads these. +pub(crate) fn resource_dict<'a>(doc: &Document, resources: &'a Dict, key: &[u8], held: &'a mut Option>) -> Option<&'a Dict> { + dict_in_place(doc, resources.get(key)?, held) +} + /// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names /// and forms already being read (`path`), and for forms nested too deep or past the page's /// budget, which `budget` records as truncated; each form entered counts one visit. @@ -472,13 +505,15 @@ pub(crate) fn form_call( if limited && budget.truncated { return None; } - let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; - let o = xobjects.get(name)?; - let obj = o.as_ref(); + let mut held = None; + let o = resource_dict(doc, resources, b"XObject", &mut held)?.get(name)?; + // Only a reference can name a stream, so nothing else needs reading, or copying. + let obj = Some(o.as_ref()?); if obj.is_some_and(|r| path.contains(&r)) { return None; } - let Object::Stream(s) = &*doc.resolve(o) else { return None }; + let form = doc.resolve(o); + let Object::Stream(s) = &*form else { return None }; if s.dict.name(b"Subtype") != Some(b"Form") { return None; } @@ -492,8 +527,7 @@ pub(crate) fn form_call( let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) }); - // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). - let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r)); let data = match budget.decode.decode(s) { Ok(data) => data, Err(_) if budget.decode.remaining() == 0 => { @@ -503,7 +537,7 @@ pub(crate) fn form_call( Err(_) => Vec::new(), }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) + Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -522,8 +556,9 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec usize) -> Result<(), EditError> { let mut cache = HashMap::new(); let mut invoked = Vec::new(); - let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); - let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(&properties))?; + let (mut held, none) = (None, Dict::new()); + let properties = resource_dict(w.doc, resources, b"Properties", &mut held).unwrap_or(&none); + let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(properties))?; let mut last = None; let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); @@ -556,7 +591,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Vec { /// The allowance is the export's, not each picture's: a page of many small pictures stops where it /// runs out, the rest are left out and counted, and the page is listed, which is what the export's -/// warning goes by. A few large pictures are well within it, and all come through. 2,000 are enough -/// to show it: 10,000 behave the same, and take half a minute to read in a test build. +/// warning goes by. A few large pictures are well within it, and all come through. #[test] fn a_page_of_many_pictures_stops_at_the_exports_allowance() { let export = |pdf: Vec, work: &mut pdfcraft_cos::ImageBudget| { @@ -9240,10 +9239,10 @@ fn a_page_of_many_pictures_stops_at_the_exports_allowance() { assert!(cost > 0); let mut work = pdfcraft_cos::ImageBudget::with_work(100 * cost); - let (pages, truncated) = export(page_of_pictures(2_000, 2), &mut work); + let (pages, truncated) = export(page_of_pictures(10_000, 2), &mut work); assert_eq!(pages[0].images.len(), 100); assert_eq!(truncated, vec![0]); - assert_eq!(work.refused(), 1_900); + assert_eq!(work.refused(), 9_900); let mut s = Session::new(); let id = s.open("large.pdf", None, Arc::new(page_of_pictures(3, 1_000)), None).unwrap(); From 4303d49eea68a2dc56db60d3bc6428316b1e23d2 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 12:15:57 +0100 Subject: [PATCH 101/562] M2: a reference to a reference is what it names `IndirectObject` read an object holding `6 0 R` as the number 6, so `/Decode [11 0 R 0]`, with 11 naming 6 and 6 holding 1, read as 6, and `/W` gave glyphs such widths. As anything but a number, such an object was lost. `XRef::get_with` is how every reference is read, to an object stored on its own or in an object stream, so it now follows such objects in a loop, up to 32 references as `Document::resolve` does in #5548. A loop takes no stack however long the chain, and a cycle runs out of references and reads as a missing object, as a reference to nothing does: the image with such a `/Decode` isn't drawn, as with `/Decode [99 0 R 0]`. An object stream isn't reached that way: it has to be a stream stored as that object, and following a reference to one stored inside another let a 1 KB file run out of stack. `XRef::objects` leaves such objects out, or a page came out once more for each one holding a reference to it, and a comment inside a reference counts as white space, as it does everywhere else. Reading an object stream's index now counts against the 64 nested loads, and reads its dictionary alone. That is older than this branch, which only made it slightly worse: a chain of object streams, each with a member of the next for its `/N`, `/First` or `/Length`, recursed with no limit, holding a copy of the chain at each, and aborted on 750 KB with an 8 MiB stack, or 250 KB with 2 MiB, in a test build. Parsing the stream read `/Length` as well as `/N`, two ways to the next stream's index, and a failed index isn't remembered, so the work doubled at each stream: 20 streams in a ring took 20 s on `main`. ISO 32000-2 7.5.7 keeps an object stream's `/Length` out of object streams, so no legal file has a `/Length` chain. Nothing forbids `/N` or `/First` by reference, though no writer I know of makes such a chain. Past the limit it reads as missing unless a stream within 64 of it has been read. `/W` and `/W2` with their list by reference are tested too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/render/src/raster/tests.rs | 67 +++++- vendor/README.md | 2 +- vendor/hayro-interpret/src/font/cid.rs | 15 +- vendor/hayro-syntax/src/data.rs | 12 +- vendor/hayro-syntax/src/object/ref.rs | 9 +- vendor/hayro-syntax/src/xref.rs | 301 ++++++++++++++++++++++++- 6 files changed, 384 insertions(+), 22 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index b996aed880..003dbfef54 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -1093,13 +1093,74 @@ fn a_decode_array_with_references_in_it_is_read() { for decode in ["[6 0 R 0 1 0 1 0]", "[1 0 6 0 R 0 1 0]", "[1 0 1 0 6 0 R 0]", "[6 0 R 7 0 R 6 0 R 7 0 R 6 0 R 7 0 R]"] { assert_eq!(render("1", decode, "/DeviceRGB", "204060>"), rgb, "{decode}"); } - // A reference that only leads back to itself holds no number. The read ends, and the image is - // drawn over the red under it. + // A reference that only leads back to itself holds no number, and reads as one that names + // nothing does. + let missing = render("1", "[99 0 R 0]", "/DeviceGray", "40>"); for decode in ["[8 0 R 0]", "[1 8 0 R]", "[9 0 R 0]", "[1 9 0 R]", "[8 0 R 9 0 R]"] { - assert_ne!(render("1", decode, "/DeviceGray", "40>"), [255, 0, 0, 255], "{decode}"); + assert_eq!(render("1", decode, "/DeviceGray", "40>"), missing, "{decode}"); } } +/// An object that holds only a reference stands for what that names, stored on its own or in an +/// object stream. Read as a number it was the reference's own object number: here 8 names 7, 7 +/// names 6 and 6 holds 1, and that read as 7, which drew white. A chain past the limit, and so a +/// cycle, reads as a missing object does. +#[test] +fn a_reference_to_a_reference_is_what_it_names() { + use super::*; + let file = |decode: &str, objects: &str| -> Vec { + let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q"; + 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 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\n40>\nendstream endobj\n\ + {objects}\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() + }; + let draw = |bytes: Vec| -> [u8; 4] { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{:?}", page.error); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + let grey = draw(file("[1 0]", "")); + assert_eq!(grey, [191, 191, 191, 255]); + + let chain = "6 0 obj 1 endobj 7 0 obj 6 0 R endobj 8 0 obj 7 0 R endobj"; + assert_eq!(draw(file("[8 0 R 0]", chain)), grey, "stored on their own"); + let doc = pdfcraft_cos::Document::open(Arc::new(file("[8 0 R 0]", chain))).unwrap(); + let packed = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + assert!(packed.windows(7).any(|w| w == b"/ObjStm"), "the save packs them"); + assert_eq!(draw(packed), grey, "stored in an object stream"); + assert_eq!(draw(file("9 0 R", "9 0 obj 10 0 R endobj 10 0 obj [1 0] endobj")), grey, "not only numbers"); + let commented = "6 0 obj 1 endobj 7 0 obj 6 %c\n0 R endobj 8 0 obj 7 0 %c\nR endobj"; + assert_eq!(draw(file("[8 %c\n0 R 0]", commented)), grey, "a comment counts as white space"); + + let links = |n: usize| -> String { + let links: String = (0..n).map(|i| format!("{} 0 obj {} 0 R endobj\n", 10 + i, 11 + i)).collect(); + format!("{links}{} 0 obj 1 endobj", 10 + n) + }; + let missing = draw(file("[99 0 R 0]", "")); + assert_ne!(missing, grey); + assert_eq!(draw(file("[10 0 R 0]", &links(32))), grey, "32 references are followed"); + assert_eq!(draw(file("[10 0 R 0]", &links(33))), missing, "33 aren't"); + assert_eq!(draw(file("[6 0 R 0]", "6 0 obj 6 0 R endobj")), missing, "nor is a cycle"); +} + /// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they /// lost a reference at the start of a pair the same way, and the array with it. Each form is compared /// with the direct one, which differs from the identity so the array is shown to count. diff --git a/vendor/README.md b/vendor/README.md index ae0755539c..d84b9a95f5 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,7 +43,7 @@ Other resource categories retain their existing parent-chain lookup. | 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (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) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_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`) | | 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); (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` | (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`; (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) | +| 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); (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 | (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`; (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) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index 1c3afae193..43b558b050 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -713,6 +713,7 @@ mod width_tests { 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >> endobj\n\ 4 0 obj 20 endobj\n5 0 obj {array} endobj\n6 0 obj 22 endobj\n\ 7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\ + 10 0 obj 4 0 R endobj\n11 0 obj [500 600] endobj\n12 0 obj [-1000 500 880] endobj\n\ trailer << /Root 1 0 R >>\n%%EOF\n" ); Pdf::new(pdf.into_bytes()).unwrap() @@ -752,12 +753,20 @@ mod width_tests { assert_eq!(vertical("[20 22 -900 400 800]"), by_range); assert_eq!(vertical("[4 0 R 22 -900 400 800]"), by_range); - // A reference that only leads back to itself holds no number. The reads end. + // A group's list can be given by reference too, and a reference to a reference is what it + // names. + let by_list = vec![(20, 500.0), (21, 600.0)]; + assert_eq!(widths("[20 11 0 R]"), by_list); + assert_eq!(widths("[10 0 R [500 600]]"), by_list); + assert_eq!(widths("[10 0 R 11 0 R]"), by_list); + assert_eq!(vertical("[20 12 0 R]"), vec![(20, [-1000.0, 500.0, 880.0])]); + + // A reference that only leads back to itself holds no number, so those glyphs keep `/DW`. for array in ["[7 0 R [500 600]]", "[8 0 R [500 600]]", "[20 7 0 R 700]", "[7 0 R 8 0 R 700]", "[20 [7 0 R 600]]"] { - widths(array); + assert!(widths(array).is_empty(), "{array}"); } for array in ["[7 0 R [-1000 500 880]]", "[8 0 R 22 -900 400 800]", "[20 [7 0 R 500 880]]"] { - vertical(array); + assert!(vertical(array).is_empty(), "{array}"); } } } diff --git a/vendor/hayro-syntax/src/data.rs b/vendor/hayro-syntax/src/data.rs index 9d5e2030f8..90c44f89c9 100644 --- a/vendor/hayro-syntax/src/data.rs +++ b/vendor/hayro-syntax/src/data.rs @@ -1,5 +1,4 @@ use crate::object::ObjectIdentifier; -use crate::object::Stream; use crate::reader::ReaderContext; use crate::sync::HashMap; use crate::sync::{Arc, Mutex, MutexExt}; @@ -92,6 +91,12 @@ impl Data { &self.data } + /// PdfCraft patch: (hayro-syntax (33)) enter the gate for what an object stream's index depends + /// on, once its data is read and [`Self::get_with`] has let go of it. + pub(crate) fn enter_loading(&self, id: ObjectIdentifier) -> Option> { + self.loading.enter(id) + } + /// Get access to the data of a decoded object stream. pub(crate) fn get_with(&self, id: ObjectIdentifier, ctx: &ReaderContext<'_>) -> Option<&[u8]> { // PdfCraft patch: (#307, see vendor/README.md hayro-syntax (10)) register exactly once: @@ -110,7 +115,8 @@ impl Data { let _loading = self.loading.enter(id)?; self.decoded .get_or_init(idx, || { - let stream = ctx.xref().get_with::>(id, ctx)?; + // PdfCraft patch: (hayro-syntax (33)) only ever an object stream. + let stream = ctx.xref().object_stream(id, ctx)?; stream.decoded().ok().map(Cow::into_owned) })? .as_deref() @@ -244,7 +250,7 @@ mod tests { } } -struct LoadGuard<'a>(&'a LoadGate); +pub(crate) struct LoadGuard<'a>(&'a LoadGate); impl Drop for LoadGuard<'_> { fn drop(&mut self) { diff --git a/vendor/hayro-syntax/src/object/ref.rs b/vendor/hayro-syntax/src/object/ref.rs index 71951f4d82..3d3cbffbe1 100644 --- a/vendor/hayro-syntax/src/object/ref.rs +++ b/vendor/hayro-syntax/src/object/ref.rs @@ -39,10 +39,11 @@ impl From for ObjRef { impl Skippable for ObjRef { fn skip(r: &mut Reader<'_>, _: bool) -> Option<()> { + // PdfCraft patch: (hayro-syntax (33)) a comment counts as white space, here too. r.skip::(false)?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.skip::(false)?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.forward_tag(b"R")?; Some(()) @@ -52,9 +53,9 @@ impl Skippable for ObjRef { impl Readable<'_> for ObjRef { fn read(r: &mut Reader<'_>, _: &ReaderContext<'_>) -> Option { let obj_ref = r.read_without_context::()?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); let gen_num = r.read_without_context::()?; - r.skip_white_spaces(); + r.skip_white_spaces_and_comments(); r.forward_tag(b"R")?; Some(Self::new(obj_ref, gen_num)) diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 745af95556..a2cfc9ca96 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -12,7 +12,7 @@ use crate::object::dict::keys::{ }; use crate::object::dict::{DictCache, probe_dict}; use crate::object::indirect::IndirectObject; -use crate::object::{Array, MaybeRef}; +use crate::object::{Array, MaybeRef, ObjRef}; use crate::object::{DateTime, Dict}; use crate::object::{Object, ObjectLike}; use crate::pdf::PdfVersion; @@ -31,6 +31,17 @@ use core::ops::Deref; pub(crate) const XREF_ENTRY_LEN: usize = 20; +/// PdfCraft patch: (hayro-syntax (33)) how many references a read follows through objects that hold +/// nothing else. Past it, as any cycle goes, the object reads as a missing one. +const MAX_REFERENCE_HOPS: usize = 32; + +/// PdfCraft patch: (hayro-syntax (33)) what [`XRef::fetch`] found at an object's place. +enum Fetched { + Object(T), + /// All the object holds is a reference to this one. + Reference(ObjectIdentifier), +} + #[derive(Debug, Copy, Clone)] pub(crate) enum XRefError { Unknown, @@ -492,7 +503,11 @@ impl XRef { iter::from_fn(move || { for next in iter.by_ref() { - if let Some(obj) = self.get_with(next.0, &ReaderContext::new(self, false)) { + // PdfCraft patch: (hayro-syntax (33)) an object that only refers to another + // isn't one of its own: followed, that one came out once more for each. + if let Some(Fetched::Object(obj)) = + self.fetch(next.0, &ReaderContext::new(self, false)) + { return Some(obj); } else { // Skip invalid objects. @@ -563,12 +578,48 @@ impl XRef { } /// Return the object with the given identifier. + /// + /// PdfCraft patch: (hayro-syntax (33)) an object that holds only a reference stands for what + /// that names. Read as a number it was the reference's own object number, and as anything else + /// it was lost. This is a loop, so a long chain takes no stack. #[allow(private_bounds)] pub(crate) fn get_with<'a, T>( &'a self, id: ObjectIdentifier, ctx: &ReaderContext<'a>, ) -> Option + where + T: ObjectLike<'a>, + { + let mut id = id; + for _ in 0..=MAX_REFERENCE_HOPS { + match self.fetch::(id, ctx)? { + Fetched::Object(object) => return Some(object), + Fetched::Reference(next) => id = next, + } + } + warn!("references lead on past the limit, or in a cycle: {id:?}"); + + None + } + + /// PdfCraft patch: (hayro-syntax (33)) the object stream `id`. It has to be a stream stored as + /// that object (ISO 32000-2 7.5.7), so this follows no reference: the check of where it is stored + /// is of that object, and one reached through a reference could be a stream stored inside + /// another. + pub(crate) fn object_stream<'a>( + &'a self, + id: ObjectIdentifier, + ctx: &ReaderContext<'a>, + ) -> Option> { + match self.fetch::>(id, ctx)? { + Fetched::Object(stream) => Some(stream), + Fetched::Reference(_) => None, + } + } + + /// What is stored at the object with the given identifier. + fn fetch<'a, T>(&'a self, id: ObjectIdentifier, ctx: &ReaderContext<'a>) -> Option> where T: ObjectLike<'a>, { @@ -597,9 +648,13 @@ impl XRef { ctx.set_in_object_stream(false); r.jump(offset); + if let Some(next) = held_reference(r.clone(), id) { + return Some(Fetched::Reference(next)); + } + if let Some(object) = r.read_with_context::>(&ctx) { if object.id() == &id { - return Some(object.get()); + return Some(Fetched::Object(object.get())); } } else { // There is a valid object at the offset, it's just not of the type the caller @@ -622,7 +677,7 @@ impl XRef { self.repair(); // Now try reading again. - self.get_with::(id, &ctx) + self.fetch::(id, &ctx) } } EntryType::ObjStream(obj_stram_gen_num, index) => { @@ -657,11 +712,20 @@ impl XRef { // A stream whose pairs don't parse stays unreadable, as it was. Some(offsets) => offsets?, None => { - let stream = self.get_with::>(obj_stream_id, &ctx)?; + // PdfCraft patch: (hayro-syntax (33)) this read can reach another object + // stream, whose `/N` or `/First` can be a member of the next: the gate's + // limit holds here too, or a chain of them ran out of stack. It reads the + // dictionary, which is all it needs: parsing the stream reads `/Length` + // too, a second way to the next stream's index, which doubled at each. + let _loading = repr.data.enter_loading(obj_stream_id)?; + let Fetched::Object(dict) = self.fetch::>(obj_stream_id, &ctx)? + else { + return None; + }; // `/N` and `/First` may be references that can't be followed while // another stream loads: reading them fails without being remembered. - let num_objects = stream.dict().get::(N)?; - let first_offset = stream.dict().get::(FIRST)?; + let num_objects = dict.get::(N)?; + let first_offset = dict.get::(FIRST)?; let parsed = read_object_stream_index(num_objects, first_offset, data).map(Arc::new); self.with_object_stream_indexes(|indexes| { @@ -673,7 +737,12 @@ impl XRef { .unwrap_or(parsed)? } }; - ObjectStream::with_offsets(data, &ctx, offsets).get(index) + let stream = ObjectStream::with_offsets(data, &ctx, offsets); + if let Some(next) = stream.reference(index) { + return Some(Fetched::Reference(next)); + } + + stream.get(index).map(Fetched::Object) } } } @@ -1232,6 +1301,28 @@ impl<'a> ObjectStream<'a> { r.read_with_context::(&self.ctx) } + + /// PdfCraft patch: (hayro-syntax (33)) what the object at `index` refers to, when a reference is + /// all it holds. + fn reference(&self, index: u32) -> Option { + let offset = self.offsets.get(index as usize)?.1; + let mut r = Reader::new(self.data); + r.jump(offset); + r.skip_white_spaces_and_comments(); + + Some(r.read_without_context::()?.into()) + } +} + +/// PdfCraft patch: (hayro-syntax (33)) what the indirect object `id` at `r` refers to, when a +/// reference is all it holds. +fn held_reference(mut r: Reader<'_>, id: ObjectIdentifier) -> Option { + if r.read_without_context::()? != id { + return None; + } + r.skip_white_spaces_and_comments(); + + Some(r.read_without_context::()?.into()) } fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { @@ -1266,6 +1357,200 @@ fn parse_metadata(info_dict: &Dict<'_>) -> Metadata { #[cfg(test)] mod tests { use super::*; + use crate::Pdf; + + /// A file with an xref stream: `stored` are objects on their own, as they would follow + /// `n 0 obj`, and `packed` says of each object in an object stream which stream and place. + fn with_xref_stream(stored: &[(u32, Vec)], packed: &[(u32, u32, u16)]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = alloc::collections::BTreeMap::new(); + for (num, body) in stored { + offsets.insert(*num, out.len()); + out.extend_from_slice(alloc::format!("{num} 0 obj ").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let last = stored.iter().map(|o| o.0).chain(packed.iter().map(|o| o.0)).max().unwrap_or(0); + let (xref, size) = (last + 1, last + 2); + let at = out.len(); + offsets.insert(xref, at); + let packed: alloc::collections::BTreeMap = packed.iter().map(|o| (o.0, (o.1, o.2))).collect(); + let mut rows = Vec::new(); + for num in 0..size { + if let Some(offset) = offsets.get(&num) { + rows.push(1); + rows.extend_from_slice(&(*offset as u32).to_be_bytes()); + rows.extend_from_slice(&[0, 0]); + } else if let Some((stream, index)) = packed.get(&num) { + rows.push(2); + rows.extend_from_slice(&stream.to_be_bytes()); + rows.extend_from_slice(&index.to_be_bytes()); + } else { + rows.extend_from_slice(&[0; 7]); + } + } + out.extend_from_slice( + alloc::format!( + "{xref} 0 obj << /Type /XRef /Size {size} /W [1 4 2] /Root 1 0 R /Length {} >> stream\n", + rows.len() + ) + .as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(alloc::format!("\nendstream endobj\nstartxref\n{at}\n%%EOF\n").as_bytes()); + out + } + + /// `/Type /ObjStm` around `members`, each stored at its offset, and `entries` of its own. + fn object_stream_of(members: &[(u32, &[u8])], entries: &str) -> Vec { + let (mut head, mut body) = (alloc::string::String::new(), Vec::new()); + for (num, member) in members { + head.push_str(&alloc::format!("{num} {} ", body.len())); + body.extend_from_slice(member); + body.push(b'\n'); + } + head.push('\n'); + let dict = alloc::format!( + "<< /Type /ObjStm /N {} /First {} /Length {} {entries} >> stream\n", + members.len(), + head.len(), + head.len() + body.len() + ); + let mut out = dict.into_bytes(); + out.extend_from_slice(head.as_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(b"\nendstream"); + out + } + + fn page_objects() -> Vec<(u32, Vec)> { + vec![ + (1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()), + (2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec()), + (3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>".to_vec()), + ] + } + + /// An object stream is a stream stored as that object, however many references lead to one. + /// Object 10 holds a reference to 11, a stream stored inside object stream 12, and 31 claims to + /// be in 10, with 11's `/N`, `/First` or `/Length` being 31: reading it recursed until the + /// stack ran out. It reads as missing, and so does a member of such a stream. + #[test] + fn an_object_stream_isnt_reached_through_a_reference() { + for key in ["N", "First", "Length"] { + let inner = alloc::format!("<< /Type /ObjStm /{key} 31 0 R >> stream\n31 0 1\n\nendstream"); + let mut stored = page_objects(); + stored.push((10, b"11 0 R".to_vec())); + stored.push((12, object_stream_of(&[(11, inner.as_bytes())], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(11, 12, 0), (31, 10, 0)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(31, 0)), None, "{key}"); + } + let inner = b"<< /Type /ObjStm /N 1 /First 5 /Length 8 >> stream\n20 0 7\n\nendstream"; + let mut stored = page_objects(); + stored.push((10, b"11 0 R".to_vec())); + stored.push((12, object_stream_of(&[(11, inner)], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(11, 12, 0), (20, 10, 0)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(20, 0)), None); + } + + /// `count` object streams stored as themselves, the `keys` of each a member of the next, or + /// with `ring` of the first after the last. Reading the first member reads them all, and it is + /// `count + 100` to tell. + fn chain_of_object_streams(count: u32, keys: &[&str], ring: bool) -> Vec { + let mut stored = page_objects(); + let mut packed = Vec::new(); + for i in 0..count { + let (stream, member) = (100 + 2 * i, 101 + 2 * i); + let value = if i == 0 { count + 100 } else { 1 }; + let data = alloc::format!("{member} 0 \n{value}\n"); + let (first, length) = (alloc::format!("{}", alloc::format!("{member} 0 \n").len()), alloc::format!("{}", data.len())); + let next = match (i + 1 == count, ring) { + (true, true) => "101 0 R".into(), + (true, false) => "1".into(), + _ => alloc::format!("{} 0 R", member + 2), + }; + let entry = |key, own: &'_ str| if keys.contains(&key) { next.clone() } else { own.into() }; + let (n, first, length): (alloc::string::String, alloc::string::String, alloc::string::String) = + (entry("N", "1"), entry("First", &first), entry("Length", &length)); + let body = alloc::format!("<< /Type /ObjStm /N {n} /First {first} /Length {length} >> stream\n{data}\nendstream"); + stored.push((stream, body.into_bytes())); + packed.push((member, stream, 0)); + } + with_xref_stream(&stored, &packed) + } + + /// A chain of object streams, each with a member of the next for its `/N`, `/First` or + /// `/Length`, had no limit: one read recursed through all of them, holding a copy of the chain + /// at each, and 50,000 ran out of an 8 MiB stack. It stops at the gate's limit, on half the stack + /// of a render worker. Past that `/N` and `/First` can't be read, so the member reads as missing, + /// and a `/Length` that can't be read falls back on `endstream`, so it reads. A short chain reads. + #[test] + fn a_chain_of_object_streams_ends_at_the_limit_of_nested_loads() { + for key in ["N", "First", "Length"] { + let pdf = chain_of_object_streams(50_000, &[key], false); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let pdf = Pdf::new(pdf).unwrap(); + let read = pdf.xref().get::(ObjectIdentifier::new(101, 0)); + assert_eq!(read, (key == "Length").then_some(50_100), "{key}"); + }) + .unwrap() + .join() + .unwrap(); + } + let pdf = Pdf::new(chain_of_object_streams(10, &["N"], false)).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(101, 0)), Some(110)); + } + + /// `/Length` and `/N` or `/First` both by reference were two ways to the next stream's index, + /// and an index that fails isn't remembered, so the work doubled at each stream: 65 streams, one + /// past the limit, in 8 KB, didn't finish, and other threads waited at the gate. The index reads + /// the dictionary alone now. Without that this doesn't end, so the time allowed can't fail it. + #[test] + fn a_chain_of_object_streams_takes_no_more_work_for_two_ways_on() { + for (count, ring) in [(65, false), (20, true), (5_000, false)] { + for keys in [&["Length", "N"][..], &["Length", "First"]] { + let pdf = chain_of_object_streams(count, keys, ring); + let (done, finished) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || { + let pdf = Pdf::new(pdf).unwrap(); + let _ = pdf.xref().get::(ObjectIdentifier::new(101, 0)); + let _ = done.send(()); + }) + .unwrap(); + assert!(finished.recv_timeout(std::time::Duration::from_secs(120)).is_ok(), "{keys:?} of {count}"); + } + } + } + + /// Object 5, a member of a real object stream, holds a reference to object 6 stored on its own, + /// and 7, stored on its own, to 8, a member: each reads as what it names. + #[test] + fn a_reference_held_in_either_storage_is_followed() { + let mut stored = page_objects(); + stored.push((6, b"42".to_vec())); + stored.push((7, b"8 0 R".to_vec())); + stored.push((9, object_stream_of(&[(5, b"6 0 R"), (8, b"43")], ""))); + let pdf = Pdf::new(with_xref_stream(&stored, &[(5, 9, 0), (8, 9, 1)])).unwrap(); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(5, 0)), Some(42)); + assert_eq!(pdf.xref().get::(ObjectIdentifier::new(7, 0)), Some(43)); + } + + /// With no page tree, every page object is a page, and one that another object only refers to + /// is one page, not two. + #[test] + fn a_page_a_reference_is_held_to_is_found_once() { + let mut stored = page_objects(); + stored[0].1 = b"<< /Type /Catalog /Pages 99 0 R >>".to_vec(); + stored[2].1 = b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 4 0 R >>".to_vec(); + stored.push((4, b"<< /Length 0 >> stream\n\nendstream".to_vec())); + stored.push((12, b"3 0 R".to_vec())); + let pdf = Pdf::new(with_xref_stream(&stored, &[])).unwrap(); + assert_eq!(pdf.pages().len(), 1); + } // PdfCraft patch tests (ported from craft-libs): xref stream entries are bounded by the // decoded data, and the type field is read at its full width. From 89fed9a9e612147f217bb161a9ec5747e38f9963 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 17:17:59 +0100 Subject: [PATCH 102/562] M2: the chain tests want std, like the module's other thread tests They use std::thread and std::sync::mpsc, so they take the same gate, and so does the helper only they use. The crate's test build doesn't compile without std on main either, for reasons of its own; these add nothing to that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- vendor/hayro-syntax/src/xref.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index a2cfc9ca96..356bad6b15 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -1456,6 +1456,7 @@ mod tests { /// `count` object streams stored as themselves, the `keys` of each a member of the next, or /// with `ring` of the first after the last. Reading the first member reads them all, and it is /// `count + 100` to tell. + #[cfg(feature = "std")] fn chain_of_object_streams(count: u32, keys: &[&str], ring: bool) -> Vec { let mut stored = page_objects(); let mut packed = Vec::new(); @@ -1484,6 +1485,7 @@ mod tests { /// at each, and 50,000 ran out of an 8 MiB stack. It stops at the gate's limit, on half the stack /// of a render worker. Past that `/N` and `/First` can't be read, so the member reads as missing, /// and a `/Length` that can't be read falls back on `endstream`, so it reads. A short chain reads. + #[cfg(feature = "std")] #[test] fn a_chain_of_object_streams_ends_at_the_limit_of_nested_loads() { for key in ["N", "First", "Length"] { @@ -1507,6 +1509,7 @@ mod tests { /// and an index that fails isn't remembered, so the work doubled at each stream: 65 streams, one /// past the limit, in 8 KB, didn't finish, and other threads waited at the gate. The index reads /// the dictionary alone now. Without that this doesn't end, so the time allowed can't fail it. + #[cfg(feature = "std")] #[test] fn a_chain_of_object_streams_takes_no_more_work_for_two_ways_on() { for (count, ring) in [(65, false), (20, true), (5_000, false)] { From 8a6373525eeec8b58c97466792eb8246ad96d12c Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 1 Nov 2026 17:17:59 +0100 Subject: [PATCH 103/562] M8: the JBIG2 redaction test compares the whole saved picture One eight-byte search would miss a clearing that took only the first byte of it: the window is gone and seven covered bytes stay. The saved picture now has to be the one with every pixel under the mark cleared and no other changed, on both coded forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/redact/src/tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 96cc2f5064..07b720795c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -740,6 +740,8 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() { /// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels /// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen /// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding. +/// The picture saved is the one with every pixel under the mark cleared and no other changed, so a +/// search that one cleared byte would blind isn't all that holds this. #[test] fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E]; @@ -765,6 +767,12 @@ fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { _ => false, }) }; + // Columns 0 to 99 of rows 0 to 7. + let mut cleared = bits.clone(); + for row in cleared.chunks_mut(stride).take(8) { + row[..12].fill(0); + row[12] &= 0x0F; + } let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests"); @@ -773,12 +781,12 @@ fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { // Half a point a pixel, its top row at y 132. let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]); assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found"); - // Columns 0 to 99 of rows 0 to 7. mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], ""); assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file"); assert!(holds(&saved, &KEPT), "the pixels beside them stay"); + assert!(holds(&saved, &cleared), "every pixel under the mark is cleared, and no other changed"); } } From 424ca0e9729a0dab057a820bc8bea3c1b28ebccb Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:52:47 -0200 Subject: [PATCH 104/562] M14: Preferences sets how many recent files to keep (#5572) (#5675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list was fixed at 12. Preferences ▸ Documents and view now takes a number from 0 to 50, with 12 as the default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. Until then Home and saved settings already leave out what is past it. A settings file can't ask for more than 50. The control channel and launch options take it as recent-files. The two new strings go in every catalog as English copies. --- ROADMAP.md | 2 + apps/pdfcraft/src/main.rs | 2 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 6 + crates/ui-egui/src/app/files.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 8 +- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/home.rs | 31 +-- crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/ckb.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fa.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/kk.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/lo.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 4 + crates/ui-egui/src/i18n/nb.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ro.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sl.tsv | 4 + crates/ui-egui/src/i18n/sq.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/th.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/persist/last_session.rs | 2 +- crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/workflows/js_ui.rs | 9 + crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/recent_files.rs | 257 +++++++++++++++++++++ parity/acrobat-features.toml | 6 +- 49 files changed, 459 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/tests/it/recent_files.rs diff --git a/ROADMAP.md b/ROADMAP.md index 01b9dbe814..74d9241e1c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. + - 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged. - 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index d2a75c5941..5bc5c5c372 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -10,7 +10,7 @@ //! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed //! --organize on --fields on --dialog properties|shortcuts|about --palette --home on //! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off -//! --page-gaps on|off --default-page-gaps on|off +//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50 //! --combine-view grid|list --combine-zoom 60..200` //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index abfecba94f..619f406cbf 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -118,6 +118,7 @@ impl PdfCraftApp { all_tools_query: String::new(), all_tools_tab: (None, false), recent: Vec::new(), + recent_limit: crate::RECENT_FILES_DEFAULT, reopen_last_session: false, restore_last_view: true, last_views: Vec::new(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 85fdae38af..26e20f8df5 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -170,6 +170,12 @@ impl PdfCraftApp { _ => return Err("default-page-gaps must be on or off".into()), }; } + ("recent-files", _) => { + let max = crate::RECENT_FILES_MAX; + let limit = value.parse().ok().filter(|n| *n <= max).ok_or_else(|| format!("recent-files must be between 0 and {max}"))?; + self.recent_limit = limit; + self.recent.truncate(limit); + } ("cover", Some(v)) => { let on = match value { "on" => true, diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 5ef110d6aa..29e6978461 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -333,7 +333,7 @@ impl PdfCraftApp { macos_association::repair(std::path::Path::new(&p)); self.recent.retain(|r| r.path != p); self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size }); - self.recent.truncate(12); + self.recent.truncate(self.recent_limit); } // What the form's scripts said while it opened (messages, errors) shows now, not with // the next edit. diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7d3fd6b948..c2a7de31dc 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -78,7 +78,7 @@ impl PdfCraftApp { let mut v = serde_json::json!({ "side_panels": self.side_panels.to_json(self.left_open), "docking_right": self.right, - "recent": self.recent, + "recent": self.recent.iter().take(self.recent_limit).collect::>(), "reopen_last_session": self.reopen_last_session, "restore_last_view": self.restore_last_view, "last_views": self.last_views_to_save(), @@ -139,6 +139,7 @@ impl PdfCraftApp { }); // Set apart: one more key inside the macro above passes serde_json's recursion limit. v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps); + v["recent_limit"] = serde_json::json!(self.recent_limit); v["fill_text_color"] = serde_json::json!(self.fill_text_color); v["redact_prefs"] = self.redact_defaults.to_json(); v["pinned_tools"] = serde_json::json!(self.pinned_tools); @@ -181,6 +182,11 @@ impl PdfCraftApp { let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); self.recent = r; } + // Nonsense keeps the limit it had; settings from before the limit have none. + if let Some(limit) = v["recent_limit"].as_u64() { + self.recent_limit = usize::try_from(limit).map_or(crate::RECENT_FILES_MAX, |n| n.min(crate::RECENT_FILES_MAX)); + } + self.recent.truncate(self.recent_limit); self.restore_layout_prefs(&v); self.restore_behavior_prefs(&v); self.restore_view_prefs(&v); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6786ac6b0c..aec79cf4f5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1931,6 +1931,8 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: // Protect / Remove security replace the Properties dialog. let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some(); if modal_closed || req.close || replaces { + // Only now: the number in Preferences can pass through 0 on its way to the one wanted. + app.recent.truncate(app.recent_limit); app.dialog = None; app.props_draft = None; app.view_draft = None; diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 957fe661fd..4f5ac9deba 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -186,25 +186,28 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { crate::folders_ui::section(app, ui); } - ui.add_space(26.0); let mut clear = false; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0))); - if !app.recent.is_empty() { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); - clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); - }); + // Preferences can turn the list off (#5572). + if app.recent_limit > 0 { + ui.add_space(26.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0))); + if !app.recent.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); + clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); + }); + } + }); + ui.add_space(8.0); + if app.recent.is_empty() { + ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted)); } - }); - ui.add_space(8.0); - if app.recent.is_empty() { - ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted)); } let mut open = None; let mut remove = None; - for r in &app.recent { + for r in app.recent.iter().take(app.recent_limit) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); let press = theme::Press::track(ui, &resp); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index babaa33002..1b08929307 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -20,6 +20,10 @@ # reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. # Spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu القائمة File ملف Edit تحرير diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 48af0db09a..e269febf18 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -7,6 +7,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + About За програмата Contributors Сътрудници Models Модели diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 54901bb5a4..7ff0f83fee 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menú File Fitxer Edit Edita diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 7138dc2c1f..3b6a7671ed 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -20,6 +20,10 @@ # reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. # Spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu پێڕست File فایل Edit دەستکاریکردن diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 51a569f4fc..22f6e3e056 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -19,6 +19,10 @@ # commands is kept. Messages with a count but no `@plural` entry use count-neutral wording # ("Vybráno stránek: {n}"), because Czech has three plural forms. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Nabídka File Soubor Rotation: Otočení: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e8e1f8b555..2c9ce7ae36 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menü File Datei Edit Bearbeiten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4d9bd63fb0..d83bf4aef7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2,6 +2,11 @@ # Partial, clean-room initial contribution. Entries not yet translated fall back to English. # Translate from English meanings; retain placeholders and trailing ellipses. # Greek plural forms: one (1) | other (0, 2+). + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Μενού File Αρχείο Edit Επεξεργασία diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1a695aae4d..131d2dacaf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menú File Archivo Edit Edición diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 1dcb047198..4f9286b843 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -19,6 +19,10 @@ # each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins # compound words. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu منو File فایل Edit ویرایش diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 0ba2f47d06..8c1a9baab9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -16,6 +16,11 @@ # Labels use sentence case. Product and technology names (PdfCraft, PDF, OCR, JavaScript ...) # stay in Latin letters; the trailing "…" of dialog commands is kept. Calendar headings use the # nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`). + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Valikko File Tiedosto Edit Muokkaa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 613e0d7107..1c22cf9d81 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -9,6 +9,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File Fichier Edit Édition diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 3b38f82719..52c5450bb3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2,6 +2,11 @@ # context English source translation # Clean-room translation written from the English source's meaning. # License: MIT OR Apache-2.0. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu मेनू File फ़ाइल Edit संपादित करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7d2f8fbf4c..47d9c24b40 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -9,6 +9,11 @@ # consulted or copied; add entries the same way. # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menü File Fájl Edit Szerkesztés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bb3a50830a..89152f62a4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3,6 +3,11 @@ # Clean-room translations of PdfCraft's English UI labels. Use standard Indonesian computing # terms; keep product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word...) unchanged. # Indonesian does not inflect nouns for number, so @plural entries list one form. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu Edit Edit Pages Halaman diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a43bbbff7e..0cae8f3e98 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File File Edit Modifica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 879a68aff1..abc616b817 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu メニュー File ファイル Edit 編集 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 673ae57b31..fc6a2d783a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1,3 +1,7 @@ +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Мәзір File Файл Edit Түзету diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c57b7e4cd2..20315b5fcf 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2,6 +2,11 @@ # context English source translation # Clean-room translation written from the English source's meaning. # License: MIT OR Apache-2.0. + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu 메뉴 File 파일 Edit 편집 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 3be898ead2..ea684f6d59 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "..." of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu ເມນູ File ໄຟລ໌ Edit ແກ້ໄຂ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e6747a80aa..fd7f431154 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -4,6 +4,10 @@ # consult the Lietuvos Respublikos terminų bankas (terminai.vlkk.lt) when selecting terminology. # Product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain unchanged. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Meniu Edit Redaguoti Pages Puslapiai diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 447897dd34..b54b3e3526 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -7,6 +7,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Allow Tillat Cancel Avbryt Only allow this if you trust the source document. Tillat dette bare hvis du stoler på kildedokumentet. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e7e6b9d357..208dd1c61e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -15,6 +15,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File Plik Edit Edycja diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3c4d0537cd..a172a03879 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menu File Arquivo Rotation: Rotação: diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index cf6abda4b1..0d0fbf16b4 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -9,6 +9,10 @@ # the trailing "…" of dialog commands is kept. Commands use the noun form ("Salvare", "Deschidere…"), # messages address the user formally ("Alegeți", "dvs."). +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Meniu Edit Editare Pages Pagini diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 46ff335c81..b45d73ecab 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Меню File Файл Edit Редактирование diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 928aec3a7b..c95f6d6bd6 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Meni File Datoteka Edit Urejanje diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index de28ddf506..eb9eed3a0e 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menyja File Skedari Edit Redakto diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index cac3c73204..9b81ffb8b9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -12,6 +12,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu మెనూ File ఫైల్ Edit సవరణ diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 72fecec870..f7f8a65c5c 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -7,6 +7,10 @@ # names stay in Latin letters; the trailing ellipsis of dialog commands is kept. Thai has one # plural form. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu เมนู File ไฟล์ Edit แก้ไข diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1dba482650..6921a6c33e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4,6 +4,10 @@ # # Clean-room: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Menü File Dosya Edit Düzen diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 297e1e7fa3..a824769600 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -18,6 +18,10 @@ # Integer plurals (CLDR uk): one (1, 21) | few (2–4, 22–24) | many (0, 5–19); 11–14 take many. # Commands use infinitives. Fixed-count templates use count-neutral wording. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Меню File Файл Edit Редагування diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2a51854c07..e79a8aacc4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7,6 +7,11 @@ # Plural rule: `plural_none` — every @plural row lists exactly one form. # Context-sensitive wording and intentional unchanged strings are regression-tested in i18n/mod.rs. # + +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu Trình đơn Edit Chỉnh sửa Pages Trang diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1425da8d0d..96e9c3bb75 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + # About, contributor/model credits, and multiline interface guidance. About 关于 Contributors 贡献者 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 644ac99d54..a1b933b830 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -8,6 +8,10 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. +# Untranslated: English text until a translator fills these in. + Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list. + Recent files to keep Recent files to keep + Menu 選單 File 檔案 Edit 編輯 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37e8e53165..44059ee4aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -145,7 +145,7 @@ use state::CopyPage; use state::OpenRequest; pub use state::{ CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt, - PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt, + PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt, }; use state::{DocumentCopyJob, GridFile}; pub use tools::bulk_fields; @@ -346,6 +346,8 @@ pub struct PdfCraftApp { /// tab coming to the front (Home, a document, Combine files) puts the view away. all_tools_tab: (Option, bool), pub recent: Vec, + /// How many recent files to keep, 0 for no list (Preferences ▸ Documents and view, #5572). + pub recent_limit: usize, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, /// Restore last view settings when reopening documents (Preferences ▸ Documents and view; diff --git a/crates/ui-egui/src/persist/last_session.rs b/crates/ui-egui/src/persist/last_session.rs index 7bc4833c0a..b46de0de7a 100644 --- a/crates/ui-egui/src/persist/last_session.rs +++ b/crates/ui-egui/src/persist/last_session.rs @@ -98,7 +98,7 @@ impl PdfCraftApp { /// the Recovery dialog offers, so neither opens twice. /// /// Only one password prompt can wait at a time: the first encrypted file asks for its - /// password and later ones stay closed (they are in the recent-files list). + /// password and later ones stay closed (in the recent-files list, if there is one). pub fn reopen_last_files(&mut self, skip: &[String]) { let last = std::mem::take(&mut self.last_session); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index eb8c3a0e8d..e3a9c540a6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -365,6 +365,11 @@ impl UnlockPrompt { } } +/// How many recent files are kept until Preferences says otherwise. +pub const RECENT_FILES_DEFAULT: usize = 12; +/// The most Preferences can ask to keep. +pub const RECENT_FILES_MAX: usize = 50; + #[derive(serde::Serialize, serde::Deserialize)] pub struct RecentFile { pub name: String, diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index c876992785..4c99ee1db8 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -492,6 +492,15 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok } }); ui.label(egui::RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); + // The web build has no file paths to list. + #[cfg(not(target_arch = "wasm32"))] + { + ui.horizontal(|ui| { + let label = ui.label(tl!("Recent files to keep")); + ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id); + }); + ui.label(egui::RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted)); + } // The web build has no file paths to reopen. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 5189548462..bce7c4d069 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -67,6 +67,7 @@ mod prepare; mod print; mod protect; mod reading_colors; +mod recent_files; mod recovery; mod redact; mod reload; diff --git a/crates/ui-egui/tests/it/recent_files.rs b/crates/ui-egui/tests/it/recent_files.rs new file mode 100644 index 0000000000..b4eccfb10e --- /dev/null +++ b/crates/ui-egui/tests/it/recent_files.rs @@ -0,0 +1,257 @@ +//! Preferences ▸ Recent files to keep (#5572): how many files the recent list holds, from none +//! up to 50, on every path that adds to it, shows it or saves it. + +use egui::Key; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, RECENT_FILES_DEFAULT, RecentFile}; + +use crate::support; + +const PDF: &[u8] = include_bytes!("../data/form.pdf"); + +fn recent_file(name: &str) -> RecentFile { + RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 } +} + +fn names(app: &PdfCraftApp) -> Vec<&str> { + app.recent.iter().map(|r| r.name.as_str()).collect() +} + +fn app_with(files: &[&str]) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.recent.extend(files.iter().map(|n| recent_file(n))); + app +} + +/// Tall enough that the whole of Home is on screen without scrolling. +fn harness(limit: usize, files: &[&str]) -> Harness<'static, PdfCraftApp> { + let files: Vec = files.iter().map(ToString::to_string).collect(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe(move |_cc| { + let mut app = app_with(&files.iter().map(String::as_str).collect::>()); + app.recent_limit = limit; + app + }); + h.run_steps(4); + h +} + +/// Open a document with a file behind it, as a file on disk is recorded. +fn open(app: &mut PdfCraftApp, name: &str) { + app.open_bytes(name, Some(format!("/nowhere/{name}")), PDF.to_vec()).unwrap(); +} + +/// Typed the way a person does: click the field, type, Enter. +fn type_limit(h: &mut Harness<'static, PdfCraftApp>, value: &str) { + h.get_by_role_and_label(Role::SpinButton, "Recent files to keep").click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, Key::A); + h.run_steps(1); + h.event(egui::Event::Text(value.into())); + h.run_steps(1); + h.key_press(Key::Enter); + h.run_steps(3); +} + +#[test] +fn the_list_keeps_twelve_files_until_preferences_says_otherwise() { + let mut app = app_with(&[]); + assert_eq!(app.recent_limit, 12); + for n in 0..=RECENT_FILES_DEFAULT { + open(&mut app, &format!("{n}.pdf")); + } + assert_eq!(app.recent.len(), 12); + assert_eq!(app.recent[0].name, "12.pdf", "newest first"); + assert_eq!(app.recent[11].name, "1.pdf", "the oldest of the thirteen went"); +} + +#[test] +fn opening_a_file_keeps_the_newest_up_to_the_limit() { + let mut app = app_with(&[]); + app.recent_limit = 2; + for name in ["first.pdf", "second.pdf", "third.pdf"] { + open(&mut app, name); + } + assert_eq!(names(&app), ["third.pdf", "second.pdf"]); + open(&mut app, "second.pdf"); + assert_eq!(names(&app), ["second.pdf", "third.pdf"], "a file opened again moves up and loses nothing"); +} + +#[test] +fn at_zero_opening_a_file_records_nothing() { + let mut app = app_with(&[]); + app.recent_limit = 0; + open(&mut app, "first.pdf"); + assert!(app.recent.is_empty()); + let saved: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(saved["recent"], serde_json::json!([]), "and the saved list is empty"); +} + +#[test] +fn at_zero_home_has_no_recent_section() { + let h = harness(0, &[]); + assert!(h.query_by_label("Recent").is_none(), "no heading"); + assert!(h.query_by_label_contains("Files you open in PdfCraft appear here").is_none(), "and no promise of a list"); + h.get_by_label_contains("PdfCraft works offline"); +} + +#[test] +fn above_zero_an_empty_list_still_says_what_it_is_for() { + let h = harness(1, &[]); + h.get_by_label("Recent"); + h.get_by_label_contains("Files you open in PdfCraft appear here"); +} + +#[test] +fn at_zero_the_menu_and_the_palette_have_nothing_to_open() { + let mut h = harness(0, &[]); + support::open_menu(&mut h, "File"); + assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "Open Recent is disabled"); + assert!(h.state_mut().execute("file.open_recent")); + assert!(h.state().toast.as_ref().is_some_and(|(m, _)| m.contains("No recent files"))); + assert!(h.state().views.is_empty(), "and nothing opened"); +} + +#[test] +fn the_longest_list_scrolls_in_the_smallest_window_down_to_clear() { + let files: Vec = (0..50).map(|n| format!("{n}.pdf")).collect(); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 520.0)).build_eframe({ + let files = files.clone(); + move |_cc| { + let mut app = app_with(&files.iter().map(String::as_str).collect::>()); + app.recent_limit = 50; + app.open_bytes("doc.pdf", None, PDF.to_vec()).unwrap(); + app + } + }); + h.run_steps(4); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); + h.run_steps(3); + let foot = |h: &Harness<'static, PdfCraftApp>| support::menu_row(h, "Clear Recent Files").rect().max.y; + assert!(foot(&h) > 520.0, "fifty rows run past the smallest window"); + support::menu_row(&h, "1.pdf").hover(); + h.run_steps(2); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(8); + assert!(foot(&h) <= 520.0, "the wheel brings the foot of the menu into view"); + support::menu_row(&h, "Clear Recent Files").click(); + h.run_steps(3); + assert!(h.state().recent.is_empty()); +} + +#[test] +fn preferences_cuts_the_list_when_it_closes_and_not_before() { + let mut h = harness(12, &["first.pdf", "second.pdf", "third.pdf"]); + h.state_mut().dialog = Some(Dialog::Preferences); + h.run_steps(4); + type_limit(&mut h, "0"); + assert_eq!(h.state().recent_limit, 0); + assert_eq!(h.state().recent.len(), 3, "0 on the way to another number forgets nothing"); + assert!(h.query_by_label("first.pdf").is_none(), "though Home already leaves them out"); + type_limit(&mut h, "2"); + assert_eq!(h.state().recent.len(), 3); + h.get_by_label("OK").click(); + h.run_steps(4); + assert_eq!(h.state().dialog, None); + assert_eq!(names(h.state()), ["first.pdf", "second.pdf"], "the newest two stay"); +} + +#[test] +fn escape_closes_preferences_with_the_same_result_as_ok() { + let mut h = harness(12, &["first.pdf", "second.pdf"]); + h.state_mut().dialog = Some(Dialog::Preferences); + h.run_steps(4); + type_limit(&mut h, "1"); + h.key_press(Key::Escape); + h.run_steps(4); + assert_eq!(h.state().dialog, None); + assert_eq!(names(h.state()), ["first.pdf"]); +} + +#[test] +fn the_field_does_not_go_past_fifty() { + let mut h = harness(12, &[]); + h.state_mut().dialog = Some(Dialog::Preferences); + h.run_steps(4); + type_limit(&mut h, "999"); + assert_eq!(h.state().recent_limit, 50); +} + +#[test] +fn settings_saved_while_preferences_is_open_already_leave_the_rest_out() { + let mut app = app_with(&["first.pdf", "second.pdf"]); + app.recent_limit = 1; + let saved = app.persist(); + assert!(saved.contains("first.pdf")); + assert!(!saved.contains("second.pdf")); +} + +#[test] +fn the_limit_is_saved_and_restored_and_cuts_a_longer_list() { + let dir = std::env::temp_dir().join(format!("pdfcraft-recent-limit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["a.pdf", "b.pdf", "c.pdf"] + .iter() + .map(|n| { + let path = dir.join(n); + std::fs::write(&path, PDF).unwrap(); + path.to_string_lossy().into_owned() + }) + .collect(); + let entries: Vec = paths.iter().map(|p| RecentFile { name: "x.pdf".into(), path: p.clone(), pages: 1, size: 0 }).collect(); + let json = serde_json::json!({ "recent": entries, "recent_limit": 2 }).to_string(); + let mut restored = PdfCraftApp::new(); + restored.restore(&json); + assert_eq!(restored.recent_limit, 2); + let kept: Vec<&str> = restored.recent.iter().map(|r| r.path.as_str()).collect(); + assert_eq!(kept, [paths[0].as_str(), paths[1].as_str()]); + let mut again = PdfCraftApp::new(); + again.restore(&restored.persist()); + assert_eq!(again.recent_limit, 2); + assert_eq!(again.recent.len(), 2); + std::fs::remove_dir_all(&dir).unwrap(); +} + +#[test] +fn a_settings_file_can_not_ask_for_a_limit_that_is_not_one() { + for (saved, want) in [ + ("{}", 12), + ("{\"recent_limit\": null}", 12), + ("{\"recent_limit\": \"3\"}", 12), + ("{\"recent_limit\": -1}", 12), + ("{\"recent_limit\": 2.5}", 12), + ("{\"recent_limit\": 0}", 0), + ("{\"recent_limit\": 50}", 50), + ("{\"recent_limit\": 51}", 50), + ("{\"recent_limit\": 18446744073709551615}", 50), + ] { + let mut app = PdfCraftApp::new(); + app.restore(saved); + assert_eq!(app.recent_limit, want, "{saved}"); + } +} + +#[test] +fn the_control_channel_sets_the_limit_and_refuses_what_it_can_not_be() { + let mut app = app_with(&["first.pdf", "second.pdf"]); + for bad in ["51", "-1", "x", "", "1.5"] { + let error = app.set_option("recent-files", bad).unwrap_err(); + assert!(error.contains("between 0 and 50"), "{bad}: {error}"); + } + assert_eq!((app.recent_limit, app.recent.len()), (12, 2), "a refused value changes nothing"); + app.set_option("recent-files", "50").unwrap(); + assert_eq!((app.recent_limit, app.recent.len()), (50, 2)); + app.set_option("recent-files", "1").unwrap(); + assert_eq!(names(&app), ["first.pdf"], "cut at once, with no dialog to wait for"); + app.set_option("recent-files", "0").unwrap(); + assert!(app.recent.is_empty()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8035e3873d..af616e7758 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9957,11 +9957,11 @@ title = "Preferences: documents (autosave interval, recent count)" area = "N" tier = "P1" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/it/recent_files.rs::preferences_cuts_the_list_when_it_closes_and_not_before", "crates/ui-egui/tests/it/recent_files.rs::at_zero_home_has_no_recent_section", "crates/ui-egui/tests/it/recent_files.rs::a_settings_file_can_not_ask_for_a_limit_that_is_not_one"] +notes = "Preferences ▸ Documents and view: Recent files to keep, from 0 to 50 with 12 by default (#5572). At 0 Home has no Recent section and File ▸ Open Recent is disabled; Restore the last view keeps its own record of paths under its own switch. Still missing: the autosave interval (recovery snapshots run every AUTOSAVE_SECS, 60 s) and the rest of Acrobat's Documents page." [[feature]] id = "misc.prefs-commenting" From 7d78f3e14474b978be014715e0cc64895d77025a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 1 Nov 2026 13:58:27 -0500 Subject: [PATCH 105/562] Release 0.17.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 72 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 68 +++++++++++++++++++++++++-------------------------- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c29337d75..ccf080b27a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.16.1" +version = "0.17.0" dependencies = [ "chrono", "craft-update", @@ -4232,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4244,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4255,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4266,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.16.1" +version = "0.17.0" dependencies = [ "base64 0.23.1", "flate2", @@ -4288,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.16.1" +version = "0.17.0" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4306,11 +4306,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-content" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.16.1" +version = "0.17.0" dependencies = [ "chrono", "hayro-syntax", @@ -4333,7 +4333,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.16.1" +version = "0.17.0" dependencies = [ "encoding_rs", "hayro-ccitt", @@ -4353,7 +4353,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.16.1" +version = "0.17.0" dependencies = [ "aes", "md-5", @@ -4365,7 +4365,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4383,7 +4383,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.16.1" +version = "0.17.0" dependencies = [ "chrono", "craft-docpath", @@ -4426,14 +4426,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.16.1" +version = "0.17.0" dependencies = [ "fax", "flate2", @@ -4446,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.16.1" +version = "0.17.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4469,11 +4469,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-js" -version = "0.16.1" +version = "0.17.0" dependencies = [ "boa_engine", "chrono", @@ -4481,7 +4481,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4494,14 +4494,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.16.1" +version = "0.17.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4512,7 +4512,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.16.1" +version = "0.17.0" dependencies = [ "image", "pdfcraft-annot", @@ -4528,11 +4528,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-organize" -version = "0.16.1" +version = "0.17.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4543,7 +4543,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.16.1" +version = "0.17.0" dependencies = [ "async-channel", "futures-lite", @@ -4553,7 +4553,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.16.1" +version = "0.17.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4564,7 +4564,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.16.1" +version = "0.17.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4576,7 +4576,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4593,7 +4593,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.16.1" +version = "0.17.0" dependencies = [ "bytemuck", "flate2", @@ -4612,7 +4612,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.16.1" +version = "0.17.0" dependencies = [ "aes", "aws-lc-rs", @@ -4648,11 +4648,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.16.1" +version = "0.17.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.16.1" +version = "0.17.0" dependencies = [ "arboard", "base64 0.23.1", @@ -4706,7 +4706,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.16.1" +version = "0.17.0" dependencies = [ "eframe", "egui", @@ -4718,7 +4718,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.16.1" +version = "0.17.0" dependencies = [ "base64 0.23.1", "image", @@ -4732,7 +4732,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.16.1" +version = "0.17.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index 85d02570ba..c9db94b27b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.16.1" +version = "0.17.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -26,39 +26,39 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.16.1" } -pdfcraft-filters = { path = "crates/filters", version = "0.16.1" } -pdfcraft-cos = { path = "crates/cos", version = "0.16.1" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" } -pdfcraft-organize = { path = "crates/organize", version = "0.16.1" } -pdfcraft-measure = { path = "crates/measure", version = "0.16.1" } -pdfcraft-annot = { path = "crates/annot", version = "0.16.1" } -pdfcraft-audit = { path = "crates/audit", version = "0.16.1" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" } -pdfcraft-model = { path = "crates/model", version = "0.16.1" } -pdfcraft-content = { path = "crates/content", version = "0.16.1" } -pdfcraft-redact = { path = "crates/redact", version = "0.16.1" } -pdfcraft-print = { path = "crates/print", version = "0.16.1" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" } -pdfcraft-sign = { path = "crates/sign", version = "0.16.1" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" } -pdfcraft-js = { path = "crates/js", version = "0.16.1" } -pdfcraft-compare = { path = "crates/compare", version = "0.16.1" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" } -pdfcraft-export = { path = "crates/export", version = "0.16.1" } -pdfcraft-edit = { path = "crates/edit", version = "0.16.1" } -pdfcraft-create = { path = "crates/create", version = "0.16.1" } -pdfcraft-forms = { path = "crates/forms", version = "0.16.1" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" } -pdfcraft-render = { path = "crates/render", version = "0.16.1" } -pdfcraft-engine = { path = "crates/engine", version = "0.16.1" } -pdfcraft-platform = { path = "crates/platform", version = "0.16.1" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" } -pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.17.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.17.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.17.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.17.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.17.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.17.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.17.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" } +pdfcraft-model = { path = "crates/model", version = "0.17.0" } +pdfcraft-content = { path = "crates/content", version = "0.17.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.17.0" } +pdfcraft-print = { path = "crates/print", version = "0.17.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.17.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" } +pdfcraft-js = { path = "crates/js", version = "0.17.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.17.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" } +pdfcraft-export = { path = "crates/export", version = "0.17.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.17.0" } +pdfcraft-create = { path = "crates/create", version = "0.17.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.17.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" } +pdfcraft-render = { path = "crates/render", version = "0.17.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.17.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.17.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false } # pdf hayro = "0.7.1" From fdaa502f279e3e960381705ba505a944e11c5fe7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:32 +0100 Subject: [PATCH 106/562] Reject signed and empty arcs in try_oid (#5581) Found while testing crates/sign/src/der.rs:467-472 (try_oid) Co-authored-by: Claude Haiku 5.5 --- crates/sign/src/der.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 6248c1e9e1..0cac252c33 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -465,7 +465,12 @@ pub fn oid(dotted: &str) -> Vec { /// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and /// never panics on malformed input. pub fn try_oid(dotted: &str) -> Result, SignError> { - let mut arcs = dotted.split('.').map(|a| a.parse::().map_err(|_| bad("invalid OID arc"))); + let mut arcs = dotted.split('.').map(|a| { + if a.is_empty() || !a.bytes().all(|c| c.is_ascii_digit()) { + return Err(bad("invalid OID arc")); + } + a.parse::().map_err(|_| bad("invalid OID arc")) + }); let first = arcs.next().ok_or_else(|| bad("empty OID"))??; let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??; if first > 2 || (first < 2 && second >= 40) { @@ -549,6 +554,14 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec { mod tests { use super::*; + #[test] + fn try_oid_rejects_signs_and_empty_arcs() { + for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] { + assert!(try_oid(bad).is_err(), "accepted {bad}"); + } + assert_eq!(try_oid("1.2.840.113549").unwrap(), oid("1.2.840.113549")); + } + #[test] fn round_trips() { let o = oid("1.2.840.113549.1.7.2"); From 2289b5755d72baa4f84cf3f8d449a6eacddb3096 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:36 +0100 Subject: [PATCH 107/562] Ignore a negative trailer /Size in incremental saves (#5566) Found while testing crates/cos/src/writer.rs:248 Co-authored-by: Claude Haiku 5.5 --- crates/cos/src/writer.rs | 2 +- crates/cos/tests/it/writer.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 627f716495..cd0137f452 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -245,7 +245,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, if let Some(p) = prev { trailer.set(b"Prev".to_vec(), Object::Int(p as i64)); } - let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32); + let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32)); if as_stream { let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect(); write_xref_stream(&mut out, base, &mut trailer, rows, size); diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 1b98664255..eb03523189 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() { assert_qpdf_clean(&saved); } +#[test] +fn incremental_save_ignores_a_negative_trailer_size() { + let mut doc = Document::open(Arc::new(classic_table())).unwrap(); + doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1)); + let next = doc.add(Object::Int(5)).num + 1; + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next))); +} + /// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is /// not written: the file never held it, and writing it would keep what was removed. Additions the /// update still refers to, from the trailer or a changed object and through other additions, are. From 778aa585223750381d56b1fe8350d5c61aa9f214 Mon Sep 17 00:00:00 2001 From: Guillem Hermida <66697068+guillehr2@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:42 +0100 Subject: [PATCH 108/562] tests(engine): the XFA opening scale test times its small and large forms in the same rounds (#5586) The test timed the 100-field form three times, then the 800-field form three times, and compared the best of each. A busy spell on a shared runner that fell on the second half slowed only the large form, and the ratio went past 16x (16.6x on a gate run) though it is about 7x. Each of five rounds now opens the small form and then the large one, and the best round's ratio is checked against the same 16x, after one untimed opening of each. With the values filled one by one again (the quadratic code before #4638) it still fails, at about 48x. --- crates/engine/src/tests.rs | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 51a19b7b24..bba8d89ae7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6192,24 +6192,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { /// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value /// read the whole form again (twice), so the time grew with the square of the fields. Eight /// times the fields now takes at most about eight times as long (it took about fifty). +/// +/// Each round opens the small form and then the large one, so a busy spell on a shared machine +/// slows both, and the best round's ratio is checked. Timing every small opening before the +/// large ones let such a spell fall on the large ones alone. #[test] fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() { - let time = |n: usize| { - let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); - (0..3) - .map(|_| { - let mut s = Session::new(); - let start = std::time::Instant::now(); - s.open("many.pdf", None, bytes.clone(), None).expect("opens"); - start.elapsed() - }) - .min() - .unwrap_or_default() + let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100)); + let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800)); + let time = |bytes: &Arc>| { + let mut s = Session::new(); + let start = std::time::Instant::now(); + s.open("many.pdf", None, bytes.clone(), None).expect("opens"); + start.elapsed() }; - let small = time(100); - let large = time(800); - let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); - assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)"); + // The first openings also pay for what the process sets up once. + time(&small); + time(&large); + let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect(); + let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6); + let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min); + assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)"); } /// A date goes into the XFA data in the field's data picture (``), as other From a74e3d4715bd6edfaf5205e31f1758e96b9208f3 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:47 +0100 Subject: [PATCH 109/562] M3: the wheel scrolls right after Alt+Tab back, before the mouse moves (#1518) (#5608) After switching back to the window, the windowing layer sends no pointer move until the mouse moves, so egui knew no pointer and the wheel scrolled nothing. When a frame turns the wheel with no known pointer and no move of its own, the raw-input hook now asks the system where the pointer is (drag_pointer::in_window, already used for file drags) and puts that move first. --- crates/ui-egui/src/app.rs | 4 ++ crates/ui-egui/src/platform/drag_pointer.rs | 69 ++++++++++++++++++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 619f406cbf..68e30b24ab 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -409,6 +409,10 @@ impl eframe::App for PdfCraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // The button's release a window move or resize kept from egui. window_controls::release_after_desktop_grab(ctx, raw); + // The wheel after Alt+Tab back, before the mouse has moved (#1518). + let known = ctx.input(|i| i.pointer.latest_pos().is_some()); + let size = ctx.input(|i| i.viewport().inner_rect).map_or(egui::Vec2::ZERO, |r| r.size()); + drag_pointer::locate_wheel(raw, known, size, || drag_pointer::in_window(ctx)); self.prepare_raw_input(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { return; diff --git a/crates/ui-egui/src/platform/drag_pointer.rs b/crates/ui-egui/src/platform/drag_pointer.rs index 05ef05c426..3074f7d851 100644 --- a/crates/ui-egui/src/platform/drag_pointer.rs +++ b/crates/ui-egui/src/platform/drag_pointer.rs @@ -1,6 +1,7 @@ -//! Where the pointer is while files are dragged over the window. The windowing layer reports -//! that a file hovers, but not where, and sends no pointer moves until the drag ends; the -//! system has to be asked. +//! Where the pointer is when the windowing layer doesn't say: while files are dragged over the +//! window (it reports that a file hovers, but not where, and sends no pointer moves until the drag +//! ends), and when the wheel turns after switching back to the window (no move is sent until the +//! mouse moves, #1518). The system has to be asked. /// The pointer in this window's coordinates (egui points), when the system can tell. #[cfg(target_os = "macos")] @@ -38,3 +39,65 @@ pub(crate) fn in_window(ctx: &egui::Context) -> Option { pub(crate) fn in_window(_ctx: &egui::Context) -> Option { None } + +/// Wheel input with no known pointer: after Alt+Tab back to the window, the windowing layer sends +/// no pointer move until the mouse moves, so egui doesn't know what is under the pointer and the +/// wheel scrolls nothing (#1518). When `raw` turns the wheel, has no pointer move of its own and +/// egui knows no pointer position (`known`), the pointer from `system` is put first, if it is +/// inside the window's `size` (egui points). +pub(crate) fn locate_wheel(raw: &mut egui::RawInput, known: bool, size: egui::Vec2, system: impl FnOnce() -> Option) { + let wheel = raw.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. })); + let moved = raw.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerGone)); + if !wheel || moved || known { + return; + } + if let Some(pos) = system().filter(|p| egui::Rect::from_min_size(egui::Pos2::ZERO, size).contains(*p)) { + raw.events.insert(0, egui::Event::PointerMoved(pos)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wheel() -> egui::Event { + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } + } + + fn located(events: Vec, known: bool, system: Option) -> Vec { + let mut raw = egui::RawInput { events, ..Default::default() }; + locate_wheel(&mut raw, known, egui::vec2(800.0, 600.0), || system); + raw.events + } + + #[test] + fn a_wheel_with_no_known_pointer_asks_the_system_where_it_is() { + let at = egui::pos2(300.0, 200.0); + // After Alt+Tab back: the wheel turns, nothing moved, egui knows no pointer. + assert_eq!(located(vec![wheel()], false, Some(at)), vec![egui::Event::PointerMoved(at), wheel()]); + // Otherwise nothing is added: egui knows the pointer, the frame moves it itself, there is + // no wheel, the system can't tell, or the pointer is outside the window. + assert_eq!(located(vec![wheel()], true, Some(at)), vec![wheel()]); + let moved = egui::Event::PointerMoved(egui::pos2(1.0, 1.0)); + assert_eq!(located(vec![moved.clone(), wheel()], false, Some(at)), vec![moved, wheel()]); + assert_eq!(located(vec![egui::Event::PointerGone, wheel()], false, Some(at)), vec![egui::Event::PointerGone, wheel()]); + assert_eq!( + located( + vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + false, + Some(at) + ) + .len(), + 1 + ); + assert_eq!(located(vec![wheel()], false, None), vec![wheel()]); + assert_eq!(located(vec![wheel()], false, Some(egui::pos2(900.0, 200.0))), vec![wheel()]); + assert_eq!(located(vec![wheel()], false, Some(egui::pos2(f32::NAN, 0.0))), vec![wheel()]); + } +} From 2242eb322f974f583eff972fdc67f6b92f0378cf Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:52 +0100 Subject: [PATCH 110/562] =?UTF-8?q?M10:=20Create=20=E2=96=B8=20Blank=20pag?= =?UTF-8?q?e=20uses=20the=20system=20region's=20paper=20(#5644)=20(#5651)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M10: Create ▸ Blank page uses the system region's paper (#5644) A new blank document was always US Letter. It now takes the paper of the system region: Windows' own Region paper size (iPaperSize) first, then the system locale on macOS or LC_ALL/LC_PAPER/LANG, A4 everywhere outside CLDR's US-Letter regions, and Letter when the region can't be told (web). The region parsing is shared with the Units preference. * M10: the first locale variable set decides the paper; extensions are not regions (#5644) LC_ALL, LC_PAPER and LANG are read in POSIX order and the first one set decides, so LC_ALL=C no longer falls through to LANG's region; and a locale tag's region is read from its base tag only, so en-US-u-nu-latn is US, not NU (CodeRabbit on #5651). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR * M10: the blank page test covers A4 as well as Letter (#5644) The app keeps the paper Create ▸ Blank page makes (blank_paper, the system region's at start), so the test makes a blank page on A4 and on Letter whatever the host's region (CodeRabbit on #5651). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --------- Co-authored-by: Claude --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/persist.rs | 1 + crates/ui-egui/src/persist/paper.rs | 150 ++++++++++++++++++++++ crates/ui-egui/src/persist/units.rs | 14 +- crates/ui-egui/src/workflows/create_ui.rs | 5 +- crates/ui-egui/tests/it/create.rs | 21 +++ parity/acrobat-features.toml | 4 +- 8 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/persist/paper.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 68e30b24ab..4de70f3d9e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -73,6 +73,7 @@ impl PdfCraftApp { default_mode: Mode::AllTools, view_defaults: Default::default(), units: Default::default(), + blank_paper: crate::paper::Paper::system(), date_format_draft: None, mode_override: None, left: LeftPanel::AllTools, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 44059ee4aa..ab5d1334e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -119,6 +119,7 @@ use panels::search_ui; pub use panels::side_panels; pub use persist::last_session; pub use persist::last_view; +pub use persist::paper; use persist::recovery; pub use persist::units; #[cfg(not(target_arch = "wasm32"))] @@ -264,6 +265,8 @@ pub struct PdfCraftApp { pub view_defaults: canvas::ViewDefaults, /// The unit page sizes and margins are shown in (Preferences ▸ Documents and view). pub units: units::Unit, + /// The paper Create ▸ Blank page makes (#5644): the system region's. + pub blank_paper: paper::Paper, /// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid /// pattern (the session keeps the last valid one). pub date_format_draft: Option, diff --git a/crates/ui-egui/src/persist.rs b/crates/ui-egui/src/persist.rs index bb29efeff2..32a8ec048d 100644 --- a/crates/ui-egui/src/persist.rs +++ b/crates/ui-egui/src/persist.rs @@ -1,5 +1,6 @@ pub mod last_session; pub mod last_view; +pub mod paper; pub(super) mod recovery; pub mod units; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/persist/paper.rs b/crates/ui-egui/src/persist/paper.rs new file mode 100644 index 0000000000..2aeb8fceb3 --- /dev/null +++ b/crates/ui-egui/src/persist/paper.rs @@ -0,0 +1,150 @@ +//! The paper size the system's region prints on: the size of a new blank document (Create ▸ +//! Blank page). + +use super::units::region_of; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Paper { + #[default] + Letter, + A4, +} + +impl Paper { + /// Width and height in points, portrait. + pub fn points(self) -> (f64, f64) { + match self { + Paper::Letter => (612.0, 792.0), + Paper::A4 => (595.28, 841.89), + } + } + + /// The system region's paper: US Letter in the Americas that use it, A4 everywhere else; + /// Letter when the region can't be told, as before the region was read. + pub fn system() -> Paper { + static SYSTEM: std::sync::OnceLock = std::sync::OnceLock::new(); + *SYSTEM.get_or_init(|| detect().unwrap_or_default()) + } +} + +/// The paper for a locale tag's region (`en_US.UTF-8`, `fr-BE`, `es_MX`); `None` without a region. +/// The Letter regions are CLDR's `paperSize` US-Letter territories. +pub(crate) fn paper_for_tag(tag: &str) -> Option { + Some(match region_of(tag)?.as_str() { + "US" | "CA" | "MX" | "PH" | "PR" | "BZ" | "CL" | "CO" | "CR" | "GT" | "NI" | "PA" | "SV" | "VE" => Paper::Letter, + _ => Paper::A4, + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn detect() -> Option { + // A Finder-started app gets no LANG, so the system locale's region decides first. + #[cfg(target_os = "macos")] + if let Ok(out) = std::process::Command::new("/usr/bin/defaults").args(["read", "-g", "AppleLocale"]).output() + && out.status.success() + && let Some(paper) = paper_for_tag(String::from_utf8_lossy(&out.stdout).trim()) + { + return Some(paper); + } + // Region ▸ the paper size Windows keeps per user, read in-process like the measurement system. + #[cfg(target_os = "windows")] + if let Some(paper) = windows_paper().and_then(|v| paper_for_ipapersize(&v)) { + return Some(paper); + } + paper_for_tag(&locale_for_paper(|var| std::env::var(var).ok())?) +} + +/// The locale that decides the paper, as POSIX picks it: the first of `LC_ALL`, `LC_PAPER` and +/// `LANG` that is set and not empty, read through `var`. A later one never stands in for it, so +/// `LC_ALL=C` with `LANG=de_DE` has no paper from the locale (CodeRabbit on #5651). +#[cfg(any(test, not(target_arch = "wasm32")))] +pub(crate) fn locale_for_paper(var: impl Fn(&str) -> Option) -> Option { + ["LC_ALL", "LC_PAPER", "LANG"].iter().find_map(|name| var(name).filter(|v| !v.is_empty())) +} + +/// The paper for the Windows `iPaperSize` value (a `DMPAPER` number): "1" Letter, "9" A4. `None` +/// for anything else, so another size falls through to the locale check. +#[cfg(any(test, target_os = "windows"))] +pub(crate) fn paper_for_ipapersize(value: &str) -> Option { + match value.trim() { + "1" => Some(Paper::Letter), + "9" => Some(Paper::A4), + _ => None, + } +} + +/// `HKCU\Control Panel\International\iPaperSize` as text; `None` when it is missing, unreadable +/// or not a string. +#[cfg(target_os = "windows")] +fn windows_paper() -> Option { + use winsafe::{HKEY, RegistryValue, co::RRF}; + match HKEY::CURRENT_USER.RegGetValue(Some("Control Panel\\International"), Some("iPaperSize"), RRF::RT_REG_SZ).ok()? { + RegistryValue::Sz(v) => Some(v), + _ => None, + } +} + +#[cfg(target_arch = "wasm32")] +fn detect() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::{Paper, locale_for_paper, paper_for_ipapersize, paper_for_tag}; + + #[test] + fn region_decides_the_paper() { + assert_eq!(paper_for_tag("en_US.UTF-8"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("fr_CA"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("es-MX"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("fr_BE.UTF-8"), Some(Paper::A4)); + assert_eq!(paper_for_tag("en_GB@euro"), Some(Paper::A4)); + assert_eq!(paper_for_tag("de-DE"), Some(Paper::A4)); + assert_eq!(paper_for_tag("zh-Hans-CN"), Some(Paper::A4)); + assert_eq!(paper_for_tag("C"), None); + assert_eq!(paper_for_tag("en"), None); + assert_eq!(paper_for_tag(""), None); + // A BCP 47 extension is not the region (CodeRabbit on #5651): `nu` is a key, not Niue. + assert_eq!(paper_for_tag("en-US-u-nu-latn"), Some(Paper::Letter)); + assert_eq!(paper_for_tag("de-DE-u-rg-uszzzz"), Some(Paper::A4)); + assert_eq!(paper_for_tag("en-x-us"), None); + } + + /// POSIX precedence (CodeRabbit on #5651): the first locale variable that is set decides, even + /// when it has no region; an empty one is not set. + #[test] + fn the_first_locale_variable_set_decides() { + let env = + |pairs: &'static [(&'static str, &'static str)]| move |name: &str| pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()); + assert_eq!(locale_for_paper(env(&[("LC_ALL", "C"), ("LANG", "de_DE.UTF-8")])).as_deref(), Some("C")); + assert_eq!(locale_for_paper(env(&[("LC_ALL", ""), ("LC_PAPER", "en_US"), ("LANG", "de_DE")])).as_deref(), Some("en_US")); + assert_eq!(locale_for_paper(env(&[("LANG", "fr_FR")])).as_deref(), Some("fr_FR")); + assert_eq!(locale_for_paper(env(&[])), None); + assert_eq!(locale_for_paper(env(&[("LC_ALL", "C.UTF-8"), ("LANG", "de_DE")])).and_then(|t| paper_for_tag(&t)), None); + } + + #[test] + fn windows_paper_size_decides_the_paper() { + assert_eq!(paper_for_ipapersize("1"), Some(Paper::Letter)); + assert_eq!(paper_for_ipapersize(" 9\r\n"), Some(Paper::A4)); + assert_eq!(paper_for_ipapersize("5"), None); + assert_eq!(paper_for_ipapersize(""), None); + } + + /// The registry read works in-process and gives a value the mapping understands, or nothing. + #[cfg(target_os = "windows")] + #[test] + fn windows_reads_ipapersize_in_process() { + if let Some(v) = super::windows_paper() { + assert!(v.trim().parse::().is_ok(), "unexpected iPaperSize {v:?}"); + } + } + + #[test] + fn a4_and_letter_in_points() { + assert_eq!(Paper::Letter.points(), (612.0, 792.0)); + let (w, h) = Paper::A4.points(); + assert_eq!(((w * 25.4 / 72.0).round(), (h * 25.4 / 72.0).round()), (210.0, 297.0)); + } +} diff --git a/crates/ui-egui/src/persist/units.rs b/crates/ui-egui/src/persist/units.rs index a345456fab..147c502513 100644 --- a/crates/ui-egui/src/persist/units.rs +++ b/crates/ui-egui/src/persist/units.rs @@ -69,14 +69,22 @@ impl Unit { /// The unit for a locale tag's region: `en_US.UTF-8`, `en-US`, `bg-BG`, `zh-Hans-CN`, `en_GB@euro`. /// `None` without a region (`C`, `POSIX`, `en`). pub(crate) fn unit_for_tag(tag: &str) -> Option { - let tag = tag.split(['.', '@']).next().unwrap_or_default(); - let region = tag.split(['-', '_']).skip(1).filter(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).last()?; - Some(match region.to_ascii_uppercase().as_str() { + Some(match region_of(tag)?.as_str() { "US" | "LR" | "MM" => Unit::Inches, _ => Unit::Millimetres, }) } +/// The upper-case two-letter region of a locale tag; `None` without one (`C`, `POSIX`, `en`). +/// Only the base tag counts: a BCP 47 extension or private use part (`-u-nu-latn`, `-x-…`, each +/// after a one-letter singleton) is not a region (CodeRabbit on #5651). +pub(crate) fn region_of(tag: &str) -> Option { + let tag = tag.split(['.', '@']).next().unwrap_or_default(); + let region = + tag.split(['-', '_']).skip(1).take_while(|p| p.len() != 1).filter(|p| p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())).last()?; + Some(region.to_ascii_uppercase()) +} + #[cfg(not(target_arch = "wasm32"))] fn detect() -> Option { // The system's own measurement setting first: a terminal's LANG is often en_US whatever the region. diff --git a/crates/ui-egui/src/workflows/create_ui.rs b/crates/ui-egui/src/workflows/create_ui.rs index 38e400a771..5a6c9caf7b 100644 --- a/crates/ui-egui/src/workflows/create_ui.rs +++ b/crates/ui-egui/src/workflows/create_ui.rs @@ -158,9 +158,10 @@ impl PdfCraftApp { self.open_created_bytes("Clipboard.pdf", created.map_err(|e| e.to_string())) } - /// Create ▸ Blank page: a new untitled US Letter document. + /// Create ▸ Blank page: a new untitled document on the system region's paper (#5644). pub(crate) fn create_blank(&mut self) { - let created = self.session.create_blank(612.0, 792.0, 1).map_err(|e| e.to_string()); + let (w, h) = self.blank_paper.points(); + let created = self.session.create_blank(w, h, 1).map_err(|e| e.to_string()); if let Err(e) = self.open_created_bytes("Untitled.pdf", created) { self.notify_fmt("Couldn't create a PDF: {e}", &[("e", &e.to_string())]); } diff --git a/crates/ui-egui/tests/it/create.rs b/crates/ui-egui/tests/it/create.rs index 1c9a757651..7260dd816b 100644 --- a/crates/ui-egui/tests/it/create.rs +++ b/crates/ui-egui/tests/it/create.rs @@ -380,3 +380,24 @@ fn multiple_files_past_a_thousand_are_all_used() { let doc = app.session.get(app.views[0].id).unwrap(); assert_eq!(doc.info.pages.len(), 1001, "every picked file is used, in order"); } + +/// Create ▸ Blank page uses the system region's paper, not always US Letter (#5644): the app +/// starts with the system's, and a blank page is on A4 and on Letter as the paper says +/// (CodeRabbit on #5651: a Letter host alone would not tell a hard-coded Letter apart). +#[test] +fn a_blank_page_is_on_the_system_regions_paper() { + use pdfcraft_ui_egui::paper::Paper; + assert_eq!(PdfCraftApp::new().blank_paper, Paper::system()); + for paper in [Paper::A4, Paper::Letter] { + let mut app = PdfCraftApp::new(); + app.blank_paper = paper; + assert!(app.execute("create.blank")); + let docs = app.session.docs(); + let crop = docs.last().and_then(|d| d.info.pages.first()).map(|p| p.crop).unwrap(); + let (w, h) = paper.points(); + assert!( + ((crop[2] - crop[0]) as f64 - w).abs() < 0.01 && ((crop[3] - crop[1]) as f64 - h).abs() < 0.01, + "{paper:?}: {crop:?} is not {w} × {h}" + ); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index af616e7758..301c941e5c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6988,8 +6988,8 @@ milestone = "M10" status = "shipped" commands = ["create.blank"] tools = ["doc_create"] -evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] -notes = "" +evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/ui-egui/tests/it/create.rs::a_blank_page_is_on_the_system_regions_paper", "crates/ui-egui/src/persist/paper.rs::region_decides_the_paper", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"] +notes = "The page is the system region's paper (#5644): US Letter in the US, Canada, Mexico, the Philippines and the other CLDR Letter regions, A4 elsewhere; Windows' own Region paper size (iPaperSize) first, then the system locale (macOS) or LC_ALL/LC_PAPER/LANG; Letter when the region is unknown (web). The doc_create tool keeps Letter unless given width and height. Missing: a page size or count chooser like Acrobat's New Document dialog." [[feature]] id = "create.from-html" From 901050a31ffcfbcef887992f65cf4b301dec4842 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:04:57 +0100 Subject: [PATCH 111/562] M14: the comments panel lays out only the cards in view (#5488) (#5583) * M14: the comments panel lays out only the cards in view (#5488) With more than 100 comments, a card or page header out of view that was measured at the current width only takes up its height instead of being laid out every frame. The selected card is always drawn, so it still scrolls itself into view. Shorter lists are laid out whole and keep every card in the accessibility tree. The 500-comment panel in tests/perf.rs drops from about 23 ms to 5 ms per frame locally, well inside the 40 ms budget it overran on CI. * M14: the comments panel measures a card again when it changes (#5488) A card's kept height is now per document and per what the card shows (its thread's text, author, date, status, colour and lock, and whether it is the selected card), so another document at the same width, an edit, an undo or a redo never lays the cards below it out at a stale height (CodeRabbit on #5583). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR * M14: a comment card with an editor open is never skipped (#5488) A card with an inline editor open on it or on one of its replies is laid out wherever it is, and its kept height is apart from the one without the editor, so the cards below make room for the editor (CodeRabbit on #5583). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --------- Co-authored-by: Claude --- crates/ui-egui/src/panels/comments_panel.rs | 128 +++++++++++++++----- crates/ui-egui/tests/it/comments.rs | 70 +++++++++++ 2 files changed, 171 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index c549ed8e3e..d54daf5717 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -241,6 +241,10 @@ pub(crate) fn show( } let mut page = usize::MAX; let mut group: Option = None; + // A card (or page header) out of view that was measured at this width only takes up its + // height: laying out hundreds of them every frame put the panel over the frame budget + // (#5488). Shorter lists are laid out whole, so every card stays in the accessibility tree. + let virtual_list = roots.len() > LAY_OUT_WHOLE; for a in roots { if sort != SortBy::Page { let g = group_of(a); @@ -252,48 +256,118 @@ pub(crate) fn show( } } else if a.page != page { page = a.page; - let n = page_counts.get(&page).copied().unwrap_or_default(); - let open = !view.comments.collapsed_pages.contains(&page); - ui.add_space(6.0); - let clicked = ui - .horizontal(|ui| { - let chevron = if open { "chevron-down" } else { "chevron-right" }; - let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked(); - let label = ui.add( - egui::Label::new( - egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)])) - .font(theme::semibold(12.5)) - .color(t.text_muted), - ) - .sense(Sense::click()), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()) - }); - toggle || label.clicked() - }) - .inner; - if clicked { - if open { - view.comments.collapsed_pages.push(page); - } else { - view.comments.collapsed_pages.retain(|&c| c != page); + // Per document: two documents shown at the same width don't share heights. + let header = ui.scope_id().with(("comment-page-header", view.id, page)); + if !(virtual_list && skip_if_hidden(ui, header)) { + let top = ui.cursor().min.y; + page_header(ui, t, info, page, page_counts.get(&page).copied().unwrap_or_default(), &mut view.comments.collapsed_pages); + if virtual_list { + remember_height(ui, header, top); } } - ui.add_space(2.0); } if sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page) { continue; } + let key = (a.page, a.index); let thread = replies_of(a); + let selected = view.comments.selected == Some(key); + // A height is kept for what the card showed when it was measured: its document, what the + // thread says, whether it was the selected card (which draws its menu and editor) and + // whether an editor was open on it. An + // edit, undo or redo that changes the card measures it afresh; other cards keep theirs. + // A card with an editor open on it or on a reply is always drawn too (CodeRabbit on + // #5583): a kept height would show it at its size before the editor opened. + let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i))); + let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing)); + // The selected card is always drawn, so it can scroll itself into view and keep its menu + // and editor. + if virtual_list && !selected && !editing && skip_if_hidden(ui, id) { + continue; + } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); + let top = ui.cursor().min.y; if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) { edit = Some(e); } + if virtual_list { + remember_height(ui, id, top); + } } edit } +/// The "Page N" group header with its fold chevron and comment count `n`; a click folds or +/// unfolds the page. +fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: usize, collapsed: &mut Vec) { + let open = !collapsed.contains(&page); + ui.add_space(6.0); + let clicked = ui + .horizontal(|ui| { + let chevron = if open { "chevron-down" } else { "chevron-right" }; + let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked(); + let label = ui.add( + egui::Label::new( + egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", info.pages.get(page).map_or("?", |p| p.label.as_str()))])) + .font(theme::semibold(12.5)) + .color(t.text_muted), + ) + .sense(Sense::click()), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())); + toggle || label.clicked() + }) + .inner; + ui.add_space(2.0); + if clicked { + if open { + collapsed.push(page); + } else { + collapsed.retain(|&c| c != page); + } + } +} + +/// What a card's height depends on in the document: the comment and its replies as shown (their +/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out +/// alike at one width. +fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for x in std::iter::once(a).chain(thread.iter().copied()) { + (&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h); + x.color.map(|c| c.map(f32::to_bits)).hash(&mut h); + } + h.finish() +} + +/// Comment lists up to this long are laid out whole; longer ones lay out only what is in view. +const LAY_OUT_WHOLE: usize = 100; + +/// For a part of the list (a card or a page header) that was measured at this width and would +/// fall out of view: takes up its height without laying it out, and returns `true`. +fn skip_if_hidden(ui: &mut egui::Ui, id: egui::Id) -> bool { + let width = ui.available_width(); + let Some(height) = ui.data(|d| d.get_temp::<(f32, f32)>(id)).filter(|(w, _)| (w - width).abs() < 0.5).map(|(_, h)| h) else { + return false; + }; + if ui.is_rect_visible(egui::Rect::from_min_size(ui.cursor().min, vec2(width, height))) { + return false; + } + ui.allocate_space(vec2(width, height)); + true +} + +/// Records the height part `id` took since the cursor was at `top`, for [`skip_if_hidden`]: less +/// the spacing `allocate_space` adds back after it. +fn remember_height(ui: &egui::Ui, id: egui::Id, top: f32) { + let height = ui.cursor().min.y - top - ui.spacing().item_spacing.y; + if height.is_finite() && height > 0.0 { + let width = ui.available_width(); + ui.data_mut(|d| d.insert_temp(id, (width, height))); + } +} + /// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment /// actions; returns an edit to apply. struct CardContext<'a> { diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 664f61733e..057663a76c 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3022,3 +3022,73 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.drop_at(from + delta); h.run_steps(3); } + +/// `n` pages with one comment each, "Card 1" to "Card n". +fn one_comment_per_page(n: usize) -> Vec { + let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 200] >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{} 0 R] >>", 4 + 2 * i)); + objs.push(format!("<< /Type /Annot /Subtype /Square /Rect [20 20 80 80] /C [1 0 0] /T (Ada) /Contents (Card {}) >>", i + 1)); + } + let mut out = b"%PDF-1.7\n".to_vec(); + for (i, o) in objs.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n"); + out +} + +/// #5488: the panel laid out every card on every frame, which put a few hundred comments over the +/// frame budget. Cards out of view now only take up their height, and the selected one is still +/// drawn wherever it is, so it can scroll into view. +#[test] +fn the_comments_panel_lays_out_only_the_cards_in_view() { + let mut h = harness_with_bytes(one_comment_per_page(120), |app| app.set_option("panel", "comments").unwrap()); + h.run_steps(4); + let drawn = h.query_all_by_label_contains("Card ").count(); + assert!((1..40).contains(&drawn), "{drawn} of 120 cards laid out"); + h.state_mut().views[0].comments.selected = Some((100, 0)); + h.state_mut().views[0].comments.reveal = true; + h.run_steps(4); + assert!(h.query_by_label("Card 101").is_some(), "the selected card is drawn and scrolled to"); + assert!(h.query_by_label("Card 1").is_none(), "the top of the list scrolled out of view"); +} + +/// CodeRabbit on #5583: a card's measured height belongs to what it showed. Card 1, far above the +/// cards in view, grows by two replies; the cards in view move down by its growth, not stay where +/// card 1's old height would put them. +#[test] +fn a_card_out_of_view_that_grows_moves_the_cards_below_it() { + let mut h = harness_with_bytes(one_comment_per_page(120), |app| app.set_option("panel", "comments").unwrap()); + h.run_steps(4); + h.state_mut().views[0].comments.selected = Some((60, 0)); + h.state_mut().views[0].comments.reveal = true; + h.run_steps(4); + assert!(h.query_by_label("Card 1").is_none(), "card 1 is out of view"); + let before = h.get_by_label("Card 61").rect().top(); + for text in ["A first reply", "A second reply"] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: text.into(), author: "Bo".into() }); + } + h.run_steps(4); + let after = h.get_by_label("Card 61").rect().top(); + assert!(after > before + 40.0, "the cards below a grown card move down: {before} → {after}"); +} + +/// CodeRabbit on #5583: a card with an editor open on it is drawn wherever it is, so the cards +/// below it make room for the editor rather than keep the card's height from before it opened. +#[test] +fn a_card_with_an_editor_open_out_of_view_is_drawn_at_its_size() { + let mut h = harness_with_bytes(one_comment_per_page(120), |app| app.set_option("panel", "comments").unwrap()); + h.run_steps(4); + h.state_mut().views[0].comments.selected = Some((60, 0)); + h.state_mut().views[0].comments.reveal = true; + h.run_steps(4); + assert!(h.query_by_label("Card 1").is_none(), "card 1 is out of view"); + let before = h.get_by_label("Card 61").rect().top(); + h.state_mut().views[0].comments.editing = Some((0, 0, "Card 1".into())); + h.run_steps(4); + let after = h.get_by_label("Card 61").rect().top(); + assert!(after > before + 20.0, "the cards below make room for card 1's editor: {before} → {after}"); +} From eafe9e2c0b36df955770fa82fef51f1fd35b70fe Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:05:01 +0100 Subject: [PATCH 112/562] M8: typing the last page of a range keeps what is typed (#5574) (#5579) * M8: typing the last page of a range keeps what is typed (#5574) The page-range dialogs (Redact pages, Rotate pages, Duplicate field, Number pages) raised the end to the start on every frame, so the half-typed "1" of "12" became the start before the "2" arrived. The end is now ordered only once its field is no longer being typed into. Number pages no longer lowers the start to a half-typed end, and its preview cannot underflow while the end is below the start. * M8: a test types the last page of Rotate Pages' range (#5574) Rotate Pages' From and to fields are labelled, and a test types "12" after a first page of 5 (pages 5-12 are turned) and "3" left by clicking OK (page 5 alone), as croquete1 asked on #5579. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR * M8: page ranges stay typed while the window is in the background, and OK orders them (#5574) Review follow-up. The end field's typing check used Response::has_focus, which is false while another window is in front, so switching apps halfway through "12" still raised the typed "1" to the start. It now asks egui's memory (widgets::typing_into). OK pressed while the end is still focused (a screen reader's click) orders the range first in Rotate pages, Duplicate field and Redact pages, and Number pages builds its edit from the ordered end, so a reversed range never reaches the edit. --------- Co-authored-by: Claude --- crates/ui-egui/src/dialogs.rs | 49 ++++++---- crates/ui-egui/src/tools/redact_ui.rs | 129 ++++++++++++++++++++++++-- crates/ui-egui/src/widgets.rs | 17 ++++ crates/ui-egui/tests/it/editing.rs | 31 +++++++ 4 files changed, 198 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index aec79cf4f5..42bed83657 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1086,6 +1086,7 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi use pdfcraft_engine::{PageOrientation as O, PageParity as P}; let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); let d = &mut app.rotate_draft; + let mut typing_to = false; ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { @@ -1107,10 +1108,11 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi widgets::radio_value(ui, &mut d.which, 0, tl!("All")); widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.which, 2, tl!("From")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); + let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + typing_to = + widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id)); ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); }); }); @@ -1142,10 +1144,12 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi }); ui.end_row(); }); - d.to = d.to.max(d.from); + widgets::keep_range_ordered(d.from, &mut d.to, typing_to); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::pill_button(ui, tl!("OK"), true).clicked() { + // OK while the end is still being typed (a screen reader can press it). + widgets::keep_range_ordered(d.from, &mut d.to, false); req.rotate_now = true; req.close = true; } @@ -1167,17 +1171,21 @@ fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pe ui.add_space(8.0); ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.all, false, tl!("From")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - d.to = d.to.max(d.from); + let typing_to = ui + .horizontal(|ui| { + widgets::radio_value(ui, &mut d.all, false, tl!("From")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n))); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + typing_to + }) + .inner; + widgets::keep_range_ordered(d.from, &mut d.to, typing_to); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::pill_button(ui, tl!("OK"), true).clicked() { + widgets::keep_range_ordered(d.from, &mut d.to, false); let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); req.close = true; @@ -1376,13 +1384,14 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); let d = &mut app.number_draft; d.to = d.to.clamp(1, n); - d.from = d.from.clamp(1, d.to); + d.from = d.from.clamp(1, n); + let mut typing_to = false; egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Pages")); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); ui.label(tl!("to")); - ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); + typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n))); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); }); ui.end_row(); @@ -1409,18 +1418,20 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); ui.end_row(); }); - d.to = d.to.max(d.from); + widgets::keep_range_ordered(d.from, &mut d.to, typing_to); let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); ui.add_space(8.0); - let preview = if d.from == d.to { + // `to` can be below `from` while it is being typed. + let preview = if d.to <= d.from { label(d.start) } else { - format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) + format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX)))) }; ui.label( egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted), ); - req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); + // Ordered here too: the footer's OK can be pressed while the end is still being typed. + req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); true } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 96b544b4d0..e55dd8c5c3 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -360,16 +360,24 @@ pub(crate) fn pages_body(ui: &mut egui::Ui, d: &mut PagesDraft, pages: usize, _t ui.label(egui::RichText::new(tl!("Mark Page Range")).font(crate::theme::semibold(18.0))); ui.add_space(8.0); widgets::radio_value(ui, &mut d.current, true, tl!("Current page")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.current, false, tl!("Pages from")); - ui.add_enabled(!d.current, egui::DragValue::new(&mut d.from).range(1..=pages)); - ui.label(tl!("to")); - ui.add_enabled(!d.current, egui::DragValue::new(&mut d.to).range(1..=pages)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())])); - }); - d.to = d.to.max(d.from); + let to_field = ui + .horizontal(|ui| { + let from_label = widgets::radio_value(ui, &mut d.current, false, tl!("Pages from")); + ui.add_enabled(!d.current, egui::DragValue::new(&mut d.from).range(1..=pages)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to_field = ui.add_enabled(!d.current, egui::DragValue::new(&mut d.to).range(1..=pages)).labelled_by(to_label.id); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &pages.to_string())])); + to_field + }) + .inner; + widgets::keep_range_ordered(d.from, &mut d.to, widgets::typing_into(&to_field)); ui.add_space(12.0); - buttons(ui, "OK", true) + let (ok, cancel) = buttons(ui, "OK", true); + if ok { + // OK while the end is still being typed (a screen reader can press it). + widgets::keep_range_ordered(d.from, &mut d.to, false); + } + (ok, cancel) } /// Find text and redact. Returns (search, cancel, import a word list). @@ -675,3 +683,106 @@ pub(crate) fn sanitize_body(ui: &mut egui::Ui, t: &Tokens) -> (bool, bool) { ui.add_space(12.0); buttons(ui, "Sanitize", true) } + +#[cfg(test)] +mod tests { + use super::*; + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + /// #5574: typing "12" into the end of 5 to 5 came out as 5. The end was raised to the start + /// after every keystroke, so the half-typed "1" was replaced before the "2" arrived. + #[test] + fn typing_the_last_page_keeps_what_is_typed() { + let mut fonts = false; + let mut h = egui_kittest::Harness::new_ui_state( + move |ui, d: &mut PagesDraft| { + // The app's fonts (the semibold heading) take effect from the next frame. + if !fonts { + ui.ctx().set_fonts(crate::theme::font_definitions()); + fonts = true; + return; + } + let t = Tokens::get(ui.ctx()); + pages_body(ui, d, 20, &t); + }, + PagesDraft { current: false, from: 5, to: 5 }, + ); + h.run(); + for (typed, expected) in [("12", 12), ("3", 5)] { + h.get_by_role_and_label(Role::SpinButton, "to").click(); + h.run(); + h.event(key(egui::Key::A, egui::Modifiers::COMMAND)); + for c in typed.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(2); + } + h.event(key(egui::Key::Enter, egui::Modifiers::NONE)); + h.run_steps(2); + assert_eq!(*h.state(), PagesDraft { current: false, from: 5, to: expected }, "typed {typed:?}"); + } + } + + /// The dialog with its OK result kept: (draft, OK pressed). + fn dialog() -> egui_kittest::Harness<'static, (PagesDraft, bool)> { + let mut fonts = false; + let mut h = egui_kittest::Harness::new_ui_state( + move |ui, (d, ok): &mut (PagesDraft, bool)| { + if !fonts { + ui.ctx().set_fonts(crate::theme::font_definitions()); + fonts = true; + return; + } + let t = Tokens::get(ui.ctx()); + *ok |= pages_body(ui, d, 20, &t).0; + }, + (PagesDraft { current: false, from: 5, to: 5 }, false), + ); + h.run(); + h + } + + fn type_last_page(h: &mut egui_kittest::Harness<'static, (PagesDraft, bool)>, typed: &str) { + h.get_by_role_and_label(Role::SpinButton, "to").click(); + h.run(); + h.event(key(egui::Key::A, egui::Modifiers::COMMAND)); + for c in typed.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(2); + } + } + + /// OK pressed while the end is still being typed (a screen reader's click leaves the field + /// focused) orders the range, so a reversed one never reaches the edit. + #[test] + fn ok_while_the_last_page_is_typed_orders_the_range() { + let mut h = dialog(); + type_last_page(&mut h, "3"); + assert_eq!(h.state().0.to, 3, "still typing"); + h.get_by_label("OK").click_accesskit(); + h.run_steps(2); + assert_eq!(*h.state(), (PagesDraft { current: false, from: 5, to: 5 }, true)); + } + + /// Switching to another window halfway through "12" leaves the typed "1" alone: the field + /// still has the keyboard when the window comes back. + #[test] + fn switching_windows_mid_number_keeps_what_is_typed() { + let mut h = dialog(); + type_last_page(&mut h, "1"); + h.input_mut().focused = false; + h.run_steps(2); + assert_eq!(h.state().0.to, 1, "the typed 1 stays while the window is in the background"); + h.input_mut().focused = true; + h.run_steps(2); + h.event(egui::Event::Text("2".into())); + h.run_steps(2); + h.event(key(egui::Key::Enter, egui::Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().0.to, 12); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d8267caa9f..5e33f9cd9a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -254,6 +254,23 @@ pub fn radio_value<'a, V: PartialEq>(ui: &mut egui::Ui, current: &mut V, alterna response } +/// Keeps the end of a "from … to …" page range at or after its start, but not while `typing` into the +/// end's `DragValue` ([`typing_into`]): the half-typed "1" of "12" is below `from`, and raising it +/// then replaced what the user was typing with `from` (#5574). A dialog's OK orders it whatever has +/// the keyboard (`typing` false), so a range left reversed never reaches the edit. +pub fn keep_range_ordered(from: usize, to: &mut usize, typing: bool) { + if !typing { + *to = (*to).max(from); + } +} + +/// Whether `field` is being typed into: it has the keyboard focus in egui's memory. Unlike +/// `Response::has_focus` this stays true while the window is in the background, so switching to +/// another app mid-number doesn't end the typing. +pub fn typing_into(field: &Response) -> bool { + field.ctx.memory(|m| m.has_focus(field.id)) +} + /// The box of a checkbox or the circle of a radio button. egui draws it with the widgets' visuals /// and has no style of its own for it, so it is set in a scope around the widget, keeping what /// the caller set (dialogs and the search panel already outline theirs): diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 12935b6d0f..7b245a5a9e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -3135,6 +3135,37 @@ fn extract_options_and_rotate_pages_dialog() { assert_eq!(d.info.pages.iter().map(|p| p.rotation).collect::>(), [90, 0]); } +/// #5579: typing the last page of Rotate Pages' range keeps what is typed (croquete1): "12" after +/// a first page of 5 rotates pages 5–12, not page 5 alone. An end typed below the start and left +/// by clicking OK is raised to the start as the field lets go: page 5, not nothing. +#[test] +fn rotate_pages_keeps_a_typed_last_page() { + use egui::accesskit::Role; + for (typed, rotated) in [("12", 5..13), ("3", 5..6)] { + let mut h = organize(20); + h.state_mut().run_command("page.rotate_dialog"); + h.run_steps(2); + let d = &mut h.state_mut().rotate_draft; + (d.which, d.from, d.to) = (2, 5, 5); + h.run_steps(1); + h.get_by_role_and_label(Role::SpinButton, "to").click(); + h.run_steps(2); + // As events, not `key_press`: its `run` checks the dialog's unnamed combo boxes. + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + for c in typed.chars() { + h.event(egui::Event::Text(c.to_string())); + h.run_steps(2); + } + // OK at once, the end still being typed. + h.get_by_label("OK").click(); + h.run_steps(3); + let s = h.state(); + let d = s.session.get(s.views[0].id).unwrap(); + let turned: Vec = d.info.pages.iter().enumerate().filter(|(_, p)| p.rotation != 0).map(|(i, _)| i + 1).collect(); + assert_eq!(turned, rotated.collect::>(), "typed {typed:?}"); + } +} + #[test] fn dragging_thumbnails_reorders_pages() { let mut h = organize(4); From 036811368baa469cf1bb9188dda1655e5a8774c4 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Sun, 1 Nov 2026 21:05:26 +0100 Subject: [PATCH 113/562] M2: both ends of a /Decode array have to be numbers to invert an export (#5678) An end that is missing or isn't a number sorted below any number, so [1], or [1 7 0 R] with 7 leading nowhere, read as inverted on a JPEG or a stencil mask, and so did an end too long to hold, which parses as infinite. Such an array now leaves the default mapping, as it does on the lossless path. Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY Co-authored-by: Claude Opus 5.5 --- crates/create/src/extract.rs | 8 ++++++-- crates/create/src/tests.rs | 12 +++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 2b3a0ffd73..b5500f60c3 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -236,12 +236,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. - let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()); + let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite()); let inverted = (jpeg || mask) && s.dict .get(b"Decode") .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| endpoint(a.first()) > endpoint(a.get(1)))) + // Both ends have to be finite numbers, as for lossless images, or the default mapping stands. + .and_then(|d| { + let a = d.as_array()?; + Some(endpoint(a.first())? > endpoint(a.get(1))?) + }) .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 4a1e007fd4..ff871d53ae 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -2237,12 +2237,14 @@ fn a_stencil_masks_decode_array_can_hold_references() { for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] { assert_eq!(export(decode), identity, "{decode}"); } - // A reference that only leads back to itself holds no number. The read ends, and the file comes - // out one way round or the other. - for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]"] { - let file = export(decode); - assert!(file == inverted || file == identity, "{decode}"); + // A reference that only leads back to itself holds no number, and an end that is missing or isn't + // a number leaves the default mapping, whichever end it is. + for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]", "[1]", "[1 /Name]", "[/Name 0]"] { + assert_eq!(export(decode), identity, "{decode}"); } + // Equal ends aren't the two ways round either, and a number too long to hold reads as infinite. + assert_eq!(export("[1 1]"), identity); + assert_eq!(export(&format!("[{} 0]", "9".repeat(400))), identity); } /// A mask of other proportions than its image is scaled to it, however far apart they are: the From 07582e9ed4cf8155cb12bb1597fb0abb1f6ac219 Mon Sep 17 00:00:00 2001 From: Andrey <38911974+Barzabel@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:05:43 +0300 Subject: [PATCH 114/562] Make MCP aligns enums compatible with Kimi (#5592) * M13: make MCP aligns enums Kimi-compatible Fixes #5588 * Drop the progress-log lines that conflict with main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/README.md | 3 ++ crates/automation/src/content.rs | 7 ++-- crates/automation/src/tools.rs | 6 +-- crates/automation/tests/it/automation.rs | 47 +++++++++++++++++++++++- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/automation/README.md b/crates/automation/README.md index 7a9de516c0..92fd2775c9 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -21,6 +21,9 @@ them with the image's current orientation. Full turns retain its appearance, rec and flips. Angles that are not multiples of 90 are refused without changing the document or its undo/redo history. +`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry +follows the box-level `align`; legacy `null` entries remain accepted with the same meaning. + `Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent). ## Conventions diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 7518072d1c..6b25c31f61 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -83,10 +83,10 @@ pub(crate) fn parse_align(al: &str) -> Result { /// Most entries `aligns` takes: one per paragraph, and no text has this many. const MAX_ALIGNS: usize = 4096; -/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`. +/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`. pub(crate) fn opt_aligns(a: &Args) -> Result>>> { let Some(v) = a.get("aligns") else { return Ok(None) }; - let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?; + let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?; if items.len() > MAX_ALIGNS { return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries"))); } @@ -94,8 +94,9 @@ pub(crate) fn opt_aligns(a: &Args) -> Result>>> { .iter() .map(|v| match v { Value::Null => Ok(None), + Value::String(s) if s == "inherit" => Ok(None), Value::String(s) => parse_align(s).map(Some), - _ => Err(bad("each entry of aligns is left, center, right, justify or null")), + _ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")), }) .collect::>>() .map(Some) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 427bdc72f6..233bb101cd 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -637,7 +637,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }), &["doc", "page", "text"], @@ -660,7 +660,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, @@ -1429,7 +1429,7 @@ pub fn tools() -> Vec { "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, - "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." }, "underline": { "type": "boolean" }, "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, "dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index aa778273da..8cff9fc4aa 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -664,6 +664,44 @@ fn tool_table_is_well_formed() { } } +fn assert_homogeneous_enums(schema: &Value, path: &str) { + fn json_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } + } + + if let Some(values) = schema.get("enum").and_then(Value::as_array) { + let first = values.first().map(json_kind); + assert!(values.iter().map(json_kind).all(|kind| Some(kind) == first), "mixed enum types at {path}: {values:?}"); + } + match schema { + Value::Object(object) => { + for (key, child) in object { + assert_homogeneous_enums(child, &format!("{path}.{key}")); + } + } + Value::Array(array) => { + for (index, child) in array.iter().enumerate() { + assert_homogeneous_enums(child, &format!("{path}[{index}]")); + } + } + _ => {} + } +} + +#[test] +fn tool_schema_enums_have_one_json_type() { + for tool in tools() { + assert_homogeneous_enums(&tool.input_schema, tool.name); + } +} + #[test] fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { let dir = workdir("font-ranges"); @@ -9878,7 +9916,11 @@ fn added_text_lines_take_their_own_alignments() { let dir = workdir("paragraph-aligns"); let mut a = auto(&dir); let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap(); - ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]})); + ok( + &mut a, + "page_add_text", + json!({"doc":id,"page":1,"text":"Left line\nRight line\nInherited line","at":[40,50],"width":500,"size":12,"aligns":[null,"right","inherit"]}), + ); let rect = |a: &mut Automation, text: &str| -> Vec { let lines = ok(a, "text_lines", json!({"doc":id,"page":1})); let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap(); @@ -9886,10 +9928,13 @@ fn added_text_lines_take_their_own_alignments() { }; assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0); assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line")); + assert!((rect(&mut a, "Inherited line")[0] - 40.0).abs() < 1.0); // The whole box centred: the right-aligned line goes with it. ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"})); let right = rect(&mut a, "Right line"); assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}"); + let inherited = rect(&mut a, "Inherited line"); + assert!(((inherited[0] + inherited[2]) / 2.0 - 290.0).abs() < 1.0, "{inherited:?}"); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); } From b36bdaeac6f53f26ce03187e91e6230c71eaece2 Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:07:29 +0800 Subject: [PATCH 115/562] M2: a shrunk image on a turned or mirrored page snaps to whole pixels (#5530) (#5640) * M2: a shrunk image on a turned or mirrored page snaps to whole pixels (#5530) hayro patch 14 (`snap_minified_image`) shrinks an interpolated image once to its whole-pixel device rectangle and draws that 1:1, instead of shrinking to a rounded-up size and resampling again at a fractional scale and offset. It only accepted an upright transform (a > 0, d > 0, b = c = 0), so every image on a page under /Rotate 90, 180 or 270 -- and any image placed turned or mirrored by its own matrix -- still took the double resample. That is #5530: a scanned page that matches PDFium upright drifts and softens when the same page is rotated. The snap now accepts every transform whose axes lie along the device's (the eight quarter-turns and mirrors) and returns the 1:1 transform that draws the shrunk raster from the snapped corner, one source-axis step a whole device pixel, in whichever direction that axis runs. Skewed and freely rotated images keep the old path. On a synthetic page (crop box and scale on whole pixels, image at fractional coordinates), /Rotate 90, 180 and 270 renders turned back differed from the upright render in 72% of pixels, by up to 200 grey levels; now they are identical. Non-interpolated images were already identical and stay so. Co-Authored-By: Claude Opus 5.5 * M2: snap only when the dropped skew is invisible, and only a resize that succeeded Review of #5640: - The snap judged an image axis-aligned by a tolerance on its coefficients (1e-4 of the scale). For a very wide image a skew that small still moves the far edge by more than a pixel (40 000 samples at 4e-5: 1.6 px), and the snap flattened it. It now measures what it drops: how far each off-axis term moves the far edges, at most a hundredth of a device pixel (SNAP_SKEW). Upright and turned alike. - A resize that fails hands back the source raster at source size; the 1:1 snapped transform then drew it at that size. The snapped transform is now used only when the shrink produced the snapped size; otherwise the full transform, as before. - The mirrored case of the edge test drew a uniform image, which looks the same mirrored; it now also mirrors a half-black image and checks which side the black lands on. Co-Authored-By: Claude Opus 5.5 * M2: the turned-page test draws every kind of image the snap reaches The /Rotate oracle test drew one 8-bit grey image. Stencil masks (CCITT scans often are one), 1-bit images and images with a soft mask reach the snap through other paths, so it now draws all five: 8-bit and 1-bit grey, a stencil mask, a soft mask of the image's size and one of another size. On main, every interpolated kind is off by 113-217 levels over 63-73% of the image when turned, and so is a non-interpolated image with a smaller soft mask (110 levels, 16%): hayro patch 15 filters such a mask whatever /Interpolate says, so it is snapped upright and was not when turned. All are identical with the fix; the other non-interpolated kinds were already identical and stay as guards. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/render/src/raster/tests.rs | 71 ++++++++ crates/render/tests/it/main.rs | 1 + crates/render/tests/it/rotated_image_snap.rs | 163 +++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro/src/renderer.rs | 98 ++++++++--- 5 files changed, 312 insertions(+), 23 deletions(-) create mode 100644 crates/render/tests/it/rotated_image_snap.rs diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 31b9bebcc6..1c24408e1b 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -6276,6 +6276,77 @@ fn interpolated_minified_image_edges_snap_to_whole_pixels() { } } +/// #5530: an image placed turned or mirrored (by its own matrix or the page's `/Rotate`) snaps to +/// whole pixels just as an upright one does. Before, only upright images were snapped, so a turned +/// scan was resampled twice and every edge was a blended, soft pixel. This checks the edges, and +/// which way a mirrored image faces; `rotated_image_snap` in the integration tests checks which +/// way a turned page's image faces (`/Rotate` only turns, it never mirrors). +#[test] +fn a_turned_or_mirrored_minified_image_snaps_to_whole_pixels() { + let black = vec![0u8; 40 * 40]; + let image = hex(&black); + // Each matrix maps the image onto the user box 0.6..10.6 × 0.4..10.4, a different way round. + for matrix in [ + "0 10 -10 0 10.6 0.4", // a quarter turn + "-10 0 0 -10 10.6 10.4", // a half turn + "0 -10 10 0 0.6 10.4", // three quarters + "-10 0 0 10 10.6 0.4", // mirrored left to right + ] { + let content = format!("q {matrix} cm /Im0 Do Q"); + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{matrix}: {:?}", page.error); + assert_eq!((page.width, page.height), (11, 11)); + let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; + // Device box 0.6..10.6 × 0.6..10.6 snaps to columns 1..11 and rows 1..11: no soft edge. + for y in 0..11 { + for x in 0..11 { + let inside = (1..11).contains(&x) && (1..11).contains(&y); + assert_eq!(gray(x, y), if inside { 0 } else { 255 }, "{matrix}: pixel ({x}, {y})"); + } + } + } + + // A uniform image looks the same mirrored, so mirror one whose left half (source columns + // 0..20) is black: drawn left to right reversed, the black half is on the right. + let halves: Vec = (0..40).flat_map(|_| (0..40).map(|x| if x < 20 { 0 } else { 255 })).collect(); + let halves = hex(&halves); + let content = "q -10 0 0 10 10.6 0.4 cm /Im0 Do Q"; + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{halves}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + halves.len() + 1 + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; + // The snapped box is columns 1..11; columns 1..3 and 8..11 are well clear of the change at 6. + for y in 2..10 { + for x in 1..4 { + assert_eq!(gray(x, y), 255, "mirrored: the left of the box shows the image's right (white) half, pixel ({x}, {y})"); + } + for x in 8..11 { + assert_eq!(gray(x, y), 0, "mirrored: the right of the box shows the image's left (black) half, pixel ({x}, {y})"); + } + } +} + #[test] fn non_interpolated_gray_images_are_not_smoothed_when_minified() { let levels = [0u8, 255, 85, 170, 40, 200, 120, 230]; diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 5323279ca1..7e8307d908 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -8,6 +8,7 @@ mod luminosity_group_space; mod named_form_properties; mod nonseparable_gray; mod open_type_cff_simple_font; +mod rotated_image_snap; mod search; mod separation_all; mod stray_q_in_a_form; diff --git a/crates/render/tests/it/rotated_image_snap.rs b/crates/render/tests/it/rotated_image_snap.rs new file mode 100644 index 0000000000..fcb5181d00 --- /dev/null +++ b/crates/render/tests/it/rotated_image_snap.rs @@ -0,0 +1,163 @@ +//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is +//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a +//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned +//! back, must match the upright one. The image itself sits at fractional coordinates: an upright +//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was +//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale +//! and offset (most of its pixels off, by up to 217 levels). +//! +//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil +//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another +//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so +//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are +//! not snapped upright or turned, and were already the same both ways; they stay in the test as a +//! guard. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage}; +use std::sync::Arc; + +/// A PDF of the given objects (object 1 the catalog), with a cross-reference table. +fn pdf(objects: &[Vec]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + bytes.extend_from_slice(data); + bytes.extend_from_slice(b"\nendstream"); + bytes +} + +/// Image size in samples. +const W: usize = 400; +const H: usize = 500; + +/// A busy grey pattern with edges both ways, as a scan has. +fn grey_samples() -> Vec { + (0..H).flat_map(|y| (0..W).map(move |x| ((x * 7 + y * 13) % 256) as u8 ^ if (x / 3 + y / 5) % 2 == 0 { 0 } else { 0x80 })).collect() +} + +/// One bit a sample, rows padded to whole bytes: a text-like pattern of thin strokes. +fn bit_samples() -> Vec { + let row = W.div_ceil(8); + let mut out = vec![0u8; row * H]; + for y in 0..H { + for x in 0..W { + if (x / 2 + y / 3) % 3 == 0 || (x * 5 + y * 3) % 11 < 3 { + out[y * row + x / 8] |= 0x80 >> (x % 8); + } + } + } + out +} + +fn alpha_samples(w: usize, h: usize) -> Vec { + (0..h).flat_map(|y| (0..w).map(move |x| ((x * 3 + y * 5) % 256) as u8)).collect() +} + +/// The kinds of image drawn. +const KINDS: [&str; 5] = ["8-bit grey", "1-bit grey", "stencil mask", "soft mask", "smaller soft mask"]; + +/// The image of `kind` as objects 5 (and 6, its soft mask). +fn image(kind: &str, interpolate: bool) -> Vec> { + let image = |depth: u8, extra: &str, data: &[u8]| { + let dict = format!( + "/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent {depth} /Interpolate {interpolate}{extra}" + ); + stream(&dict, data) + }; + let mask = |w: usize, h: usize| { + let dict = + format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate}"); + stream(&dict, &alpha_samples(w, h)) + }; + match kind { + "8-bit grey" => vec![image(8, "", &grey_samples())], + "1-bit grey" => vec![image(1, "", &bit_samples())], + "stencil mask" => vec![stream( + &format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ImageMask true /BitsPerComponent 1 /Interpolate {interpolate}"), + &bit_samples(), + )], + "soft mask" => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(W, H)], + _ => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(300, 350)], + } +} + +/// A page whose crop box sits inside a larger media box, as in the report, with the image drawn +/// at fractional coordinates inside it, in a colour (a stencil mask paints it). +fn page(rotate: u32, kind: &str, interpolate: bool) -> Vec { + let mut objects = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 794 1224] /CropBox [114 510 680 1210] /Rotate {rotate} /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>" + ) + .into_bytes(), + stream("", b"0.1 0.2 0.6 rg q 500.3 0 0 600.7 150.15 560.45 cm /Im Do Q"), + ]; + objects.extend(image(kind, interpolate)); + pdf(&objects) +} + +/// At half a pixel per point every crop-box edge, upright or turned, is a whole device pixel, +/// and the 400 × 500 image is shrunk to about 250 × 300 pixels. +fn render(rotate: u32, kind: &str, interpolate: bool) -> RenderedPage { + let mut renderer = PageRenderer::new(Arc::new(page(rotate, kind, interpolate)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 0.5, ..Default::default() }); + assert!(out.error.is_none(), "{kind}: {:?}", out.error); + out +} + +fn rgb(out: &RenderedPage, x: u32, y: u32) -> [u8; 3] { + let i = ((y * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] +} + +/// The largest channel difference between the turned render and the upright one turned the same +/// way. +fn turned_difference(rotate: u32, kind: &str, interpolate: bool) -> u8 { + let upright = render(0, kind, interpolate); + let turned = render(rotate, kind, interpolate); + let (w, h) = (upright.width, upright.height); + // `/Rotate` turns the page clockwise. + let expected = |x: u32, y: u32| match rotate { + 90 => rgb(&upright, y, h - 1 - x), + 180 => rgb(&upright, w - 1 - x, h - 1 - y), + _ => rgb(&upright, w - 1 - y, x), + }; + let (tw, th) = if rotate == 180 { (w, h) } else { (h, w) }; + assert_eq!((turned.width, turned.height), (tw, th), "{kind}, /Rotate {rotate}"); + let mut worst = 0; + for y in 0..th { + for x in 0..tw { + let (got, want) = (rgb(&turned, x, y), expected(x, y)); + worst = (0..3).map(|c| got[c].abs_diff(want[c])).fold(worst, u8::max); + } + } + worst +} + +#[test] +fn a_shrunk_image_on_a_turned_page_is_the_upright_render_turned() { + for kind in KINDS { + for interpolate in [true, false] { + for rotate in [90, 180, 270] { + let worst = turned_difference(rotate, kind, interpolate); + assert!(worst <= 1, "{kind}, /Rotate {rotate}, /Interpolate {interpolate}: off by up to {worst} levels"); + } + } + } +} diff --git a/vendor/README.md b/vendor/README.md index b2944d2b97..3173659698 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -40,7 +40,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (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 | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (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) | -| 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, 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, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at 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); (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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | +| 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | 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; (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 | (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`; (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | 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; (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 | (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`; (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) | diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index ac103fa197..0b9cfe779e 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -401,30 +401,55 @@ fn may_paint_rect(rect: Rect, transform: Affine, width: u16, height: u16) -> boo paints_canvas(device, width, height) } -/// PdfCraft patch: the whole-pixel device rectangle of a shrunk, axis-aligned image, as -/// `(width, height, x0, y0)`. `transform` maps source pixels to device pixels and `planned` is the -/// size [`image_resampling_size`] chose; `None` (draw as before) for rotated, skewed or flipped -/// images, non-finite geometry, or a snapped size that strays more than a pixel from `planned` -/// (so the memory budget that size passed still holds). +/// PdfCraft patch: how far, in device pixels, the terms [`snap_minified_image`] drops may move an +/// image's far edges: a hundredth of a pixel, which no edge shows. Rounding noise in a turned or +/// scaled matrix stays far below it. +const SNAP_SKEW: f64 = 0.01; + +/// PdfCraft patch: the whole-pixel device rectangle of a shrunk image whose axes lie along the +/// device's, as `(width, height, transform)`: the size to shrink it to (along its own axes) and +/// the transform that draws that raster 1:1, one source-axis step a whole device pixel. Upright, +/// mirrored and quarter-turned images all qualify, so a page under `/Rotate` (or an image placed +/// turned) is drawn as sharply as upright (#5530). `transform` maps source pixels to device +/// pixels and `planned` is the size [`image_resampling_size`] chose; `None` (draw as before) for +/// skewed or freely rotated images, non-finite geometry, or a snapped size that strays more than +/// a pixel from `planned` (so the memory budget that size passed still holds). An image counts +/// as axis-aligned when the terms the snap drops move its far edges by at most [`SNAP_SKEW`]: a +/// tolerance on the coefficients alone let a slight skew of a very wide image through, though it +/// moved the far edge by more than a pixel. fn snap_minified_image( transform: &Affine, source: (u32, u32), planned: (u32, u32), -) -> Option<(u32, u32, f64, f64)> { +) -> Option<(u32, u32, Affine)> { let [a, b, c, d, e, f] = transform.as_coeffs(); - let tiny = 1e-4 * a.abs().max(d.abs()); - if !(a > 0.0 && d > 0.0 && b.abs() <= tiny && c.abs() <= tiny) { - return None; - } + // The device span of a source edge from `origin` across `side` samples of `scale`, rounded. let edge = |origin: f64, scale: f64, side: u32| { - let (lo, hi) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round()); + let (lo, hi) = (p.min(q), p.max(q)); (hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi)) }; - let (x0, x1) = edge(e, a, source.0)?; - let (y0, y1) = edge(f, d, source.1)?; - // Bounded by the 1e6 check above, so the casts cannot saturate. - let (w, h) = ((x1 - x0) as u32, (y1 - y0) as u32); - (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0)) + // Along an axis drawn backwards (negative scale), the raster starts at the span's far end. + let start = |scale: f64, (lo, hi): (f64, f64)| if scale > 0.0 { lo } else { hi }; + let step = |scale: f64| if scale > 0.0 { 1.0 } else { -1.0 }; + // How far a dropped term moves the image's far edge: `scale` across `side` source samples. + let negligible = |scale: f64, side: u32| (scale * f64::from(side)).abs() <= SNAP_SKEW; + let (width, height) = source; + // Source x along device x, source y along device y (upright or mirrored), or a quarter turn: + // source x along device y, source y along device x. + let (w, h, draw) = if negligible(b, width) && negligible(c, height) { + let (x, y) = (edge(e, a, source.0)?, edge(f, d, source.1)?); + // Bounded by the 1e6 check above, so the casts cannot saturate. + let (w, h) = ((x.1 - x.0) as u32, (y.1 - y.0) as u32); + (w, h, Affine::new([step(a), 0.0, 0.0, step(d), start(a, x), start(d, y)])) + } else if negligible(a, width) && negligible(d, height) { + let (x, y) = (edge(e, c, source.1)?, edge(f, b, source.0)?); + let (w, h) = ((y.1 - y.0) as u32, (x.1 - x.0) as u32); + (w, h, Affine::new([0.0, step(b), step(c), 0.0, start(c, x), start(b, y)])) + } else { + return None; + }; + (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, draw)) } /// PdfCraft patch (19): what `Renderer::set_paint` found out: the clip path to apply while @@ -960,10 +985,11 @@ impl Renderer { if requested_resize && self.in_type3_glyph && interpolate { quality = ImageQuality::High; } - // PdfCraft patch: an axis-aligned, interpolated image that is shrunk lands on whole device - // pixels. It is shrunk once to exactly its snapped device rectangle and drawn 1:1 at the - // snapped corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a - // second time at a fractional scale and offset, which blurs scans and photos. + // PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's + // (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to + // exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the + // shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale + // and offset, which blurs scans and photos. let snapped = if interpolate && requested_resize && !self.in_type3_glyph @@ -1122,9 +1148,12 @@ impl Renderer { img_height as u16, may_have_transparency, ); + // The snapped transform draws the raster 1:1, so it holds only if the shrink produced the + // snapped size. A failed resize hands back the source raster: drawn 1:1 it would come out + // at its source size, so it keeps the full transform instead. let draw_transform = match snapped { - Some((_, _, x0, y0)) => Affine::translate((x0, y0)), - None => cur_transform * additional_transform, + Some((w, h, draw)) if (img_width, img_height) == (w, h) => draw, + _ => cur_transform * additional_transform, }; self.draw_pixmap(Arc::new(pixmap), quality, draw_transform); } @@ -2581,3 +2610,28 @@ pub fn tiling_cell_pixels(scale: f32, step: f32, max_pixels: f32) -> (u16, f64) // (`exact` is), so the division is too. (pixels as u16, pixels / step) } + +#[cfg(test)] +mod snap_tests { + use super::{snap_minified_image, Affine}; + + /// The skew a snap drops is measured as the distance it moves the image's far edge, not as a + /// coefficient against the scale: a 40 000 sample wide image skewed by 4e-5 per sample moves + /// its far edge 1.6 device pixels, and must keep its full transform (#5640 review). + #[test] + fn a_skew_that_moves_a_far_edge_is_not_snapped() { + let source = (40_000, 400); + let planned = (20_000, 200); + // Upright, shrunk to half, sheared so the far end sits 1.6 pixels lower: not snapped. + let skewed = Affine::new([0.5, 4e-5, 0.0, 0.5, 0.3, 0.3]); + assert!(snap_minified_image(&skewed, source, planned).is_none()); + // A quarter turn with the same stray term along device x: not snapped either. + let turned = Affine::new([4e-5, 0.5, -0.5, 0.0, 200.3, 0.3]); + assert!(snap_minified_image(&turned, source, (20_000, 200)).is_none()); + // Rounding noise far below a hundredth of a pixel at the far edge still snaps. + let noisy = Affine::new([0.5, 1e-9, 0.0, 0.5, 0.3, 0.3]); + assert!(snap_minified_image(&noisy, source, planned).is_some()); + let turned_noisy = Affine::new([1e-9, 0.5, -0.5, 0.0, 200.3, 0.3]); + assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some()); + } +} From 53c79ca9b0160cea485230cf3fa840bac80d1e8c Mon Sep 17 00:00:00 2001 From: archamon Date: Sun, 1 Nov 2026 18:39:39 +0000 Subject: [PATCH 116/562] feat(ui,annot): customizable hotbar strip, freehand highlight, and moveable navigation rail - Add Freehand-Highlight tool with 100pt line width support, Lucide highlighter icon, and PDF Multiply blending. - Add customizable floating and docked hotbar with command picker, size slider, full-side strip chrome integration, and settings gear icon anchored at the bar end. - Add left/right docking options for the navigation rail with toggle button and preferences setting. --- ROADMAP.md | 1 + crates/annot/src/appearance.rs | 6 +- crates/annot/src/create.rs | 8 +- crates/annot/src/modify.rs | 12 +- crates/annot/src/tests.rs | 26 ++ crates/annot/src/types.rs | 8 +- crates/automation/src/comments.rs | 5 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 21 ++ crates/engine/src/commands.rs | 3 + crates/engine/src/comments.rs | 1 + crates/engine/src/edit/labels.rs | 1 + crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/app/control.rs | 19 ++ crates/ui-egui/src/app/prefs.rs | 5 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/canvas/bars.rs | 16 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/commands.rs | 2 + crates/ui-egui/src/control.rs | 5 + crates/ui-egui/src/control/host.rs | 2 + crates/ui-egui/src/dialogs/comment_props.rs | 2 +- crates/ui-egui/src/editing.rs | 7 +- crates/ui-egui/src/hotbar.rs | 355 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/side_panels.rs | 52 ++- crates/ui-egui/src/tools/comments.rs | 31 +- crates/ui-egui/src/tools/comments/input.rs | 6 +- crates/ui-egui/src/tools/comments/menu.rs | 2 +- crates/ui-egui/src/tools/comments/paint.rs | 7 +- crates/ui-egui/src/tools/comments/state.rs | 5 +- crates/ui-egui/src/tools/comments/tests.rs | 17 + crates/ui-egui/src/workflows/js_ui.rs | 14 + crates/ui-egui/tests/it/comments.rs | 139 ++++++++ crates/ui-egui/tests/it/ui.rs | 24 ++ docs/roadmap.md | 2 +- docs/ui-parity.md | 2 +- parity/acrobat-features.toml | 7 +- 38 files changed, 792 insertions(+), 36 deletions(-) create mode 100644 crates/ui-egui/src/hotbar.rs diff --git a/ROADMAP.md b/ROADMAP.md index 74d9241e1c..6e7140b909 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -221,6 +221,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. +- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged. - 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged. - 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged. diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index abc746c0dd..909250ef26 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -392,9 +392,13 @@ struct AppearanceDrawing { } impl AppearanceDrawing { + /// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode. fn new(context: &AppearanceContext<'_>) -> Self { let mut drawing = Self::default(); - if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + if let Some(gs) = ext_gstate( + context.opacity, + context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")), + ) { drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); drawing.content.push_str("/GS0 gs\n"); } diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index a6db5b940b..d897133bce 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -87,7 +87,7 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } @@ -164,6 +164,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::Oval { .. } => "Oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", Shape::Line { .. } => "Line", + Shape::FreehandHighlight { .. } => "Freehand Highlight", Shape::Ink { .. } => "Pencil", Shape::TextBox { .. } => "Text Box", Shape::Typewriter { .. } => "Typewriter", @@ -292,10 +293,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } } } - Shape::Ink { strokes } | Shape::Signature { strokes } => { + Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); d.set(b"InkList".to_vec(), Object::Array(list)); + if matches!(&new.shape, Shape::FreehandHighlight { .. }) { + d.set(b"IT".to_vec(), Object::name("FreehandHighlight")); + } border(&mut d); } Shape::Polygon { vertices, cloud } => { diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 2e3ae0b7d0..fdddbc8718 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -576,10 +576,20 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha } } if let Some(w) = width { + let old_w = border_width_of(d); + let w = w.max(0.0); let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + bs.set(b"W".to_vec(), Object::Real(w)); d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); + if d.name(b"Subtype") == Some(b"Ink") && w > old_w { + let vals: Vec = + d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + if let Some(rect) = four(&vals) { + let expanded = grow(normalize(rect), (w - old_w) / 2.0); + d.set(b"Rect".to_vec(), num_array(&expanded)); + } + } } if let Some(ends) = endings { if ends.len() == 1 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index a3dc14e262..985e98fb01 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2194,3 +2194,29 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); assert!(!long.contains(&"w".repeat(257))); } + +#[test] +fn freehand_highlight_is_compatible_ink_with_marker_blending() { + let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; + let mut annotation = new(0, shape.clone()); + annotation.style = Style::default_for(&shape); + let mut doc = fixture(); + let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap(); + let doc = reopen(&doc); + let ink = &list(&doc, 0)[index]; + + assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..])); + assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..])); + assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35)); + let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap(); + let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap(); + assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..])); + + let mut doc = doc; + let rect_before: Vec = list(&doc, 0)[index].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap(); + let rect_after: Vec = list(&doc, 0)[index].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4); + assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4); +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index bd3409b5ae..522995b9f9 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -355,6 +355,10 @@ pub enum Shape { start: LineEnding, end: LineEnding, }, + /// Translucent freehand highlighting, stored as ink with a distinct intent. + FreehandHighlight { + strokes: Vec>, + }, /// Freehand strokes (Draw tool). Ink { strokes: Vec>, @@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { } impl Shape { + /// Return the PDF `/Subtype` name for this annotation shape. pub fn subtype(&self) -> &'static str { match self { Shape::Note { .. } => "Text", @@ -481,7 +486,7 @@ impl Shape { Shape::Rectangle { .. } => "Square", Shape::Oval { .. } => "Circle", Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink", Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", Shape::Redact { .. } => "Redact", @@ -610,6 +615,7 @@ impl Style { } Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None }, Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index f87fd0b58e..5dbd5b8542 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -183,6 +183,7 @@ impl Automation { "page": c.page + 1, "index": c.index + 1, "type": c.subtype, + "intent": c.intent, "author": c.author, "contents": c.contents, "modified": c.modified, @@ -328,7 +329,7 @@ impl Automation { let [start, end] = ending_pair(kind, endings.as_deref())?; Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } - "ink" => { + "ink" | "freehand_highlight" => { let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into()); let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?; let strokes = strokes @@ -346,7 +347,7 @@ impl Automation { .collect::>>() }) .collect::>>()?; - Shape::Ink { strokes } + if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } } } "polygon" | "cloud" | "polyline" => { let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points")); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 233bb101cd..e39673c201 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -754,7 +754,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, - "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, + "type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] }, "path": { "type": "string", "description": "attachment: the file to attach." }, "points": { "type": "array", "items": point(), "minItems": 2 }, "knee": point(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 8cff9fc4aa..0d73dbb900 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2149,6 +2149,27 @@ fn comments_through_tools() { save_and_reopen_comments(&dir, &mut a, doc); } +#[test] +fn freehand_highlight_through_tools() { + let dir = workdir("freehand-highlight"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100, + "strokes": [[[20, 100], [120, 100]]] }), + ); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1); + assert_eq!(list["comments"][0]["type"], "Ink"); + assert_eq!(list["comments"][0]["intent"], "FreehandHighlight"); + assert_eq!(list["comments"][0]["color"], "#FFF000"); + let undone = ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(undone["undone"], "Add freehand highlight"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0); +} + fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6bfe9fa826..0647726452 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -163,6 +163,8 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. pub const COMMANDS: &[CommandSpec] = &[ + c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"), + c("view.hand_tool", "Hand", None, None, Document, "hand"), c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. @@ -259,6 +261,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.underline", "Underline text", None, None, Annotate, "underline"), c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"), c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"), + c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"), c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"), c("comment.line", "Draw a line", None, None, Annotate, "minus"), c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs index eee799ed2f..c8262a7241 100644 --- a/crates/engine/src/comments.rs +++ b/crates/engine/src/comments.rs @@ -82,6 +82,7 @@ pub fn comment_type_name(subtype: &str) -> &str { /// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { match a.intent.as_deref() { + Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight", Some("FreeTextCallout") => "Callout", Some("PolygonCloud") => "Cloud", Some("FreeTextTypeWriter") => "Typewriter", diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index afc6056e85..5343804ef3 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -327,6 +327,7 @@ fn add_annotation(s: &Shape) -> &'static str { Shape::Oval { .. } => "Add oval", Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", Shape::Line { .. } => "Add line", + Shape::FreehandHighlight { .. } => "Add freehand highlight", Shape::Ink { .. } => "Add drawing", Shape::TextBox { .. } => "Add text box", Shape::Typewriter { .. } => "Add text", diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 4de70f3d9e..43d0066e63 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -88,6 +88,7 @@ impl PdfCraftApp { space_hand: None, full_screen_tool: None, comment_prefs: Default::default(), + hotbar: Default::default(), theme: ThemeKind::Light, // A fresh install follows the desktop's appearance (#1248); saved settings override. theme_preference: ThemePreference::System, diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 26e20f8df5..e4078efd13 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -16,6 +16,18 @@ impl PdfCraftApp { pub fn set_option(&mut self, key: &str, value: &str) -> Result<(), String> { let view = self.active.and_then(|i| self.views.get_mut(i)); match (key, view) { + ("hotbar-size", _) => { + let size: f32 = value.parse().map_err(|_| "hotbar-size must be 24–64 pixels")?; + if !size.is_finite() || !(24.0..=64.0).contains(&size) { + return Err("hotbar-size must be 24–64 pixels".into()); + } + self.hotbar.button_size = size; + } + ("hotbar-full-side", _) => { + self.hotbar.full_side = value.parse().map_err(|_| "hotbar-full-side must be true or false")?; + } + ("hotbar-tools", _) => self.hotbar.set_tools(value)?, + ("hotbar-side", _) => self.hotbar.place(value)?, ("language", _) => { let language = i18n::normalize_pref(value).ok_or_else(|| { let codes: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect(); @@ -217,6 +229,13 @@ impl PdfCraftApp { _ => return Err("tools-side must be left or right".into()), } } + ("rail-side", _) => { + self.side_panels.rail_side = match value { + "left" => side_panels::ToolsSide::Left, + "right" => side_panels::ToolsSide::Right, + _ => return Err("rail-side must be left or right".into()), + } + } ("organize", Some(v)) => { if value == "off" { v.organize = false; diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index c2a7de31dc..7bd8943774 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -146,6 +146,7 @@ impl PdfCraftApp { // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); + v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); @@ -157,6 +158,10 @@ impl PdfCraftApp { /// Restore state written by `persist`. Unknown or malformed data is ignored. pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; + if let Ok(mut bar) = serde_json::from_value::(v["hotbar"].clone()) { + bar.normalize(); + self.hotbar = bar; + } let panels = side_panels::restore(&v); self.side_panels = panels.panels; if let Some(open) = panels.tools_open { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a0976644b..a220644421 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4056,6 +4056,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // the quick tools come back with the window. if app.mode != crate::Mode::Read && !app.full_screen { quick_bar(app, avail, ui); + crate::hotbar::show(app, avail, ui.ctx()); } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 2472366672..4ad2bf05db 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -279,7 +279,15 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te /// The floating quick-action bar at the left edge of the document area. pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let pos = area.left_top() + vec2(14.0, 14.0); + let mut offset = vec2(0.0, 0.0); + if app.hotbar.full_side { + match app.hotbar.dock.as_deref() { + Some("left") => offset.x += app.hotbar.button_size + 14.0, + Some("top") => offset.y += app.hotbar.button_size + 14.0, + _ => {} + } + } + let pos = area.left_top() + vec2(14.0, 14.0) + offset; egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) @@ -302,6 +310,12 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let current = app.comment_prefs.group_tool[g]; let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label())); + if comments::GROUPS[g].len() == 1 { + if resp.clicked() { + app.execute(current.command()); + } + continue; + } // A small corner triangle marks the flyout. let r = resp.rect; let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa6233c133..59525aae7b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,6 +1,6 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. -use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; +use craft_ui::panes::{Rail, RailItem, RailRequest}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; @@ -572,13 +572,18 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let view = &mut app.views[index]; let mut page_display = None; let rail = Rail::new(egui::Id::unique("rail")) - .edge(Edge::End) + .edge(app.side_panels.rail_edge()) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; + let right = app.side_panels.rail_on_right(); + let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; + if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { + app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; + } let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { view.zoom_step(false); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 08d2040e23..f072b7febe 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -259,6 +259,8 @@ impl PdfCraftApp { self.notify(error); } } + "view.select_tool" => self.quick_tool = crate::QuickTool::Select, + "view.hand_tool" => self.quick_tool = crate::QuickTool::Hand, "view.side_panel" => self.toggle_side_panel(), "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c3c730ed68..44e5481a11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -39,6 +39,11 @@ //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. //! `comment-tool-pinned=true|false` remembers the annotation quick bar's pin across restarts; +//! `hotbar-tools` accepts comma-separated command IDs (empty clears the bar), and +//! `hotbar-side=float|left|right|top|bottom` floats or docks the customizable hotbar; +//! `hotbar-full-side=true|false` extends a docked bar along the entire side. +//! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` +//! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 64b10b69fb..b803f2738c 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -133,9 +133,11 @@ impl Host for crate::PdfCraftApp { "home": self.active.is_none(), "mode": format!("{:?}", self.mode), "default_mode": self.default_mode, + "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, + "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 4702e63f9e..35ce510ae7 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -240,7 +240,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 }; if let Some(w) = e.width.as_mut() { ui.label(tl!("Thickness")); - ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt")); + ui.add(egui::Slider::new(w, min_width..=crate::comments::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")); ui.end_row(); } if let Some(ends) = e.endings.as_mut() { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index d4520a15b4..b810e2abe5 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -52,8 +52,11 @@ fn select_after_edit( // 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) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. } | pdfcraft_engine::Shape::FreehandHighlight { .. }) + && matches!( + quick_tool, + crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight) + ) => { view.comments.selected = None; } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs new file mode 100644 index 0000000000..88c2fd4c6a --- /dev/null +++ b/crates/ui-egui/src/hotbar.rs @@ -0,0 +1,355 @@ +//! A movable, persistent set of shortcuts to the shared command registry. +use egui::{Rect, Sense, vec2}; +use pdfcraft_engine::commands::{COMMANDS, command}; +use serde::{Deserialize, Serialize}; + +use crate::{PdfCraftApp, icons, theme::Tokens}; + +/// Persistent state and UI state for the customizable document hotbar. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub(crate) struct Hotbar { + pub tools: Vec, + /// Position as a fraction of the available travel, so resizing keeps the bar visible. + pub position: [f32; 2], + pub vertical: bool, + pub button_size: f32, + pub dock: Option, + pub full_side: bool, + #[serde(skip)] + search: String, + #[serde(skip)] + picker_open: bool, +} + +impl Default for Hotbar { + /// Create a default empty horizontal floating hotbar. + fn default() -> Self { + Self { + tools: Vec::new(), + position: [0.5, 0.0], + vertical: false, + button_size: 32.0, + dock: None, + full_side: false, + search: String::new(), + picker_open: false, + } + } +} + +impl Hotbar { + /// Clamp button size and normalized position coordinates, drop unknown docked sides, + /// and deduplicate/filter tool command identifiers against the command registry. + pub fn normalize(&mut self) { + self.button_size = if self.button_size.is_finite() { self.button_size.clamp(24.0, 64.0) } else { 32.0 }; + if self.dock.as_deref().is_some_and(|side| !matches!(side, "left" | "right" | "top" | "bottom")) { + self.dock = None; + } + let mut seen = std::collections::HashSet::new(); + self.tools.retain(|id| command(id).is_some() && seen.insert(id.clone())); + for coordinate in &mut self.position { + *coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 }; + } + } + + /// Parse and validate a comma-separated list of command identifiers for the hotbar. + pub fn set_tools(&mut self, value: &str) -> Result<(), String> { + let tools: Vec = value.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect(); + if let Some(id) = tools.iter().find(|id| command(id).is_none()) { + return Err(format!("Unknown hotbar command: {id}")); + } + self.tools = tools; + self.normalize(); + Ok(()) + } + + /// Dock the hotbar to `"left"`, `"right"`, `"top"`, or `"bottom"`, or undock with `"float"`. + pub fn place(&mut self, side: &str) -> Result<(), String> { + if side == "float" { + self.dock = None; + return Ok(()); + } + let (position, vertical) = match side { + "left" => ([0.0, 0.5], true), + "right" => ([1.0, 0.5], true), + "top" => ([0.5, 0.0], false), + "bottom" => ([0.5, 1.0], false), + _ => return Err("hotbar-side must be float, left, right, top or bottom".into()), + }; + self.dock = Some(side.to_owned()); + self.position = position; + self.vertical = vertical; + Ok(()) + } +} + +/// Render the hotbar settings gear button and open the tool picker when clicked. +fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { + let settings = ui + .push_id("hotbar-settings", |ui| { + let mut btn = icons::button(ui, "settings", cell, false, tl!("Hotbar settings")); + btn = btn.on_hover_text(tl!("Hotbar settings / add tools")); + btn.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "+")); + btn + }) + .inner; + if settings.clicked() { + *picker_open = true; + } +} + +/// Render the configured tool shortcut buttons and their removal context menus. +fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option) { + for (index, id) in tools.iter().enumerate() { + let Some(spec) = command(id) else { continue }; + let active = crate::comments::CommentTool::from_command(id).is_some_and(|tool| app.quick_tool == crate::QuickTool::Comment(tool)); + let button = ui.add_enabled_ui(app.command_enabled(spec), |ui| icons::button(ui, spec.icon, cell, active, tl!(spec.label))).inner; + if button.clicked() { + *run = Some(spec.id); + } + button.context_menu(|ui| { + if ui.button(tl!("Remove from hotbar")).clicked() { + *remove = Some(index); + ui.close(); + } + }); + } +} + +/// Draw the floating or side-docked hotbar and its tool picker window inside `area`. +pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { + let mut bar = std::mem::take(&mut app.hotbar); + let t = Tokens::get(ctx); + let id = egui::Id::unique("custom-hotbar"); + let full_side = bar.full_side && bar.dock.is_some(); + let bounds = if full_side { area } else { area.shrink(10.0) }; + if let Some(side) = bar.dock.clone() { + let _ = bar.place(&side); + } + let cell = bar.button_size; + let mut size = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or(vec2(90.0, 44.0)); + if full_side { + size = if bar.vertical { vec2(cell + 12.0, bounds.height()) } else { vec2(bounds.width(), cell + 12.0) }; + } + let travel = (bounds.size() - size).max(vec2(0.0, 0.0)); + let pos = bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]); + let mut run = None; + let mut picker_open = bar.picker_open; + let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(ctx, |ui| { + let frame = if full_side { + egui::Frame::NONE.fill(t.chrome).corner_radius(egui::CornerRadius::ZERO).inner_margin(6) + } else { + egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(10).inner_margin(6) + }; + let frame_resp = frame.show(ui, |ui| { + if full_side { + ui.set_min_size((size - vec2(12.0, 12.0)).max(vec2(0.0, 0.0))); + } + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + if bar.vertical { + ui.set_width(cell); + } else { + ui.set_height(cell); + } + let mut remove = None; + if full_side { + if bar.vertical { + ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_height().max(cell); + egui::ScrollArea::vertical().max_width(cell).max_height(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } else { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + settings_button(ui, cell, &mut picker_open); + ui.add_space(4.0); + let remaining = ui.available_width().max(cell); + egui::ScrollArea::horizontal().max_height(cell).max_width(remaining).show(ui, |ui| { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + }); + } + } else { + let layout = + if bar.vertical { egui::Layout::top_down(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, |ui| { + let grip_size = if bar.vertical { vec2(cell, 18.0) } else { vec2(18.0, cell) }; + let (grip_rect, grip) = ui.allocate_exact_size(grip_size, Sense::drag()); + for x in [-3.0, 3.0] { + for y in [-6.0, 0.0, 6.0] { + let offset = if bar.vertical { vec2(y, x) } else { vec2(x, y) }; + ui.painter().circle_filled(grip_rect.center() + offset, 1.5, t.text_faint); + } + } + let drag_label = tl!("Drag hotbar"); + let grip = grip.on_hover_text(drag_label).on_hover_cursor(egui::CursorIcon::Grab); + grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label)); + if grip.dragged() && bar.dock.is_none() { + let delta = grip.drag_delta(); + bar.position[0] = (bar.position[0] + delta.x / travel.x.max(1.0)).clamp(0.0, 1.0); + bar.position[1] = (bar.position[1] + delta.y / travel.y.max(1.0)).clamp(0.0, 1.0); + } + let scroll_max = if bar.vertical { (bounds.height() - cell - 50.0).max(cell) } else { (bounds.width() - cell - 50.0).max(cell) }; + let scroll = if bar.vertical { + egui::ScrollArea::vertical().max_width(cell).max_height(scroll_max) + } else { + egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max) + }; + scroll.auto_shrink([true, true]).show(ui, |ui| { + ui.with_layout(layout, |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + settings_button(ui, cell, &mut picker_open); + }); + } + if let Some(index) = remove { + bar.tools.remove(index); + } + }); + if full_side { + let r = frame_resp.response.rect; + let divider = egui::Stroke::new(1.0, t.divider); + match bar.dock.as_deref() { + Some("left") => { + ui.painter().vline(r.right() - 0.5, r.top()..=r.bottom(), divider); + } + Some("right") => { + ui.painter().vline(r.left() + 0.5, r.top()..=r.bottom(), divider); + } + Some("top") => { + ui.painter().hline(r.left()..=r.right(), r.bottom() - 0.5, divider); + } + Some("bottom") => { + ui.painter().hline(r.left()..=r.right(), r.top() + 0.5, divider); + } + _ => {} + } + } + }); + let picker_id = egui::Id::unique("hotbar-picker"); + // A remembered collapsed state can survive disabling the collapse affordance. + let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(ctx, picker_id.with("collapsing"), true); + expanded.set_open(true); + expanded.store(ctx); + let screen = ctx.content_rect().shrink(12.0); + let picker_x = if pos.x + cell + 380.0 < screen.right() { pos.x + cell + 20.0 } else { pos.x - 360.0 }; + let picker_pos = egui::pos2( + picker_x.clamp(screen.left(), (screen.right() - 350.0).max(screen.left())), + pos.y.clamp(screen.top(), (screen.bottom() - 640.0).max(screen.top())), + ); + egui::Window::new(tl!("Hotbar tools")) + .id(picker_id) + .order(egui::Order::Foreground) + .open(&mut picker_open) + .collapsible(false) + .resizable(false) + .fixed_pos(picker_pos) + .max_height((screen.height() - 24.0).max(180.0)) + .vscroll(true) + .show(ctx, |ui| { + ui.set_min_width(260.0); + ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).text(tl!("Toolbar size")).suffix(" px")); + ui.add_enabled(bar.dock.is_none(), crate::widgets::checkbox(&mut bar.vertical, tl!("Vertical layout"))); + if ui.selectable_label(bar.dock.is_none(), tl!("Float (drag anywhere)")).clicked() { + let _ = bar.place("float"); + } + ui.label(tl!("Stick to a side:")); + ui.horizontal(|ui| { + for (label, side) in [(tl!("Left"), "left"), (tl!("Right"), "right"), (tl!("Top"), "top"), (tl!("Bottom"), "bottom")] { + if ui.selectable_label(bar.dock.as_deref() == Some(side), label).clicked() { + let _ = bar.place(side); + } + } + }); + ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip"))) + .on_hover_text(tl!("Extend the docked toolbar along the entire side. Choose a side above to enable.")); + if ui.button(tl!("Close menu")).clicked() { + ui.close(); + } + ui.separator(); + ui.label(tl!("Add tools to hotbar")); + ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…"))); + let search = bar.search.to_lowercase(); + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for spec in COMMANDS { + if !spec.label.to_lowercase().contains(&search) && !spec.id.contains(&search) { + continue; + } + let mut selected = bar.tools.iter().any(|id| id == spec.id); + if ui.add(crate::widgets::checkbox(&mut selected, tl!(spec.label))).changed() { + if selected { + bar.tools.push(spec.id.to_owned()); + } else { + bar.tools.retain(|id| id != spec.id); + } + } + } + }); + ui.separator(); + }); + ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size())); + bar.picker_open = picker_open; + app.hotbar = bar; + if let Some(id) = run { + app.execute(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hotbar_validation_and_placement() { + let mut bar = Hotbar::default(); + assert!(bar.tools.is_empty()); + bar.set_tools("comment.ink,comment.freehand_highlight,comment.ink").unwrap(); + assert_eq!(bar.tools.len(), 2); + assert!(bar.set_tools("does.not.exist").is_err()); + assert_eq!(bar.tools.len(), 2); + bar.place("right").unwrap(); + assert!(bar.vertical); + assert_eq!(bar.position, [1.0, 0.5]); + bar.place("bottom").unwrap(); + assert!(!bar.vertical); + assert_eq!(bar.position, [0.5, 1.0]); + assert_eq!(bar.dock.as_deref(), Some("bottom")); + bar.place("float").unwrap(); + assert!(bar.dock.is_none()); + } + + #[test] + fn hotbar_preferences_round_trip() { + let mut app = PdfCraftApp::new(); + app.set_option("hotbar-tools", "comment.freehand_highlight,comment.ink").unwrap(); + app.set_option("hotbar-side", "left").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + app.set_option("hotbar-full-side", "true").unwrap(); + assert!(app.set_option("hotbar-size", "NaN").is_err()); + let mut restored = PdfCraftApp::new(); + restored.restore(&app.persist()); + assert_eq!(restored.hotbar.tools, app.hotbar.tools); + assert_eq!(restored.hotbar.position, [0.0, 0.5]); + assert!(restored.hotbar.vertical); + assert_eq!(restored.hotbar.button_size, 48.0); + assert!(restored.hotbar.full_side); + assert_eq!(restored.hotbar.dock.as_deref(), Some("left")); + } + + #[test] + fn hotbar_restore_filters_unknown_tools_and_offscreen_positions() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"hotbar":{"tools":["invalid","comment.ink","comment.ink"],"position":[-10,999]}}"#); + assert_eq!(app.hotbar.tools, ["comment.ink"]); + assert_eq!(app.hotbar.position, [0.0, 1.0]); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ab5d1334e3..fe0138f27f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,7 @@ mod dialogs; pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; mod home; +mod hotbar; mod panels; pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; @@ -294,6 +295,7 @@ pub struct PdfCraftApp { full_screen_tool: Option, /// Comment author, per-tool colours and widths, pin. pub comment_prefs: comments::CommentPrefs, + pub(crate) hotbar: hotbar::Hotbar, /// Resolved colours, including the current OS theme when following the system. pub theme: ThemeKind, pub theme_preference: ThemePreference, diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index 71c3baa648..447aeac9fb 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -65,13 +65,14 @@ pub struct SidePanels { /// The one setting that places the Tools pane; [`SidePanels::tools_edge`] is its only reader /// in the layout. pub tools_side: ToolsSide, + pub rail_side: ToolsSide, pub tools_width: f32, pub side_width: f32, } impl Default for SidePanels { fn default() -> Self { - Self { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } + Self { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } } } @@ -103,6 +104,19 @@ impl SidePanels { } } + /// Whether the navigation rail is docked on the right side of the window. + pub fn rail_on_right(&self) -> bool { + self.rail_side == ToolsSide::Right + } + + /// Return the layout [`Edge`] (`Start` or `End`) where the navigation rail and side panel dock. + pub fn rail_edge(&self) -> Edge { + match self.rail_side { + ToolsSide::Left => Edge::Start, + ToolsSide::Right => Edge::End, + } + } + pub fn width(&self, pane: Pane) -> f32 { match pane { Pane::Tools => self.tools_width, @@ -123,6 +137,7 @@ impl SidePanels { json!({ "version": VERSION, "tools_side": self.tools_side, + "rail_side": self.rail_side, "tools_open": tools_open, "tools_width": self.tools_width, "side_width": self.side_width, @@ -143,6 +158,9 @@ pub fn restore(saved: &Value) -> Restored { if let Some(side) = value.get("tools_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { panels.tools_side = side; } + if let Some(side) = value.get("rail_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + panels.rail_side = side; + } if let Some(width) = value.get("tools_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Tools, w)) { panels.tools_width = width; } @@ -346,10 +364,11 @@ impl PaneContent for Content<'_> { } } +/// Return the docked [`PaneState`] (edge and width) for `pane`. fn state_of(panels: &SidePanels, pane: Pane) -> PaneState { let edge = match pane { Pane::Tools => panels.tools_edge(), - Pane::Side => Edge::End, + Pane::Side => panels.rail_edge(), }; PaneState::docked(edge, panels.width(pane)) } @@ -434,7 +453,7 @@ mod tests { #[test] fn docked_default_keeps_widths() { let restored = restore(&docked(300.0, 400.0)); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 300.0, side_width: 400.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: 300.0, side_width: 400.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -448,6 +467,7 @@ mod tests { ); let restored = restore(&old); assert_eq!(restored.panels.tools_side, ToolsSide::Right); + assert_eq!(restored.panels.rail_side, ToolsSide::Right); assert_eq!(restored.panels.tools_width, 290.0); // The Inspector's own split fixes Tools' width, not its. assert_eq!(restored.panels.side_width, SIDE_WIDTH); @@ -461,7 +481,10 @@ mod tests { json!([]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: 360.0 }); + assert_eq!( + restored.panels, + SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: TOOLS_WIDTH, side_width: 360.0 } + ); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -470,6 +493,7 @@ mod tests { let old = workspace(split(json!({"FixedSecond": 350.0}), tabs(&["canvas"]), tabs(&["inspector", "tools"])), json!([]), json!([])); let restored = restore(&old); assert_eq!(restored.panels.tools_side, ToolsSide::Left); + assert_eq!(restored.panels.rail_side, ToolsSide::Right); assert_eq!((restored.panels.tools_width, restored.panels.side_width), (TOOLS_WIDTH, SIDE_WIDTH)); assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); } @@ -485,7 +509,7 @@ mod tests { ]), ); let restored = restore(&old); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, rail_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); } @@ -504,7 +528,10 @@ mod tests { } // Non-finite and out-of-range widths fall back or clamp; an unknown side keeps Left. let restored = restore(&json!({"side_panels": {"version": 1, "tools_side": "up", "tools_width": 9000.0, "side_width": "x"}})); - assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 520.0, side_width: SIDE_WIDTH }); + assert_eq!( + restored.panels, + SidePanels { tools_side: ToolsSide::Left, rail_side: ToolsSide::Right, tools_width: 520.0, side_width: SIDE_WIDTH } + ); // Deep trees are cut off rather than walked. let mut deep = tabs(&["tools"]); for _ in 0..200 { @@ -515,8 +542,19 @@ mod tests { #[test] fn saved_value_round_trips() { - let panels = SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 410.0 }; + let panels = SidePanels { tools_side: ToolsSide::Right, rail_side: ToolsSide::Left, tools_width: 300.0, side_width: 410.0 }; let restored = restore(&json!({ "side_panels": panels.to_json(false) })); assert_eq!(restored, Restored { panels, tools_open: Some(false), side_closed: false }); } + + #[test] + fn rail_side_placement_and_restore() { + let mut panels = SidePanels::default(); + assert_eq!(panels.rail_side, ToolsSide::Right); + assert_eq!(panels.rail_edge(), Edge::End); + panels.rail_side = ToolsSide::Left; + assert_eq!(panels.rail_edge(), Edge::Start); + let restored = restore(&json!({"side_panels": {"version": 1, "rail_side": "left"}})); + assert_eq!(restored.panels.rail_side, ToolsSide::Left); + } } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index e9956e5f95..cb8bafe642 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -54,6 +54,9 @@ pub(crate) use state::{EditorFocus, PageCx}; /// Acrobat's selection blue for annotation frames and handles (11-visual-spec §5). const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// Maximum stroke thickness offered by commenting controls, in PDF points. +pub const MAX_LINE_WIDTH: f64 = 100.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum CommentTool { Note, @@ -63,6 +66,7 @@ pub enum CommentTool { StrikeOut, Squiggly, Ink, + FreehandHighlight, Line, Arrow, Rectangle, @@ -80,7 +84,15 @@ pub enum CommentTool { /// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸. pub const GROUPS: [&[CommentTool]; 3] = [ &[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout, CommentTool::Attach], - &[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Squiggly, CommentTool::Caret, CommentTool::ReplaceText], + &[ + CommentTool::Highlight, + CommentTool::FreehandHighlight, + CommentTool::Underline, + CommentTool::StrikeOut, + CommentTool::Squiggly, + CommentTool::Caret, + CommentTool::ReplaceText, + ], &[ CommentTool::Ink, CommentTool::Line, @@ -94,7 +106,8 @@ pub const GROUPS: [&[CommentTool]; 3] = [ ], ]; -pub const ALL: [CommentTool; 19] = [ +pub const ALL: [CommentTool; 20] = [ + CommentTool::FreehandHighlight, CommentTool::Eraser, CommentTool::ReplaceText, CommentTool::Attach, @@ -117,6 +130,7 @@ pub const ALL: [CommentTool; 19] = [ ]; impl CommentTool { + /// The command registry identifier (`"comment.*"`) corresponding to this tool. pub fn command(self) -> &'static str { match self { Self::Note => "comment.note", @@ -125,6 +139,7 @@ impl CommentTool { Self::Underline => "comment.underline", Self::StrikeOut => "comment.strikeout", Self::Squiggly => "comment.squiggly", + Self::FreehandHighlight => "comment.freehand_highlight", Self::Ink => "comment.ink", Self::Line => "comment.line", Self::Arrow => "comment.arrow", @@ -141,10 +156,12 @@ impl CommentTool { } } + /// Resolve a [`CommentTool`] from its command registry identifier. pub fn from_command(id: &str) -> Option { ALL.into_iter().find(|t| t.command() == id) } + /// User-facing label for this commenting tool. pub fn label(self) -> &'static str { match self { Self::Note => "Add a sticky note", @@ -153,6 +170,7 @@ impl CommentTool { Self::Underline => "Underline", Self::StrikeOut => "Strikethrough", Self::Squiggly => "Squiggly underline", + Self::FreehandHighlight => "Freehand-Highlight", Self::Ink => "Draw", Self::Line => "Line", Self::Arrow => "Arrow", @@ -169,6 +187,7 @@ impl CommentTool { } } + /// Lucide icon name for this commenting tool. pub fn icon(self) -> &'static str { match self { Self::Note => "message-square-plus", @@ -177,6 +196,7 @@ impl CommentTool { Self::Underline => "underline", Self::StrikeOut => "strikethrough", Self::Squiggly => "spline", + Self::FreehandHighlight => "highlighter", Self::Ink => "pencil", Self::Line => "minus", Self::Arrow => "move-right", @@ -209,7 +229,7 @@ impl CommentTool { /// Tools that draw with a drag gesture. pub fn draws(self) -> bool { - matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) + matches!(self, Self::Ink | Self::FreehandHighlight | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval | Self::Eraser) } /// Tools that place points one click at a time (double-click or Enter finishes). @@ -240,6 +260,7 @@ impl CommentTool { Self::Highlight | Self::Underline | Self::StrikeOut | Self::Squiggly => { Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() } } + Self::FreehandHighlight => Shape::FreehandHighlight { strokes: Vec::new() }, Self::Ink => Shape::Ink { strokes: Vec::new() }, Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None }, Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow }, @@ -318,9 +339,10 @@ impl CommentPrefs { } } + /// Clamp and store the stroke width (`tool.min_width()..=MAX_LINE_WIDTH`) for `tool`. pub fn set_width(&mut self, tool: CommentTool, w: f64) { if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) { - s.width = w.clamp(tool.min_width(), 12.0); + s.width = if w.is_finite() { w.clamp(tool.min_width(), MAX_LINE_WIDTH) } else { s.width }; } } @@ -381,6 +403,7 @@ pub fn tool_for(a: &Annotation) -> Option { ("StrikeOut", Some("StrikeOutTextEdit")) => CommentTool::ReplaceText, ("StrikeOut", _) => CommentTool::StrikeOut, ("Squiggly", _) => CommentTool::Squiggly, + ("Ink", Some("FreehandHighlight")) => CommentTool::FreehandHighlight, ("Ink", _) => CommentTool::Ink, ("Line", _) => CommentTool::Line, ("Square", _) => CommentTool::Rectangle, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 5e74fdf56e..a7ef6c92e1 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -140,7 +140,7 @@ fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a /// comment is moved, resized or drawn. pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) { - let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser)); + let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser)); if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() { return; } @@ -343,7 +343,7 @@ impl Gestures<'_> { { let p = clamp_to(page_rect, p); let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + if matches!(*tool, CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Eraser) { let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); if last.distance(p) >= 1.5 { points.push(u); @@ -369,7 +369,7 @@ impl Gestures<'_> { // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink + if matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight) && resp.clicked() && over_page && let Some(p) = pointer diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 52ea8d707d..b577fbbd29 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -330,7 +330,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m ui.set_min_width(180.0); let mut w = style.width; ui.label(egui::RichText::new(tl!("Line thickness")).font(theme::semibold(12.0))); - if ui.add(egui::Slider::new(&mut w, tool.min_width()..=12.0).step_by(0.5).suffix(" pt")).changed() { + if ui.add(egui::Slider::new(&mut w, tool.min_width()..=super::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt")).changed() { prefs.set_width(tool, w); } }); diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index bfcd67982b..59b5ca773a 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -127,10 +127,10 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin { let style = cx.prefs.style(*tool); let zoom = cx.xf.zoom(); - let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color)); + let stroke = Stroke::new((style.width as f32 * zoom).max(1.0), color32(style.color).gamma_multiply(style.opacity as f32)); let pts: Vec = points.iter().map(|p| cx.to_screen(*p)).collect(); match tool { - CommentTool::Ink => { + CommentTool::Ink | CommentTool::FreehandHighlight => { painter.add(egui::Shape::line(pts, stroke)); } CommentTool::Eraser => { @@ -186,6 +186,7 @@ pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option= 2.0 && rect[3] - rect[1] >= 2.0; match tool { + CommentTool::FreehandHighlight if !points.is_empty() => Some(Shape::FreehandHighlight { strokes: vec![points.to_vec()] }), CommentTool::Ink if !points.is_empty() => Some(Shape::Ink { strokes: vec![points.to_vec()] }), CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, @@ -209,7 +210,7 @@ pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option f64 { - 3.0 * if width.is_finite() { width.clamp(0.5, 12.0) } else { 2.0 } + 3.0 * if width.is_finite() { width.clamp(0.5, super::MAX_LINE_WIDTH) } else { 2.0 } } /// Eraser: rub out drawings along `path` (user space); one undo step for all of them. It rubs diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index ab24352256..6cac912210 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -87,7 +87,10 @@ const SETTLE_MAX: usize = 16; impl CommentView { /// Remember a released drag drawing so it stays on screen until its page is re-rendered. pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) { - if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) { + if !matches!( + tool, + CommentTool::Ink | CommentTool::FreehandHighlight | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval + ) { return; } self.settling.retain(|s| now - s.since < SETTLE_SECS); diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 91e796e43a..6110e8a6fc 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -1,5 +1,22 @@ use super::*; +#[test] +fn freehand_highlight_and_draw_accept_widths_up_to_100() { + assert_eq!(CommentTool::FreehandHighlight.group(), CommentTool::Highlight.group()); + assert_eq!(GROUPS.len(), 3); + let mut prefs = CommentPrefs::default(); + assert_eq!(prefs.style(CommentTool::FreehandHighlight).width, 14.0); + assert_eq!(prefs.style(CommentTool::FreehandHighlight).opacity, 0.35); + for tool in [CommentTool::Ink, CommentTool::FreehandHighlight] { + prefs.set_width(tool, 75.0); + assert_eq!(prefs.style(tool).width, 75.0); + prefs.set_width(tool, 200.0); + assert_eq!(prefs.style(tool).width, 100.0); + prefs.set_width(tool, f64::NAN); + assert_eq!(prefs.style(tool).width, 100.0); + } +} + /// A selected line is drawn where it is going: moved by its body (both ends follow the /// pointer), with an end dragged (that end alone), and as released until the page is drawn. #[test] diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 4c99ee1db8..208ea30770 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -434,6 +434,20 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok app.side_panels.tools_side = crate::side_panels::ToolsSide::Right; } }); + ui.horizontal(|ui| { + ui.label(tl!("Navigation rail side")); + let right = app.side_panels.rail_on_right(); + let left_btn = ui.selectable_label(!right, tl!("Left")); + left_btn.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !right, tl!("Navigation rail on left"))); + if left_btn.clicked() { + app.side_panels.rail_side = crate::side_panels::ToolsSide::Left; + } + let right_btn = ui.selectable_label(right, tl!("Right")); + right_btn.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, right, tl!("Navigation rail on right"))); + if right_btn.clicked() { + app.side_panels.rail_side = crate::side_panels::ToolsSide::Right; + } + }); ui.add_space(8.0); crate::appearance::preferences_section(ui, app, t); ui.add_space(8.0); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 057663a76c..41f5040294 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3092,3 +3092,142 @@ fn a_card_with_an_editor_open_out_of_view_is_drawn_at_its_size() { let after = h.get_by_label("Card 61").rect().top(); assert!(after > before + 20.0, "the cards below make room for card 1's editor: {before} → {after}"); } + +#[test] +fn freehand_highlighter_draws_translucent_yellow_ink() { + let mut h = harness(|app| app.set_option("quick", "freehand_highlight").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (240.0, 100.0)); + + let c = comments(&h); + assert_eq!(c.len(), 1, "{c:?}"); + assert_eq!(c[0].subtype, "Ink"); + assert_eq!(c[0].intent.as_deref(), Some("FreehandHighlight")); + let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c[0].index).expect("ink properties"); + assert_eq!(props.color, Some([1.0, 0.94, 0.0])); + assert!((props.opacity - 0.35).abs() < 0.001); + assert_eq!(props.width, Some(14.0)); + assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); + h.state_mut().comment_prefs.set_width(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight, 100.0); + drag_pt(&mut h, (40.0, 160.0), (240.0, 160.0)); + let c = comments(&h); + assert_eq!(c.len(), 2); + let props = h.state().session.get(h.state().views[0].id).unwrap().comment_props(0, c.iter().map(|a| a.index).max().unwrap()).unwrap(); + assert_eq!(props.width, Some(100.0)); + assert_eq!(h.state().views[0].comments.selected, None); +} + +#[test] +fn hotbar_picker_adds_and_runs_freehand_highlight() { + let mut h = harness(|_| {}); + h.get_by_label("+").click(); + h.run_steps(3); + field(&h, "Search all tools…").click(); + h.run_steps(2); + field(&h, "Search all tools…").type_text("freehand"); + h.run_steps(3); + h.get_by_role_and_label(Role::CheckBox, "Freehand-Highlight").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some(), "picker stays open after adding a tool"); + h.get_by_role_and_label(Role::CheckBox, "Draw freehand").click(); + h.run_steps(3); + h.get_by_label("Close menu").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.freehand_highlight", "comment.ink"])); + h.state_mut().set_option("hotbar-side", "bottom").unwrap(); + h.run_steps(3); + // The new tool is available directly from its custom shortcut. + h.query_all_by_role_and_label(Role::Button, "Freehand-Highlight").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); +} + +#[test] +fn hotbar_drag_moves_and_persists_position() { + let mut h = harness(|_| {}); + let before: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from + egui::vec2(90.0, 90.0)); + let after: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_ne!(before["hotbar"]["position"], after["hotbar"]["position"]); + let mut restored = PdfCraftApp::new(); + restored.restore(&h.state().persist()); + let saved: serde_json::Value = serde_json::from_str(&restored.persist()).unwrap(); + assert_eq!(saved["hotbar"], after["hotbar"]); +} + +#[test] +fn hotbar_vertical_size_and_docking() { + let mut h = harness(|app| { + app.set_option("hotbar-tools", "comment.ink,comment.freehand_highlight").unwrap(); + app.set_option("hotbar-side", "right").unwrap(); + }); + for size in [24, 48, 64] { + h.state_mut().set_option("hotbar-size", &size.to_string()).unwrap(); + h.run_steps(5); + let measured = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + assert!(measured.x <= size as f32 + 24.0, "vertical bar too wide: {measured:?}"); + assert!(measured.x >= size as f32); + } + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from - egui::vec2(90.0, 90.0)); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["position"], serde_json::json!([1.0, 0.5])); + assert_eq!(saved["hotbar"]["dock"], "right"); + h.state_mut().set_option("hotbar-side", "float").unwrap(); + h.run_steps(3); + let from = h.get_by_label("Drag hotbar").rect().center(); + drag(&mut h, from, from - egui::vec2(90.0, 90.0)); + let moved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_ne!(saved["hotbar"]["position"], moved["hotbar"]["position"]); +} + +#[test] +fn hotbar_settings_reopen_even_with_remembered_collapsed_state() { + let mut h = harness(|_| {}); + let mut collapsed = + egui::collapsing_header::CollapsingState::load_with_default_open(&h.ctx, egui::Id::unique("hotbar-picker").with("collapsing"), false); + collapsed.set_open(false); + collapsed.store(&h.ctx); + h.get_by_label("+").click(); + h.run_steps(5); + assert!(h.query_by_label("Close menu").is_some()); + h.get_by_label("+").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some(), "+ always shows settings"); + h.get_by_label("Close menu").click(); + h.run_steps(3); + h.get_by_label("+").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some()); +} + +#[test] +fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { + let mut h = harness(|app| { + app.set_option("hotbar-side", "right").unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + }); + h.get_by_label("+").click(); + h.run_steps(5); + h.get_by_label("Full-side strip").click(); + h.run_steps(3); + let enabled: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(enabled["hotbar"]["full_side"], true, "settings checkbox enables strip"); + h.get_by_label("Close menu").click(); + for side in ["right", "left", "top", "bottom"] { + h.state_mut().set_option("hotbar-side", side).unwrap(); + h.run_steps(5); + let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + let (length, thickness) = if matches!(side, "left" | "right") { (size.y, size.x) } else { (size.x, size.y) }; + assert!(length > 300.0, "strip must fill the edge: {side} {size:?}"); + assert!(thickness <= 64.0, "strip must stay narrow: {size:?}"); + h.get_by_label("+"); + } + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["full_side"], true); + h.state_mut().set_option("hotbar-side", "float").unwrap(); + h.run_steps(5); + let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); + assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}"); +} diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 69be82e7a7..b5565a53bb 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -346,6 +346,30 @@ fn closed_right_panel_stays_closed_for_a_document_asking_for_its_outline() { assert_eq!(h.state().right, None, "the closed right panel reopened after a restart"); } +#[test] +fn navigation_rail_toggles_between_right_and_left_and_persists() { + use pdfcraft_ui_egui::Dialog; + + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); + h.get_by_label("Dock navigation rail on Left").click(); + h.run_steps(3); + assert_eq!(h.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + let saved = h.state().persist(); + let mut h2 = harness(move |app| { + app.restore(&saved); + }); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + h2.state_mut().dialog = Some(Dialog::Preferences); + h2.run_steps(3); + h2.get_by_label("Navigation rail on right").click(); + h2.run_steps(3); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); + h2.get_by_label("Navigation rail on left").click(); + h2.run_steps(3); + assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); +} + /// Read mode hides the tools, not the navigation panes: the rail's Bookmarks button opens the /// bookmarks when Read is the default workspace (#932), and the left tool pane stays hidden. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index c5bc1ab7d0..59f478dea1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -237,7 +237,7 @@ total matches the parity estimate. | 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. | | 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; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
    Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
    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. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Local customization: adjustable, floating or side-docked hotbar with optional full-side strip and persistent tool picker; freehand highlighting is grouped with Highlight. Estimates unchanged. Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | 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 | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86eb0c66e7..f950e36a94 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) |---|---|---|---| | Home view (recent files, recommended tools) | yes | yes | shipped | | All tools pane, tool sets | yes | yes, with milestone badges | shipped | -| Quick-action toolbar, customisable | yes | fixed | partial (`view.customize-quick-tools` planned) | +| Quick-action toolbar, customisable | yes | customizable floating/docked hotbar, adjustable size, persistent multi-select picker | partial (`view.customize-quick-tools`; no shortcut reordering) | | Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 301c941e5c..02bb03f09c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1808,8 +1808,13 @@ evidence = [ "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder", "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow", "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins", + "crates/ui-egui/tests/it/comments.rs::hotbar_picker_adds_and_runs_freehand_highlight", + "crates/ui-egui/tests/it/comments.rs::hotbar_drag_moves_and_persists_position", + "crates/ui-egui/tests/it/comments.rs::hotbar_vertical_size_and_docking", + "crates/ui-egui/tests/it/comments.rs::hotbar_settings_reopen_even_with_remembered_collapsed_state", + "crates/ui-egui/tests/it/comments.rs::hotbar_full_side_strip_spans_each_edge_and_returns_to_float", ] -notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Floating quick-action toolbar customisation remains planned." +notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Customizable document hotbar searches the command registry, adds/removes tools, resizes buttons from 24 to 64 pixels, floats or docks on any side with an optional full-side chrome strip, and persists preferences (UI control: hotbar-tools, hotbar-size, hotbar-side, hotbar-full-side). Missing: hotbar shortcut reordering and full Acrobat customization-dialog parity." [[feature]] id = "view.restriction-notice" From faebcfd2649e42ee892d64d04d19a5ed05d02e42 Mon Sep 17 00:00:00 2001 From: archamon Date: Sun, 1 Nov 2026 20:58:16 +0000 Subject: [PATCH 117/562] fix(annot,ui): stabilize ink width bounds, search translated tool labels, and fix hotbar settings label --- crates/annot/src/create.rs | 18 +++++++----- crates/annot/src/modify.rs | 41 +++++++++++++++++++++------ crates/annot/src/tests.rs | 34 ++++++++++++++++++++-- crates/ui-egui/src/hotbar.rs | 31 +++++++++++++++----- crates/ui-egui/src/workflows/js_ui.rs | 8 ++++-- crates/ui-egui/tests/it/comments.rs | 31 +++++++++++++++----- crates/ui-egui/tests/it/ui.rs | 4 +-- 7 files changed, 131 insertions(+), 36 deletions(-) diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index d897133bce..1904745e95 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -33,6 +33,16 @@ pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] { [r[0] - by, r[1] - by, r[2] + by, r[3] + by] } +/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or +/// more points (matching [`appearance::smooth_segments`]). +pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> { + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + bounds(strokes.iter().flatten().copied().chain(controls)) +} + pub(crate) fn ending_draws(ending: LineEnding) -> bool { ending != LineEnding::None } @@ -91,13 +101,7 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { return Err(bad("drawing (no points)")); } - // Strokes of three or more points are drawn as curves, which stay within their - // points and control points. - let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { - let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); - appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) - }); - grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) + grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } Shape::Polygon { vertices, cloud } => { let b = bounds(vertices.iter().copied()) diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index fdddbc8718..a615b8e013 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,5 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -529,7 +529,33 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne } fn border_width_of(d: &Dict) -> f64 { - d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) + appearance::border_width(d) +} + +/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`. +/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating +/// padding across repeated width increases and decreases. +fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> { + let list = d.get(b"InkList")?.as_array()?; + let mut strokes: Vec> = Vec::with_capacity(list.len()); + for s in list { + let pts: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?; + strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect()); + } + let inner = ink_bounds(&strokes)?; + let vals: Vec = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + let Some(old_rect) = four(&vals).map(normalize) else { + return Some(grow(inner, w / 2.0 + 1.0)); + }; + let old_half = old_w / 2.0; + let new_half = w / 2.0; + let base = [ + (inner[0] - old_rect[0] - old_half).max(1.0), + (inner[1] - old_rect[1] - old_half).max(1.0), + (old_rect[2] - inner[2] - old_half).max(1.0), + (old_rect[3] - inner[3] - old_half).max(1.0), + ]; + Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half]) } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. @@ -582,13 +608,10 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha bs.set(b"W".to_vec(), Object::Real(w)); d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); - if d.name(b"Subtype") == Some(b"Ink") && w > old_w { - let vals: Vec = - d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); - if let Some(rect) = four(&vals) { - let expanded = grow(normalize(rect), (w - old_w) / 2.0); - d.set(b"Rect".to_vec(), num_array(&expanded)); - } + if d.name(b"Subtype") == Some(b"Ink") + && let Some(rect) = ink_rect_for_width(d, old_w, w) + { + d.set(b"Rect".to_vec(), num_array(&rect)); } } if let Some(ends) = endings { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 985e98fb01..cf800fa98a 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2214,9 +2214,39 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..])); let mut doc = doc; - let rect_before: Vec = list(&doc, 0)[index].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + let rect_of = |doc: &Document, idx: usize| -> Vec { + list(doc, 0)[idx].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect() + }; + let rect_before = rect_of(&doc, index); set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap(); - let rect_after: Vec = list(&doc, 0)[index].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect(); + let rect_after = rect_of(&doc, index); assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4); assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4); + + // Repeated increase/decrease cycles must keep bounds stable and never accumulate padding. + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(14.0), endings: None }, &meta("narrower")).unwrap(); + let rect_restored = rect_of(&doc, index); + for (a, b) in rect_restored.iter().zip(rect_before.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_before:?}, got {rect_restored:?}"); + } + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider-again")).unwrap(); + let rect_second_wide = rect_of(&doc, index); + for (a, b) in rect_second_wide.iter().zip(rect_after.iter()) { + assert!((a - b).abs() < 1e-4, "expected {rect_after:?}, got {rect_second_wide:?}"); + } + + // Imported ink whose width is specified only by `/Border [0 0 20]` must use 20 pt as its + // previous width (shrinking by 5 pt on a decrease to 10 pt rather than expanding as if 1 pt). + let (_, r) = lookup::annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(20.0)])); + d.set(b"Rect".to_vec(), create::num_array(&[5.0, 85.0, 135.0, 115.0])); + }) + .unwrap(); + set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(10.0), endings: None }, &meta("border-shrink")).unwrap(); + let rect_from_border = rect_of(&doc, index); + for (a, b) in rect_from_border.iter().zip([10.0, 90.0, 130.0, 110.0]) { + assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); + } } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 88c2fd4c6a..f6a0c14c7f 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -88,10 +88,7 @@ impl Hotbar { fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { let settings = ui .push_id("hotbar-settings", |ui| { - let mut btn = icons::button(ui, "settings", cell, false, tl!("Hotbar settings")); - btn = btn.on_hover_text(tl!("Hotbar settings / add tools")); - btn.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "+")); - btn + icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools")) }) .inner; if settings.clicked() { @@ -99,6 +96,13 @@ fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { } } +/// Whether `spec` matches the hotbar picker search query across the translated label, canonical +/// English label, or command identifier (case-insensitively). +fn matches_search(spec: &pdfcraft_engine::commands::CommandSpec, query: &str) -> bool { + let search = query.to_lowercase(); + tl!(spec.label).to_lowercase().contains(&search) || spec.label.to_lowercase().contains(&search) || spec.id.to_lowercase().contains(&search) +} + /// Render the configured tool shortcut buttons and their removal context menus. fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option) { for (index, id) in tools.iter().enumerate() { @@ -270,18 +274,19 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { } } }); + let strip_hint = tl!("Extend the docked toolbar along the entire side. Choose a side above to enable."); ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip"))) - .on_hover_text(tl!("Extend the docked toolbar along the entire side. Choose a side above to enable.")); + .on_hover_text(strip_hint) + .on_disabled_hover_text(strip_hint); if ui.button(tl!("Close menu")).clicked() { ui.close(); } ui.separator(); ui.label(tl!("Add tools to hotbar")); ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…"))); - let search = bar.search.to_lowercase(); egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for spec in COMMANDS { - if !spec.label.to_lowercase().contains(&search) && !spec.id.contains(&search) { + if !matches_search(spec, &bar.search) { continue; } let mut selected = bar.tools.iter().any(|id| id == spec.id); @@ -352,4 +357,16 @@ mod tests { assert_eq!(app.hotbar.tools, ["comment.ink"]); assert_eq!(app.hotbar.position, [0.0, 1.0]); } + + #[test] + fn hotbar_search_matches_translated_and_canonical_labels() { + let ink = command("comment.ink").unwrap(); + crate::i18n::set_current(crate::i18n::lang_from_tag("fr").unwrap()); + assert_eq!(tl!(ink.label), "Dessiner à main levée"); + assert!(matches_search(ink, "dessiner"), "translated French label must match"); + assert!(matches_search(ink, "freehand"), "canonical English label must still match"); + assert!(matches_search(ink, "comment.ink"), "command ID must still match"); + assert!(!matches_search(ink, "introuvable")); + crate::i18n::set_current(crate::i18n::Lang::EN); + } } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 208ea30770..70c3f775e9 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -438,12 +438,16 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.label(tl!("Navigation rail side")); let right = app.side_panels.rail_on_right(); let left_btn = ui.selectable_label(!right, tl!("Left")); - left_btn.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, !right, tl!("Navigation rail on left"))); + left_btn.widget_info(|| { + egui::WidgetInfo::selected(egui::Role::Button, true, !right, format!("{}: {}", tl!("Navigation rail side"), tl!("Left"))) + }); if left_btn.clicked() { app.side_panels.rail_side = crate::side_panels::ToolsSide::Left; } let right_btn = ui.selectable_label(right, tl!("Right")); - right_btn.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, right, tl!("Navigation rail on right"))); + right_btn.widget_info(|| { + egui::WidgetInfo::selected(egui::Role::Button, true, right, format!("{}: {}", tl!("Navigation rail side"), tl!("Right"))) + }); if right_btn.clicked() { app.side_panels.rail_side = crate::side_panels::ToolsSide::Right; } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 41f5040294..6805732422 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -3119,7 +3119,7 @@ fn freehand_highlighter_draws_translucent_yellow_ink() { #[test] fn hotbar_picker_adds_and_runs_freehand_highlight() { let mut h = harness(|_| {}); - h.get_by_label("+").click(); + h.get_by_label("Hotbar settings").click(); h.run_steps(3); field(&h, "Search all tools…").click(); h.run_steps(2); @@ -3142,6 +3142,23 @@ fn hotbar_picker_adds_and_runs_freehand_highlight() { assert_eq!(h.state().quick_tool, QuickTool::Comment(pdfcraft_ui_egui::comments::CommentTool::FreehandHighlight)); } +#[test] +fn hotbar_picker_searches_translated_tool_labels() { + let mut h = harness(|app| { + app.set_option("language", "fr").unwrap(); + }); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + field(&h, "Search all tools…").click(); + h.run_steps(2); + field(&h, "Search all tools…").type_text("Dessiner"); + h.run_steps(3); + h.get_by_role_and_label(Role::CheckBox, "Dessiner à main levée").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["tools"], serde_json::json!(["comment.ink"])); +} + #[test] fn hotbar_drag_moves_and_persists_position() { let mut h = harness(|_| {}); @@ -3189,15 +3206,15 @@ fn hotbar_settings_reopen_even_with_remembered_collapsed_state() { egui::collapsing_header::CollapsingState::load_with_default_open(&h.ctx, egui::Id::unique("hotbar-picker").with("collapsing"), false); collapsed.set_open(false); collapsed.store(&h.ctx); - h.get_by_label("+").click(); + h.get_by_label("Hotbar settings").click(); h.run_steps(5); assert!(h.query_by_label("Close menu").is_some()); - h.get_by_label("+").click(); + h.get_by_label("Hotbar settings").click(); h.run_steps(3); - assert!(h.query_by_label("Close menu").is_some(), "+ always shows settings"); + assert!(h.query_by_label("Close menu").is_some(), "Hotbar settings always shows settings"); h.get_by_label("Close menu").click(); h.run_steps(3); - h.get_by_label("+").click(); + h.get_by_label("Hotbar settings").click(); h.run_steps(3); assert!(h.query_by_label("Close menu").is_some()); } @@ -3208,7 +3225,7 @@ fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { app.set_option("hotbar-side", "right").unwrap(); app.set_option("hotbar-size", "48").unwrap(); }); - h.get_by_label("+").click(); + h.get_by_label("Hotbar settings").click(); h.run_steps(5); h.get_by_label("Full-side strip").click(); h.run_steps(3); @@ -3222,7 +3239,7 @@ fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { let (length, thickness) = if matches!(side, "left" | "right") { (size.y, size.x) } else { (size.x, size.y) }; assert!(length > 300.0, "strip must fill the edge: {side} {size:?}"); assert!(thickness <= 64.0, "strip must stay narrow: {size:?}"); - h.get_by_label("+"); + h.get_by_label("Hotbar settings"); } let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); assert_eq!(saved["hotbar"]["full_side"], true); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index b5565a53bb..61ff002224 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -362,10 +362,10 @@ fn navigation_rail_toggles_between_right_and_left_and_persists() { assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); h2.state_mut().dialog = Some(Dialog::Preferences); h2.run_steps(3); - h2.get_by_label("Navigation rail on right").click(); + h2.get_by_label("Navigation rail side: Right").click(); h2.run_steps(3); assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Right); - h2.get_by_label("Navigation rail on left").click(); + h2.get_by_label("Navigation rail side: Left").click(); h2.run_steps(3); assert_eq!(h2.state().side_panels.rail_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); } From 8e035f2e3458a47da6d5032c99b6c6de491af5fd Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 1 Nov 2026 22:14:38 +0100 Subject: [PATCH 118/562] fix(ui): reserve the navigation rail footer height (#5677) --- crates/ui-egui/src/chrome.rs | 181 +++++++++++++++++----------------- crates/ui-egui/tests/it/ui.rs | 73 ++++++++------ 2 files changed, 137 insertions(+), 117 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 59525aae7b..5be9ee506d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -577,96 +577,11 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). - ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + // Let the footer measure its natural height from the top. A bottom-up child can + // grow above the auto-sized panel's origin without reserving that space for the + // scrolling items, especially with the extra docking button. + ui.with_layout(Layout::top_down(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; - let right = app.side_panels.rail_on_right(); - let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; - if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { - app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); - } - // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. - let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); - let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); - page_display = egui::Popup::menu(&resp) - .show(|ui| { - ui.set_min_width(220.0); - rail_view_menu(ui, view) - }) - .and_then(|r| r.inner); - let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); - if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { - view.rotate_view(true); - } - // Not `columns-2` while fitting the page: that is the two-page view's icon. - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; - if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { - view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; - view.goto = Some((view.current, 0.0)); - } - let id = egui::Id::unique("zoom-input"); - let draft = id.with("draft"); - let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); - let edit = egui::TextEdit::singleline(&mut typed) - .id(id) - .desired_width(40.0) - .align(egui::Align2::CENTER_TOP) - .font(theme::medium(11.0)) - .text_color(t.text_muted) - .frame(egui::Frame::NONE) - .margin(vec2(2.0, 2.0)); - let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); - ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); - if r.gained_focus() { - let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), id); - } - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(draft, typed.clone())); - } else { - ui.data_mut(|d| d.remove::(draft)); - if r.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - && let Some(zoom) = DocView::parse_zoom_percent(&typed) - { - view.set_zoom(zoom); - } - } - ui.add_space(6.0); - if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { - view.step_page(true); - } - if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { - view.step_page(false); - } - // History is distinct from stepping to the adjacent page, and belongs to this tab. - for (forward, icon, label, shortcut, enabled) in [ - (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), - (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), - ] { - let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); - if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { - view.view_history(forward); - } - } - let font = theme::regular(11.0); - let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); - if widgets::text_width(ui, &position, &font) > ui.available_width() { - position = page_count.to_string(); - } - // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. - let shown = if view.current < page_count { view.current + 1 } else { page_count }; - let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); - ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); let page_input_id = view.page_input_id(); // Consume replacement selection once, including when the editor has lost focus. let reselect = std::mem::take(&mut view.page_input_reselect); @@ -702,6 +617,94 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); } + let font = theme::regular(11.0); + let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); + if widgets::text_width(ui, &position, &font) > ui.available_width() { + position = page_count.to_string(); + } + // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. + let shown = if view.current < page_count { view.current + 1 } else { page_count }; + let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); + ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); + // History is distinct from stepping to the adjacent page, and belongs to this tab. + for (forward, icon, label, shortcut, enabled) in [ + (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), + (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), + ] { + let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { + view.view_history(forward); + } + } + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { + view.step_page(false); + } + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { + view.step_page(true); + } + ui.add_space(6.0); + let id = egui::Id::unique("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .align(egui::Align2::CENTER_TOP) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); + } + } + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { + view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; + view.goto = Some((view.current, 0.0)); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); + if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { + view.rotate_view(true); + } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { + view.zoom_step(true); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { + view.zoom_step(false); + } + let right = app.side_panels.rail_on_right(); + let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; + if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { + app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; + } }); }); match rail.request { diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 61ff002224..0dc431344e 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1720,36 +1720,53 @@ fn the_edit_panel_keeps_the_add_update_remove_menu() { /// being drawn under the page number field. #[test] fn short_window_rail_buttons_scroll_clear_of_page_navigation() { - let (width, height) = (1000.0, 560.0); - let mut h = Harness::builder().with_size(egui::vec2(width, height)).build_eframe(|_cc| { - let mut app = PdfCraftApp::new(); - app.set_option("language", "en").unwrap(); - app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); - app - }); - crate::support::settle(&mut h); - let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { - h.query_all_by_label(label).map(|n| n.rect()).find(|r| r.left() >= width - 48.0).unwrap_or_else(|| panic!("{label} in the rail")) - }; - let page = h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(); - assert!(page.left() >= width - 48.0 && page.bottom() <= height, "page field {page:?} in the rail"); - assert!(rail(&h, "Comments").bottom() <= page.top(), "the first button sits above the page field"); - assert!(rail(&h, "Tags").top() >= page.top(), "the window is short enough that the buttons must scroll"); - let over = rail(&h, "Comments").center(); - h.event(egui::Event::PointerMoved(over)); - for _ in 0..8 { - h.event(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, -200.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::NONE, - source: egui::MouseWheelSource::Unknown, + use pdfcraft_ui_egui::side_panels::ToolsSide; + + for side in [ToolsSide::Right, ToolsSide::Left] { + let (width, height) = (1000.0, 560.0); + let mut h = Harness::builder().with_size(egui::vec2(width, height)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.side_panels.rail_side = side; + app.open_bytes("short.pdf", None, FIXTURE.to_vec()).unwrap(); + app }); - h.run_steps(4); + crate::support::settle(&mut h); + let in_rail = |r: egui::Rect| match side { + ToolsSide::Left => r.right() <= 48.0, + ToolsSide::Right => r.left() >= width - 48.0, + }; + let rail = |h: &Harness<'static, PdfCraftApp>, label: &str| { + h.query_all_by_label(label).map(|n| n.rect()).find(|r| in_rail(*r)).unwrap_or_else(|| panic!("{label} in the rail")) + }; + let page = h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(); + assert!(in_rail(page) && page.bottom() <= height, "page field {page:?} in the rail"); + let dock_label = match side { + ToolsSide::Right => "Dock navigation rail on Left", + ToolsSide::Left => "Dock navigation rail on Right", + }; + let dock = rail(&h, dock_label); + assert!(dock.top() >= page.bottom() && dock.bottom() <= height, "dock button {dock:?} stays below navigation and inside the window"); + assert!(rail(&h, "Comments").bottom() <= page.top(), "the first button sits above the page field"); + assert!(rail(&h, "Tags").top() >= page.top(), "the window is short enough that the buttons must scroll"); + let first = rail(&h, "Comments"); + let over = first.center(); + h.event(egui::Event::PointerMoved(over)); + for _ in 0..8 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(4); + } + let tags = rail(&h, "Tags"); + assert!(tags.top() >= first.top(), "Tags {tags:?} is fully visible in the scrolling area"); + assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}"); + assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put"); } - let tags = rail(&h, "Tags"); - assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}"); - assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put"); } /// A PDF with `n` empty 200×300 pt pages. From a87c4fb6b7ac0b37fee3502125ffd803160bba57 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:14:52 +0100 Subject: [PATCH 119/562] M8: redaction stores again without them the images that share a redacted JBIG2 image's globals, so the globals leave the file (protect.redact-image-pixels) A JBIG2 image's /JBIG2Globals hold the symbol dictionary of every image that shares them (jbig2enc -s and most scanners give one to all of a scan's pages), so the bitmaps of marks drawn only under a redaction stayed in the file while another page's image used them. Every image using the globals of a removed or replaced image (or of one in a removed or replaced form) is now decoded and stored again in place as Flate without them; a full save then drops them. One that can't be decoded for certain refuses the redaction (RedactError::SharedJbig2Globals). Report::images_restored counts them. --- crates/cos/src/document/objects.rs | 11 +++ crates/redact/README.md | 8 +- crates/redact/src/image.rs | 31 +++++++ crates/redact/src/lib.rs | 73 +++++++++++++++++ crates/redact/src/tests.rs | 127 +++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 6 files changed, 249 insertions(+), 5 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 39a4d9a6da..bab99ac45e 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -326,6 +326,17 @@ impl Document { pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter) } + /// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's + /// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without + /// globals. Redaction finds with it the other images that share a redacted image's globals. + pub fn jbig2_globals_ref(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { + return None; + } + self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref) + } + /// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`) /// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is /// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read diff --git a/crates/redact/README.md b/crates/redact/README.md index af43e958d8..cbc7e8bfd9 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -21,9 +21,11 @@ clear_marks(&mut doc, None)?; // remove marks without app (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its `/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy - is Flate, and refers to no globals: when no other image uses them, they leave the file with the - original on the full rewrite; when one does, they stay as they are, and may hold the shapes of - marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are + is Flate, and refers to no globals. Globals hold the shapes of every image that shares them (a + scan's pages, often), those of marks under the area included, so every other image that uses + the globals of a removed or replaced image is decoded and stored again in place as Flate without + them (`Report::images_restored`), and they leave the file on the full rewrite; one that can't be + decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 6c7a6bf93b..8c58d9c429 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -167,3 +167,34 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] dict.remove(b"Alternates"); Some(Some(Stream::flate(dict, &data))) } + +/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, +/// and written as a Flate image of the same samples, its other entries kept. `None` when it can't +/// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than +/// the image, or not a one-bit image of one component). Redaction does this to every image that +/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn +/// under the area too. +pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { + let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); + if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { + return None; + } + let (w, h) = (w as usize, h as usize); + let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); + // JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7). + if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) { + return None; + } + let bytes = w.div_ceil(8).checked_mul(h)?; + // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one + // of the raster and a byte a row. + let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; + let mut data = doc.decoded_image_within(s, allowance).ok()?; + if data.len() < bytes { + return None; + } + data.truncate(bytes); + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + Some(Stream::flate(dict, &data)) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..e1891ecfef 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -70,6 +70,12 @@ pub enum RedactError { /// could not be shown complete. #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] TooDeep, + /// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again + /// without them, so the shapes they hold, those of marks under the area included, would stay. + #[error( + "{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted" + )] + SharedJbig2Globals(usize), #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -132,6 +138,9 @@ pub struct Report { pub glyphs: usize, pub images_removed: usize, pub images_cleared: usize, + /// JBIG2 images, anywhere in the document, stored again as Flate without the `/JBIG2Globals` + /// they shared with a redacted image, so those globals leave the file. + pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, pub forms_rewritten: usize, @@ -509,6 +518,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result Result { + let mut globals = HashSet::new(); + let mut seen = HashSet::new(); + let mut stack: Vec = doomed.to_vec(); + while let Some(r) = stack.pop() { + if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK { + continue; + } + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + if let Some(g) = doc.jbig2_globals_ref(s) { + globals.insert(g); + } + // A form's images, and its forms' (an image's /SMask is a stream of its own, found below). + let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r)); + let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x)); + if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) { + stack.extend(x.iter().filter_map(|(_, v)| v.as_ref())); + } + if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) { + stack.push(m); + } + } + if globals.is_empty() { + return Ok(0); + } + let sharing: Vec = doc + .scan_objects() + .filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g)))) + .map(|(r, _)| r) + .collect(); + let doomed: HashSet = doomed.iter().copied().collect(); + let (mut restored, mut stuck) = (0, 0); + for r in sharing { + let object = doc.get(r); + let Object::Stream(s) = &*object else { continue }; + match image::without_globals(doc, s) { + Some(new) => { + doc.set(r, Object::Stream(new)); + restored += usize::from(!doomed.contains(&r)); + } + None if doomed.contains(&r) => {} + None => stuck += 1, + } + } + if stuck > 0 { + return Err(RedactError::SharedJbig2Globals(stuck)); + } + Ok(restored) +} + /// What applying the marks collects across pages. struct Applying { report: Report, diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 07b720795c..524c5565c6 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -938,6 +938,133 @@ fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() { } } +/// The segments of a JBIG2 stream `encode_jbig2` wrote, each with its header: a header is the +/// segment's number (4 bytes), type, flags whose top three bits count the segments it refers to, +/// those (a byte each), its page (a byte) and its data's length (4 bytes; ISO/IEC 14492 7.2). +fn jbig2_segments(mut coded: &[u8]) -> Vec> { + let mut segments = Vec::new(); + while !coded.is_empty() { + let refers = usize::from(coded[5] >> 5); + let header = 4 + 1 + 1 + refers + 1 + 4; + let length = u32::from_be_bytes(coded[header - 4..header].try_into().unwrap()) as usize; + segments.push(coded[..header + length].to_vec()); + coded = &coded[header + length..]; + } + segments +} + +/// Two pages of a scan share one `/JBIG2Globals` stream holding the symbol dictionary, as +/// `jbig2enc -s` and most scanners write them, and the first has a mark found nowhere else (a +/// signature, say) under the area. The dictionary holds that mark's bitmap, so once the first page +/// is redacted the second page's image is stored again without the globals, and neither the +/// dictionary nor any stream naming globals stays in the saved file; both pages keep every pixel +/// outside the area. +#[test] +fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { + const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"]; + const SIGNATURE: [&str; 7] = ["#..#.##", ".##.#..", "#.#..##", "..##.#.", "##..#.#", ".#.##..", "#..#..#"]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + let draw = |bits: &mut [u8], shape: &[&str], top: usize, left: usize| { + for (dy, row) in shape.iter().enumerate() { + for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') { + bits[(top + dy) * stride + (left + dx) / 8] &= !(0x80 >> ((left + dx) % 8)); + } + } + }; + // The letter over and over lower down, and the signature once, top left, under the mark. + let mut first = vec![0xFFu8; stride * h]; + for top in [14usize, 24, 32] { + for x in (3..w - 6).step_by(7) { + draw(&mut first, &LETTER, top, x); + } + } + draw(&mut first, &SIGNATURE, 2, 10); + let mut second = vec![0xFFu8; stride * h]; + for x in (3..w - 6).step_by(7) { + draw(&mut second, &LETTER, 20, x); + } + // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the + // second page's segments are numbered past it. + let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap()); + assert_eq!(segments[1][4], 0, "the second segment is the symbol dictionary"); + let mut dictionary = segments.remove(1); + dictionary[6] = 0; + let page_one: Vec = segments.concat(); + let page_two: Vec = jbig2_segments(&pdfcraft_filters::encode_jbig2_generic(&second, w as u32, h as u32).unwrap()) + .into_iter() + .enumerate() + .flat_map(|(k, mut s)| { + s[..4].copy_from_slice(&(100 + k as u32).to_be_bytes()); + s + }) + .collect(); + let image = format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 9 0 R >>" + ); + // Half a point a pixel, the image's top row at y 120. + let page = |content: u32, im: u32| { + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents {content} 0 R /Resources << /XObject << /Im {im} 0 R >> >> >>") + .into_bytes() + }; + let build = |second_image: &str| { + pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + page(5, 7), + page(6, 8), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()), + stream(&image, page_one.clone()), + stream(second_image, page_two.clone()), + stream("", dictionary.clone()), + ]) + }; + let mut doc = build(&image); + // A page's picture as it is drawn, JBIG2 read with its globals. + let picture = |doc: &Document, page: usize, name: &[u8]| { + let p = pdfcraft_model::pages(doc).swap_remove(page); + let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; + let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; + match &*doc.get(xo.get(name)?.as_ref()?) { + Object::Stream(s) => doc.decoded_image(s).ok(), + _ => None, + } + }; + assert_eq!(picture(&doc, 0, b"Im"), Some(first.clone()), "the first page reads its symbols from the globals"); + assert_eq!(picture(&doc, 1, b"Im"), Some(second.clone())); + // Columns 0 to 99 of rows 0 to 12. + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed, report.images_restored), (1, 0, 1)); + let mut cleared = first.clone(); + for y in 0..=12 { + for x in 0..=99 { + cleared[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + let saved = reopen(&doc); + assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared)); + assert_eq!(picture(&saved, 1, b"Im"), Some(second), "the second page keeps its picture"); + // The dictionary's data (after its 11-byte header) codes the signature's bitmap. + let coded = &dictionary[11..]; + for (r, object) in saved.scan_objects() { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&object, &mut bytes); + assert!(!bytes.windows(b"JBIG2Globals".len()).any(|w| w == b"JBIG2Globals"), "{r:?} still names globals"); + let data = match &*object { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }; + assert!(!bytes.windows(coded.len()).chain(data.windows(coded.len())).any(|w| w == coded), "the symbol dictionary stays as {r:?}"); + } + // The second page's image can't be read for certain (its JBIG2 page is shorter than the + // image), so it can't be stored without the globals: the redaction is refused. + let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); + mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2187f33c81..be9ec03ea6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5804,8 +5804,8 @@ milestone = "M8" status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] -evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate (larger than a Group 4 or JBIG2 scan) and refers to no globals; globals another image still uses stay, and may hold the shapes of marks under the area (not where they were)" +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"] +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate (larger than a Group 4 or JBIG2 scan) and refers to no globals; every other image using a redacted image's globals is stored again as Flate without them, so they leave the file (one that can't be decoded refuses the redaction)" [[feature]] id = "protect.redact-vectors" From d35be6fdac56b2596008cb0b09b441f098418369 Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 1 Nov 2026 22:40:04 +0100 Subject: [PATCH 120/562] fix(ui): use the shared rail viewport fix (#5677) --- Cargo.lock | 18 ++-- Cargo.toml | 14 +-- crates/ui-egui/src/chrome.rs | 181 +++++++++++++++++------------------ 3 files changed, 105 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ccf080b27a..78d058dcb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,17 +1178,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "egui", "serde", @@ -1221,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-keymap", "craft-quit", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-winprint-gdi", "log", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "dispatch2", "egui", @@ -1259,7 +1259,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index c9db94b27b..edd9e682b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,18 +13,18 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # One menu definition for the native macOS menu bar and the in-window bar elsewhere. -craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false } +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.17.0" } pdfcraft-filters = { path = "crates/filters", version = "0.17.0" } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5be9ee506d..59525aae7b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -577,11 +577,96 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, &items, app.right.as_ref(), |ui| { // Page navigation cluster at the bottom, a panel of its own so that in a short // window the buttons above scroll instead of running into it (#3282). - // Let the footer measure its natural height from the top. A bottom-up child can - // grow above the auto-sized panel's origin without reserving that space for the - // scrolling items, especially with the extra docking button. - ui.with_layout(Layout::top_down(Align::Center), |ui| { + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { ui.spacing_mut().item_spacing.y = 2.0; + let right = app.side_panels.rail_on_right(); + let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; + if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { + app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { + view.zoom_step(false); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { + view.zoom_step(true); + } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); + let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); + if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { + view.rotate_view(true); + } + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { + view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; + view.goto = Some((view.current, 0.0)); + } + let id = egui::Id::unique("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .align(egui::Align2::CENTER_TOP) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); + } + } + ui.add_space(6.0); + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { + view.step_page(true); + } + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { + view.step_page(false); + } + // History is distinct from stepping to the adjacent page, and belongs to this tab. + for (forward, icon, label, shortcut, enabled) in [ + (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), + (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), + ] { + let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { + view.view_history(forward); + } + } + let font = theme::regular(11.0); + let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); + if widgets::text_width(ui, &position, &font) > ui.available_width() { + position = page_count.to_string(); + } + // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. + let shown = if view.current < page_count { view.current + 1 } else { page_count }; + let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); + ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); let page_input_id = view.page_input_id(); // Consume replacement selection once, including when the editor has lost focus. let reselect = std::mem::take(&mut view.page_input_reselect); @@ -617,94 +702,6 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); } - let font = theme::regular(11.0); - let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); - if widgets::text_width(ui, &position, &font) > ui.available_width() { - position = page_count.to_string(); - } - // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. - let shown = if view.current < page_count { view.current + 1 } else { page_count }; - let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); - ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); - // History is distinct from stepping to the adjacent page, and belongs to this tab. - for (forward, icon, label, shortcut, enabled) in [ - (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), - (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), - ] { - let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); - if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { - view.view_history(forward); - } - } - if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { - view.step_page(false); - } - if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { - view.step_page(true); - } - ui.add_space(6.0); - let id = egui::Id::unique("zoom-input"); - let draft = id.with("draft"); - let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); - let edit = egui::TextEdit::singleline(&mut typed) - .id(id) - .desired_width(40.0) - .align(egui::Align2::CENTER_TOP) - .font(theme::medium(11.0)) - .text_color(t.text_muted) - .frame(egui::Frame::NONE) - .margin(vec2(2.0, 2.0)); - let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); - ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); - if r.gained_focus() { - let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), id); - } - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(draft, typed.clone())); - } else { - ui.data_mut(|d| d.remove::(draft)); - if r.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - && let Some(zoom) = DocView::parse_zoom_percent(&typed) - { - view.set_zoom(zoom); - } - } - // Not `columns-2` while fitting the page: that is the two-page view's icon. - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; - if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { - view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; - view.goto = Some((view.current, 0.0)); - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); - if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { - view.rotate_view(true); - } - // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. - let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); - let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); - page_display = egui::Popup::menu(&resp) - .show(|ui| { - ui.set_min_width(220.0); - rail_view_menu(ui, view) - }) - .and_then(|r| r.inner); - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); - } - let right = app.side_panels.rail_on_right(); - let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") }; - if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() { - app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right }; - } }); }); match rail.request { From 616ddca371a49cee3a3f187b0d086e5058e7662d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:48:29 +0100 Subject: [PATCH 121/562] M8: images stored again without shared JBIG2 globals are stored as a cleared copy is An image that shared a redacted image's /JBIG2Globals is now stored again as the smallest of Flate, Group 4 or JBIG2 coded alone (smallest), like a cleared copy, rather than always Flate, so the other pages of a scan don't balloon either. The shared-globals test's second page is a ragged scan and checks it is no longer Flate and is smaller. --- crates/redact/README.md | 4 ++-- crates/redact/src/image.rs | 8 ++++++-- crates/redact/src/lib.rs | 12 ++++++------ crates/redact/src/tests.rs | 13 ++++++++----- parity/acrobat-features.toml | 2 +- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/crates/redact/README.md b/crates/redact/README.md index e590d0a03d..9c024d1b55 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,8 +25,8 @@ clear_marks(&mut doc, None)?; // remove marks without app when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to no globals. Globals hold the shapes of every image that shares them (a scan's pages, often), those of marks under the area included, so every other image that uses the globals of a removed - or replaced image is decoded and stored again in place as Flate without them - (`Report::images_restored`), and they leave the file on the full rewrite; one that can't be + or replaced image is decoded and stored again in place without them, as a copy is (Flate, + Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 802c7a47ec..a96f4715e6 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -204,7 +204,8 @@ fn stored_len(s: &Stream) -> usize { } /// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, -/// and written as a Flate image of the same samples, its other entries kept. `None` when it can't +/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest +/// (`smallest`), its other entries kept. `None` when it can't /// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than /// the image, or not a one-bit image of one component). Redaction does this to every image that /// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn @@ -231,5 +232,8 @@ pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { data.truncate(bytes); let mut dict = s.dict.clone(); dict.remove(b"Length"); - Some(Stream::flate(dict, &data)) + // The globals are named in the parameters; the copy is coded alone, as `clear`'s are. + dict.remove(b"DecodeParms"); + dict.remove(b"Alternates"); + Some(smallest(dict, &data, w, h, true, true)) } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index e1891ecfef..fad55c431f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -138,8 +138,8 @@ pub struct Report { pub glyphs: usize, pub images_removed: usize, pub images_cleared: usize, - /// JBIG2 images, anywhere in the document, stored again as Flate without the `/JBIG2Globals` - /// they shared with a redacted image, so those globals leave the file. + /// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the + /// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file. pub images_restored: usize, pub paths_removed: usize, pub paths_clipped: usize, @@ -551,10 +551,10 @@ const MAX_JBIG2_WALK: usize = 100_000; /// of marks drawn only under the area too. The image itself is replaced or removed, but the globals /// would stay for as long as another image uses them. So every image that uses the globals of a /// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded -/// and stored again in place as Flate, without them, and a full save then leaves them out. An -/// image that can't be stored again refuses the redaction (nothing changes, as `apply` works on a -/// copy), but for a removed or replaced one, which is either unlinked or still drawn whole on -/// another page. Returns how many images that weren't removed or replaced were stored again. +/// and stored again in place without them, as a cleared copy is, and a full save then leaves them +/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply` +/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn +/// whole on another page. Returns how many images that weren't removed or replaced were stored again. fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef]) -> Result { let mut globals = HashSet::new(); let mut seen = HashSet::new(); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 22eac1296b..74b8407056 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1154,10 +1154,8 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { } } draw(&mut first, &SIGNATURE, 2, 10); - let mut second = vec![0xFFu8; stride * h]; - for x in (3..w - 6).step_by(7) { - draw(&mut second, &LETTER, 20, x); - } + // The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate. + let second = ragged_scan(w, h); // The first page's symbol dictionary (its second segment) goes to the globals, on no page; the // second page's segments are numbered past it. let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap()); @@ -1219,7 +1217,12 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { } let saved = reopen(&doc); assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared)); - assert_eq!(picture(&saved, 1, b"Im"), Some(second), "the second page keeps its picture"); + assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture"); + // Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate. + let restored = named_stream(&saved, 1, b"Im").unwrap(); + let flate = pdfcraft_filters::encode_flate(&second); + assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len()); // The dictionary's data (after its 11-byte header) codes the signature's bitmap. let coded = &dictionary[11..]; for (r, object) in saved.scan_objects() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ebafe530be..8b923d6ce7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5825,7 +5825,7 @@ status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared", "crates/redact/src/tests.rs::redaction_clears_pixels_intersected_without_covering_their_centres", "crates/redact/src/tests.rs::a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared", "crates/redact/src/tests.rs::an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared", "crates/redact/src/tests.rs::a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed", "crates/redact/src/tests.rs::a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole", "crates/redact/src/tests.rs::a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals", "crates/redact/src/tests.rs::a_cleared_black_and_white_scan_is_stored_as_group_4", "crates/redact/src/tests.rs::globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file"] -notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again as Flate without them, so they leave the file (one that can't be decoded refuses the redaction)" +notes = "intersected pixel footprints are cleared conservatively in a copy, CCITT fax and JBIG2 images (scans stored as Group 4 or JBIG2, with their /JBIG2Globals) included; DCT/JPX images, Group 3 on bytes (which the bootstrap renderer may draw as other rows than the decoder reads), and a JBIG2 image whose page isn't its size, removed whole. The copy is Flate, or for a one-bit image of one component Group 4, or JBIG2 when it was JBIG2, whichever is smallest as stored, and refers to no globals; every other image using a redacted image's globals is stored again without them (Flate, Group 4 or JBIG2, whichever is smallest), so they leave the file (one that can't be decoded refuses the redaction)" [[feature]] id = "protect.redact-vectors" From 0b2f93538dedbe3118cb826a7e525385c6d0d70e Mon Sep 17 00:00:00 2001 From: FredApps Date: Sun, 1 Nov 2026 22:57:53 +0100 Subject: [PATCH 122/562] test(ui): target the Bookmarks submenu item explicitly (#5677) --- crates/ui-egui/tests/it/view.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index aef5ddae73..7f444bee0f 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2072,7 +2072,11 @@ fn long_view_menu_fits_a_short_window() { assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}"); crate::support::menu_row(&h, "Side panels").hover(); h.run_steps(4); - h.get_all_by_label("Bookmarks").last().unwrap().click(); + // The navigation rail has a Bookmarks button too; click the submenu's radio item. + let bookmarks = h.get_by(|n| n.role() == egui::accesskit::Role::MenuItemRadio && n.label().as_deref() == Some("Bookmarks")); + let row = bookmarks.rect(); + assert!(row.top() >= 0.0 && row.bottom() <= 400.0, "the Bookmarks submenu row is onscreen: {row:?}"); + bookmarks.click(); h.run_steps(4); assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); } From 74d51ebf4ae41ebc83492d008c4f97c98d346e9e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:50:39 +0100 Subject: [PATCH 123/562] M2.6: a JBIG2 image stored again without its globals decodes within the run's allowance --- crates/redact/src/image.rs | 10 +++++----- crates/redact/src/lib.rs | 19 +++++++++++++------ crates/redact/src/tests.rs | 9 +++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 67234497b8..c7b1036725 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -211,12 +211,12 @@ fn stored_len(s: &Stream) -> usize { /// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn, /// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest -/// (`smallest`), its other entries kept. `None` when it can't -/// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than -/// the image, or not a one-bit image of one component). Redaction does this to every image that +/// (`smallest`), its other entries kept. `None` when it can't be decoded for certain (too large, +/// past what is left of `budget`, data that doesn't decode, a JBIG2 page of another size than the +/// image, or not a one-bit image of one component). Redaction does this to every image that /// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn /// under the area too. -pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { +pub(crate) fn without_globals(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Option { let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; @@ -231,7 +231,7 @@ pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option { // As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one // of the raster and a byte a row. let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?; - let mut data = doc.decoded_image_within(s, allowance).ok()?; + let mut data = budget.decode(doc, s, allowance).ok()?; if data.len() < bytes { return None; } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 611745a42f..41ac47357e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -449,7 +449,7 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co if applying.images.refused() > 0 { return Err(RedactError::ImagesPastAllowance); } - let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, mut images, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -532,7 +532,7 @@ fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_co // A removed image or form must not stay reachable through another resource dictionary (an // inherited or shared /Resources): a full save keeps whatever is still referenced. interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?; - report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects)?; + report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects, &mut images)?; // 5. Verify: nothing readable may remain under a region. let mut residue = 0; @@ -570,8 +570,10 @@ const MAX_JBIG2_WALK: usize = 100_000; /// and stored again in place without them, as a cleared copy is, and a full save then leaves them /// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply` /// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn -/// whole on another page. Returns how many images that weren't removed or replaced were stored again. -fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef]) -> Result { +/// whole on another page. They are decoded within what is left of the run's allowance (`images`); +/// past it the redaction is refused as one clearing too many images is. Returns how many images +/// that weren't removed or replaced were stored again. +fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef], images: &mut pdfcraft_cos::ImageBudget) -> Result { let mut globals = HashSet::new(); let mut seen = HashSet::new(); let mut stack: Vec = doomed.to_vec(); @@ -603,19 +605,24 @@ fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef]) -> Result = doomed.iter().copied().collect(); - let (mut restored, mut stuck) = (0, 0); + let (mut restored, mut stuck, mut past_allowance) = (0, 0, false); for r in sharing { let object = doc.get(r); let Object::Stream(s) = &*object else { continue }; - match image::without_globals(doc, s) { + let refused = images.refused(); + match image::without_globals(doc, s, images) { Some(new) => { doc.set(r, Object::Stream(new)); restored += usize::from(!doomed.contains(&r)); } None if doomed.contains(&r) => {} + None if images.refused() > refused => past_allowance = true, None => stuck += 1, } } + if past_allowance { + return Err(RedactError::ImagesPastAllowance); + } if stuck > 0 { return Err(RedactError::SharedJbig2Globals(stuck)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 6e9a0b2810..2b9a143a19 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1303,6 +1303,15 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1))); mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], ""); assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1)))); + // Storing the second page's image again decodes it, within what is left of the run's + // allowance: with nothing left the redaction is refused as one past the allowance, not + // reported as an image that can't be read. + let mut doc = build(&image); + let first_image = [ObjRef::new(7, 0)]; + let refused = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(0)); + assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "{refused:?}"); + let restored = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)); + assert_eq!(restored.ok(), Some(1)); } /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through From 5eb82d1f68cf263a866b18ce76208276f42e9ca6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:52:07 +0100 Subject: [PATCH 124/562] M2.6: an image pays for the pixels its data holds, not those its size declares --- crates/cos/src/budget.rs | 62 +++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 75ed03ea1a..270fcdac21 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -46,8 +46,10 @@ type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usiz /// aren't decoded. A caller then treats them as it does an image it can't read, and /// [`ImageBudget::refused`] says how many there were. /// -/// An image also pays for its pixels, as what reads it goes through them one by one however few -/// bytes hold them: blank rows of fax or Flate take almost none. A JPEG pays for its samples when +/// An image also pays for its pixels once decoded, as what reads it goes through them one by one +/// however few bytes coded them: blank rows of fax or Flate take almost none. It pays for no more +/// than its data holds, a bit a pixel at least, so an image declaring a size its data doesn't fill +/// costs what a smaller one would, not the run's allowance. A JPEG pays for its samples when /// it is decoded ([`ImageBudget::pay_jpeg`]), and is otherwise not charged, as JPEG 2000 isn't: /// their decoders don't run here. #[derive(Debug)] @@ -70,22 +72,19 @@ impl ImageBudget { /// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance. pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { - self.pay_size(s)?; let decoded = doc.decoded_image_spending(s, max, &mut self.work); self.count_refused(s, &decoded); - decoded + self.pay_size(s, decoded?) } /// Image `s` through its filters alone, charged to the allowance: [`Stream::decoded_within`] /// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an /// image that has no fax or JBIG2 decoder to go through. pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { - if s.filters().iter().all(|(f, _)| f.is_decodable()) { - self.pay_size(s)?; - } let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work); self.count_refused(s, &decoded); - decoded + let decoded = decoded?; + if s.filters().iter().all(|(f, _)| f.is_decodable()) { self.pay_size(s, decoded) } else { Ok(decoded) } } /// `data` through `chain`, any corruption being an error ([`pdfcraft_filters::decode_within`]), @@ -121,14 +120,20 @@ impl ImageBudget { self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading)) } - /// Pays for the pixels of image `s`, unless it is JBIG2, whose decoder pays for its page, or - /// isn't decoded here. - fn pay_size(&mut self, s: &Stream) -> Result<(), CosError> { + /// Pays for the pixels of image `s` that its `decoded` data holds, those of its `/Width` and + /// `/Height` but no more than a bit of the data each, unless it is JBIG2, whose decoder pays for + /// its page, or isn't decoded here. What can't be paid for refuses the image. + fn pay_size(&mut self, s: &Stream, decoded: Vec) -> Result, CosError> { if !charged(s) || matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { - return Ok(()); + return Ok(decoded); } let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()).unwrap_or(0); - if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height"))) { Ok(()) } else { Err(CosError::Filter(FilterError::Spent.to_string())) } + let held = u64::try_from(decoded.len()).unwrap_or(u64::MAX).saturating_mul(8); + if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height")).min(held)) { + Ok(decoded) + } else { + Err(CosError::Filter(FilterError::Spent.to_string())) + } } /// An image that failed with nothing left was refused, unless it has a filter that costs @@ -498,7 +503,7 @@ mod image_tests { let mut budget = ImageBudget::with_work(cost); assert_eq!(budget.decode_filters(&doc, &flate, 1 << 24).unwrap(), rows); assert_eq!(budget.work_left(), 0); - // Short of its pixels it isn't read: this much would have paid for the filters. + // Short of its pixels it is refused, having paid for its filters. let mut budget = ImageBudget::with_work(4096 * 1024 - 1); assert!(budget.decode(&doc, &flate, 1 << 24).unwrap_err().to_string().contains("more image decoding than")); assert_eq!((budget.work_left(), budget.refused()), (0, 1)); @@ -527,6 +532,35 @@ mod image_tests { assert_eq!((budget.work_left(), budget.refused()), (0, 1)); } + /// An image pays for the pixels its data holds, not those its size declares: one claiming + /// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the + /// images after it are decoded as they would be without it. + #[test] + fn an_image_pays_for_no_more_pixels_than_its_data_holds() { + let doc = Document::new_empty(); + let sized = |side: i64, data: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(side)); + dict.set(b"Height".to_vec(), Object::Int(side)); + Stream::flate(dict, data) + }; + let claims = sized(131_072, &[0; 16]); + let image = sized(64, &[7; 64 * 64]); + let cost = |s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, s, 1 << 24).unwrap(); + u64::MAX - budget.work_left() + }; + assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them"); + assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels"); + for decode in [ImageBudget::decode, ImageBudget::decode_filters] { + let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image)); + assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]); + assert_eq!(decode(&mut budget, &doc, &image, 1 << 24).unwrap(), [7; 64 * 64], "the image after it is decoded"); + assert_eq!((budget.work_left(), budget.refused()), (0, 0)); + } + } + /// A stream a run rewrites is read strictly and charged, and what the caller does with it after /// comes out of the same allowance: what can't be paid for spends all of it. #[test] From f89a5917ed82d579d2fd78cd29ba7c443cff6454 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:52:37 +0100 Subject: [PATCH 125/562] M2.6: streams made in memory enlarge a run's image allowance as a file's bytes do --- crates/cos/src/budget.rs | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 270fcdac21..e4373211e9 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -59,9 +59,19 @@ pub struct ImageBudget { } impl ImageBudget { - /// The allowance for an operation on `doc`, which a longer file makes larger. + /// The allowance for an operation on `doc`, which a longer file makes larger. The streams made + /// or changed since it was opened count as the file's bytes do, so a document built in memory + /// (combined, created, or given pages of scans) gets what it would once saved and opened again. pub fn new(doc: &Document) -> Self { - let bytes = u64::try_from(doc.bytes().len()).unwrap_or(u64::MAX); + let length = |n: usize| u64::try_from(n).unwrap_or(u64::MAX); + let edited = doc + .scan_objects_subset(doc.modified_objects()) + .filter_map(|(_, object)| match &*object.ok()? { + Object::Stream(s) => Some(length(s.raw.len())), + _ => None, + }) + .fold(0u64, u64::saturating_add); + let bytes = length(doc.bytes().len()).saturating_add(edited); Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE))) } @@ -661,6 +671,24 @@ mod image_tests { assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + len * IMAGE_WORK_PER_BYTE); } + /// A document made in memory, as Combine, Create and inserted scans make one, gets for the + /// streams it was given what it would get for them once saved: what isn't a stream adds nothing. + #[test] + fn streams_made_in_memory_allow_more_decoding_too() { + let mut doc = Document::new_empty(); + doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 3000]))); + doc.add(Object::Dict(Dict::new())); + doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 100]))); + let flate = pdfcraft_filters::encode_flate(&[1; 100]).len() as u64; + assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (3000 + flate) * IMAGE_WORK_PER_BYTE); + // A stream of an opened file changed in memory counts with the file. + let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec(); + let len = pdf.len() as u64; + let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap(); + doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 500]))); + assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (len + 500) * IMAGE_WORK_PER_BYTE); + } + // What the constants' comments promise, for a page scanned at 300 dpi: about 1,000 of them // whatever the file's size, and a page paying for itself with 2 KB of the file. const PAGE: u64 = 17_000_000; From 5f07da441978ccdc8dcc98501e3996562ec8db56 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 09:33:03 +0530 Subject: [PATCH 126/562] ui: modernize preferences dialog with card-based layout - Replace cluttered settings list with clean, modular card containers - Integrate interactive real-time search box flush with card boundary - Combine page display and continuous scrolling into a unified segmented pill - Enable smooth scrolling with dedicated animation duration - Enhance vertical scrollbar spacing and layout geometry - Update unit and integration tests across layout, appearance, and theme - Credit @Combiler01 in contributors/people.toml --- contributors/people.toml | 3 + crates/ui-egui/src/dialogs.rs | 17 +- crates/ui-egui/src/dialogs/appearance.rs | 26 +- crates/ui-egui/src/dialogs/preferences.rs | 783 ++++++++++++++++++ crates/ui-egui/src/workflows/js_ui.rs | 443 +--------- crates/ui-egui/tests/it/preferences_layout.rs | 95 +++ crates/ui-egui/tests/it/theme.rs | 6 +- 7 files changed, 911 insertions(+), 462 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/preferences.rs diff --git a/contributors/people.toml b/contributors/people.toml index a5a47f5c52..fecfb68c90 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -180,3 +180,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.Combiler01] +sync_github_name = true diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 42bed83657..2e2457fbbf 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -14,6 +14,7 @@ pub(super) mod credits; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; +pub(super) mod preferences; mod prompts; pub(super) mod protect; pub(super) mod standards_ui; @@ -127,6 +128,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::Properties(_) => 640.0, Dialog::Print => 820.0, Dialog::FieldProps | Dialog::BulkFieldProps => 600.0, + Dialog::Preferences => 610.0, Dialog::About => 780.0, Dialog::PinTool => 540.0, _ => 520.0, @@ -1356,10 +1358,11 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) /// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted. fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.set_width(610.0); let t = Tokens::get(ui.ctx()); // The settings outgrow a short window: scroll them rather than run off the screen. // The OK row stays below the scroll, in reach however far the settings are scrolled. - let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); + let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW).max(preferences::PREFERENCES_MIN_SCROLL); // A scroll area starts from the room the dialog offers it, and a modal offers more // each frame for the first few, so with OK outside the scroll the dialog would grow // over those frames. Asking for the full height as the least it may scroll in @@ -1368,9 +1371,17 @@ fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendin // cards and other full-width rows (#3557). ui.scope(|ui| { ui.spacing_mut().scroll.floating = false; - egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); + ui.spacing_mut().scroll.bar_inner_margin = 14.0; + ui.spacing_mut().scroll.bar_outer_margin = 2.0; + ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25); + egui::ScrollArea::vertical() + .id_salt("preferences-dialog-scroll") + .max_height(max) + .min_scrolled_height(max) + .animated(true) + .show(ui, |ui| preferences::preferences_body(ui, app, &t)); }); - req.close = crate::js_ui::preferences_buttons(ui); + req.close = preferences::preferences_buttons(ui); false } diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 48ad0cb31c..135337aaba 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -5,7 +5,6 @@ use egui::{Rect, RichText, pos2, vec2}; use crate::PdfCraftApp; use crate::theme::{self, ThemeKind, ThemePreference, Tokens}; -use crate::widgets; /// The themes each card offers. One of each today; a new theme is one more row here. pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Light, "Light gray")]; @@ -82,9 +81,10 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } } -/// One theme card: its title, Active when it is what shows now, a preview of the chosen theme -/// and a radio row per theme. Returns true when the card or one of its radio rows was clicked. +/// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. +/// Returns true when the card was clicked. fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { + let mut title_clicked = false; let inner = egui::Frame::new() .fill(t.card) .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border })) @@ -93,7 +93,8 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The .show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { - ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text)); + let resp = ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text)); + title_clicked = resp.interact(egui::Sense::click()).clicked(); if active { ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); } @@ -101,17 +102,10 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The ui.add_space(4.0); let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); preview(ui, shown, ui.available_width()); - ui.add_space(4.0); - let mut clicked = false; - for (kind, label) in themes { - // One theme per card today, so it is always the chosen one. - clicked |= ui.add(widgets::radio(*kind == shown, tl!(label))).clicked(); - } - clicked }); - // The whole card picks its theme, not just the radio row. + // The whole card picks its theme. let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); - inner.inner || whole.clicked() + title_clicked || whole.clicked() } /// The Appearance section of Preferences. @@ -175,7 +169,6 @@ mod tests { ); h.run(); assert!(h.query_by_label("Light theme").is_some() && h.query_by_label("Dark theme").is_some()); - assert!(h.query_by_label("Light gray").is_some() && h.query_by_label("Dark gray").is_some()); assert_eq!(h.query_all_by_label("Active").count(), 1); if let Ok(path) = std::env::var("PDFCRAFT_APPEARANCE_SHOT") { h.render().unwrap().save(path).unwrap(); @@ -200,7 +193,7 @@ mod tests { assert_eq!(h.state().theme_preference, ThemePreference::System); } - /// Clicking a theme card, or its radio row, picks that theme, not only the mode combo box. + /// Clicking a theme card picks that theme, not only the mode combo box. #[test] fn clicking_a_theme_card_picks_that_theme() { let mut app = PdfCraftApp::new(); @@ -219,10 +212,9 @@ mod tests { app, ); h.run(); - h.get_by_label("Dark gray").click(); + h.get_by_label("Dark theme").click(); h.run(); assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Dark, ThemeKind::Dark)); - // The card's title is not a widget of its own: the click lands on the card. h.get_by_label("Light theme").click(); h.run(); assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Light, ThemeKind::Light)); diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs new file mode 100644 index 0000000000..4147417277 --- /dev/null +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -0,0 +1,783 @@ +//! Preferences dialog: card-based settings interface organized into clean, modular cards +//! with segmented pill controls and real-time search filtering. + +use egui::{Color32, CornerRadius, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; + +use crate::PdfCraftApp; +use crate::canvas::{Fit, PageLayout}; +use crate::motion::AnimationEffects; +use crate::scroll_speed::ScrollSpeed; +use crate::side_panels::ToolsSide; +use crate::theme::{self, ThemeKind, Tokens}; +use crate::units::Unit; +use crate::widgets::{self, icons}; + +/// The height the Preferences dialog keeps below its scrolling settings for the buttons row. +pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; + +/// The least height the scrolling settings are given in very small windows. +pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; + +/// A segmented pill control for mutually exclusive choices. +/// +/// Draws a rounded container with options inside. The active option is highlighted +/// with the accent colour. Emits AccessKit RadioButton widget info with the option's label. +pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); + segmented_pill_choice_a11y(ui, t, current, &with_a11y) +} + +/// A segmented pill selection bar with separate accessibility labels. +pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + let mut changed = false; + egui::Frame::new() + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(14)) + .inner_margin(Margin::symmetric(2, 2)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.horizontal(|ui| { + for &(value, label, a11y_label) in options { + let is_active = *current == value; + let font = theme::medium(12.0); + let text_color = if is_active { Color32::WHITE } else { t.text }; + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; + let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); + + if is_active { + ui.painter().rect(rect, CornerRadius::same(12), t.accent, Stroke::NONE, egui::StrokeKind::Inside); + } else { + let press = theme::Press::track(ui, &resp); + let fill = press.fill(Color32::TRANSPARENT, t.hover, t.pressed); + ui.painter().rect(rect, CornerRadius::same(12), fill, Stroke::NONE, egui::StrokeKind::Inside); + } + ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + + let resp = theme::hand(resp); + if resp.clicked() && *current != value { + *current = value; + changed = true; + } + } + }); + }); + changed +} + +/// An independent toggle pill button (e.g. for standalone toggles). +#[allow(dead_code)] +pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + let mut changed = false; + let is_active = *active; + let font = theme::medium(12.0); + let text_color = if is_active { Color32::WHITE } else { t.text }; + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; + let (rect, resp) = ui.allocate_exact_size(vec2(w + 20.0, 26.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, is_active, label)); + + let (fill, stroke) = if is_active { + (t.accent, Stroke::NONE) + } else { + let press = theme::Press::track(ui, &resp); + (press.fill(t.field, t.hover, t.pressed), Stroke::new(1.0, t.border)) + }; + ui.painter().rect(rect, CornerRadius::same(13), fill, stroke, egui::StrokeKind::Inside); + ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + + let resp = theme::hand(resp); + if resp.clicked() { + *active = !*active; + changed = true; + } + changed +} + +/// A standard card container for a Preferences section. +/// Groups related settings with clean margins, subtle border and rounded corners. +pub fn preference_card(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Option<&str>, content: impl FnOnce(&mut egui::Ui) -> R) -> R { + let card_fill = if t.kind == ThemeKind::Light { Color32::from_rgb(0xF7, 0xF8, 0xFA) } else { Color32::from_rgb(0x24, 0x24, 0x28) }; + egui::Frame::new() + .fill(card_fill) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(8)) + .inner_margin(Margin::symmetric(14, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + if let Some(ic) = icon { + ui.add(icons::image(ic, 16.0, t.accent)); + } + ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text)); + }); + ui.add_space(8.0); + content(ui) + }) + .inner +} + +fn matches_search(query: &str, title: &str, keywords: &[&str]) -> bool { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return true; + } + title.to_lowercase().contains(&q) || keywords.iter().any(|k| k.to_lowercase().contains(&q)) +} + +/// Whether the interface can draw a language picker's native name. +fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = (ui, text); + true + } + #[cfg(not(target_arch = "wasm32"))] + { + ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text)) + } +} + +/// The date picker's displayed name. +pub(crate) fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str { + match crate::i18n::Lang::from_code(l.code) { + Some(language) => language.picker_name(drawable), + None => l.name, + } +} + +/// Interactive search box widget at the top of Preferences. +fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, rect: Rect) { + let stroke = Stroke::new(1.0, t.border); + ui.painter().rect(rect, CornerRadius::same(15), t.field, stroke, egui::StrokeKind::Inside); + + // Magnifying glass icon: 14pt icon centered vertically at (rect.min.x + 16, rect.center().y) + let icon_rect = Rect::from_center_size(pos2(rect.min.x + 16.0, rect.center().y), vec2(14.0, 14.0)); + icons::paint(ui, icon_rect, "search", 14.0, t.text_muted); + + let clear_w = if query.is_empty() { 12.0 } else { 30.0 }; + let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(rect.max.x - clear_w, rect.max.y)); + let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + let edit = child_ui.add( + egui::TextEdit::singleline(query) + .hint_text(tl!("Search settings…")) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(edit_rect.width()), + ); + edit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Search settings…"))); + + if !query.is_empty() { + let clear_rect = Rect::from_center_size(pos2(rect.max.x - 16.0, rect.center().y), vec2(16.0, 16.0)); + let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); + clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); + icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); + if clear_resp.clicked() { + query.clear(); + } + } + + if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + query.clear(); + } +} + +/// The body of the Preferences dialog with card layout and search filtering. +pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); + let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + + // Top Header: Title on the left, Search bar on the right. + // Allocate the full available width so the search bar aligns flush with the card boxes below. + let full_w = ui.available_width(); + let header_h = 30.0; + let (header_rect, _) = ui.allocate_exact_size(vec2(full_w, header_h), Sense::hover()); + + // Title on the left, vertically centered in the header row: + let title_rect = Rect::from_min_max(header_rect.min, pos2(header_rect.min.x + 240.0, header_rect.max.y)); + let mut title_ui = ui.new_child(egui::UiBuilder::new().max_rect(title_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); + title_ui.label(RichText::new(tl!("Preferences")).font(theme::semibold(18.0)).color(t.text)); + + // Search bar on the right, right edge flush with the card box boundary: + let search_w = 210.0; + let search_rect = Rect::from_min_max(pos2(header_rect.max.x - search_w, header_rect.min.y), header_rect.max); + let before = query.clone(); + preference_search_box_at(ui, t, &mut query, search_rect); + if query != before { + ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + } + + ui.add_space(10.0); + + let mut matches_count = 0; + + // Card 1: General & Interface + let kw1 = &[ + "general", + "interface", + "language", + "size", + "tools", + "panel", + "side", + "animation", + "motion", + "reopen", + "session", + "restore", + "reload", + "recent", + "title", + ]; + let title1 = tl!("General & Interface"); + if matches_search(&query, title1, kw1) { + matches_count += 1; + preference_card(ui, t, title1, Some("settings"), |ui| { + card_general(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 2: Appearance & Theme + let kw2 = &["appearance", "theme", "dark", "light", "shadow", "edge", "highlight", "field", "reading", "colors", "colours", "custom", "contrast"]; + let title2 = tl_ctx!("theme", "Appearance"); + if matches_search(&query, title2, kw2) { + matches_count += 1; + preference_card(ui, t, title2, Some("sun"), |ui| { + card_appearance(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 3: Documents and View + let kw3 = + &["documents", "view", "workspace", "mode", "page", "display", "continuous", "scrolling", "gaps", "zoom", "fit", "units", "scroll", "speed"]; + let title3 = tl!("Documents and view"); + if matches_search(&query, title3, kw3) { + matches_count += 1; + preference_card(ui, t, title3, Some("file-text"), |ui| { + card_documents_and_view(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 4: Comment + let kw4 = &["comment", "comments", "identity", "name", "author", "preview", "hover", "highlight"]; + let title4 = tl!("Comment"); + if matches_search(&query, title4, kw4) { + matches_count += 1; + preference_card(ui, t, title4, Some("message-square-text"), |ui| { + card_identity(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 5: Fill & Sign + let kw5 = &["fill", "sign", "flatten", "date", "format", "pattern", "today", "language", "token"]; + let title5 = tl!("Fill & Sign"); + if matches_search(&query, title5, kw5) { + matches_count += 1; + preference_card(ui, t, title5, Some("pen-tool"), |ui| { + card_fill_and_sign(ui, app, t); + }); + ui.add_space(12.0); + } + + // Card 6: JavaScript + let kw6 = &["javascript", "js", "scripts", "sandbox", "security", "acrobat"]; + let title6 = "JavaScript"; + if matches_search(&query, title6, kw6) { + matches_count += 1; + preference_card(ui, t, title6, Some("code-xml"), |ui| { + card_javascript(ui, app, t); + }); + ui.add_space(12.0); + } + + // Empty search state when nothing matches + if matches_count == 0 { + ui.add_space(30.0); + ui.vertical_centered(|ui| { + let (rect, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); + icons::paint(ui, rect, "search", 32.0, t.text_muted); + ui.add_space(8.0); + ui.label(RichText::new(format!("No settings match \"{query}\"")).font(theme::medium(14.0)).color(t.text_muted)); + ui.add_space(10.0); + if widgets::pill_button(ui, tl!("Clear search"), false).clicked() { + ui.data_mut(|d| d.insert_temp(search_id, String::new())); + ui.ctx().request_repaint(); + } + }); + } +} + +/// Content for Card 1: General & Interface +fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.horizontal(|ui| { + let label = ui.label(tl!("Interface language")); + let (selected, picker) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let selected = crate::i18n::Lang::from_code(&app.language) + .map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned()); + let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable)) + .into_iter() + .map(|language| (language, language.picker_name(drawable))) + .collect::>(); + (selected, picker) + }; + let before = app.language.clone(); + craft_ui::ComboBox::from_id_salt("interface-language") + .selected_text(selected) + .width(160.0) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { + ui.close(); + } + for (language, name) in &picker { + if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + if app.language != before { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); + } + + ui.add_space(16.0); + use crate::ui_scale; + let label_size = ui.label(tl!("Interface size")); + let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); + let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); + let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); + let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); + craft_ui::ComboBox::from_id_salt("interface-size") + .selected_text(selected) + .width(130.0) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { + ui.close(); + } + for p in ui_scale::PRESETS { + let factor = f32::from(p) / 100.0; + if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { + app.ui_scale = Some(factor); + ui.close(); + } + } + }) + .response + .labelled_by(label_size.id); + }); + ui.label( + RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")).small().color(t.text_muted), + ); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Tools panel side")); + let right = app.side_panels.tools_on_right(); + let mut side = if right { ToolsSide::Right } else { ToolsSide::Left }; + if segmented_pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { + app.side_panels.tools_side = side; + } + + ui.add_space(16.0); + ui.label(tl!("Navigation rail side")); + let rail_right = app.side_panels.rail_on_right(); + let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; + if segmented_pill_choice_a11y( + ui, + t, + &mut rside, + &[(ToolsSide::Left, tl!("Left"), tl!("Navigation rail on left")), (ToolsSide::Right, tl!("Right"), tl!("Navigation rail on right"))], + ) { + app.side_panels.rail_side = rside; + } + }); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Animation effects")); + segmented_pill_choice( + ui, + t, + &mut app.animation_effects, + &[ + (AnimationEffects::System, tl_ctx!("animation effects", AnimationEffects::System.label())), + (AnimationEffects::On, tl_ctx!("animation effects", AnimationEffects::On.label())), + (AnimationEffects::Off, tl_ctx!("animation effects", AnimationEffects::Off.label())), + ], + ); + }); + ui.add_space(6.0); + + #[cfg(not(target_arch = "wasm32"))] + ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); + #[cfg(not(target_arch = "wasm32"))] + ui.add(widgets::checkbox(&mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"))); + #[cfg(not(target_arch = "wasm32"))] + ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); + ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); + + #[cfg(not(target_arch = "wasm32"))] + { + ui.horizontal(|ui| { + let label = ui.label(tl!("Recent files to keep")); + ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id); + }); + ui.label(RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted)); + } + + if app.window_controls.is_some() { + ui.add(widgets::checkbox(&mut app.system_title_bar, tl!("Use system title bar"))).on_hover_text(tl!( + "Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme." + )); + } +} + +/// Content for Card 2: Appearance & Theme +fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { + ui.ctx().request_repaint(); + } + ui.add_space(4.0); + crate::appearance::preferences_section(ui, app, t); + ui.add_space(8.0); + reading_colours_section(ui, app, t); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DisplayModeChoice { + Single, + TwoUp, + Continuous, +} + +/// Content for Card 3: Documents and View +fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.horizontal(|ui| { + ui.label(tl!("Default workspace mode")); + segmented_pill_choice( + ui, + t, + &mut app.default_mode, + &[ + (crate::Mode::AllTools, tl!("All tools")), + (crate::Mode::Read, tl!("Read")), + (crate::Mode::Edit, tl!("Edit")), + (crate::Mode::Convert, tl!("Convert")), + (crate::Mode::Sign, tl!("E-Sign")), + ], + ); + }); + ui.label( + RichText::new(tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session.")) + .small() + .color(t.text_muted), + ); + ui.add_space(6.0); + + let defaults = &mut app.view_defaults; + ui.horizontal(|ui| { + ui.label(tl!("Default page display")); + let mut display_choice = if defaults.continuous { + DisplayModeChoice::Continuous + } else if defaults.layout == PageLayout::TwoUp { + DisplayModeChoice::TwoUp + } else { + DisplayModeChoice::Single + }; + if segmented_pill_choice( + ui, + t, + &mut display_choice, + &[ + (DisplayModeChoice::Single, tl!("Single page")), + (DisplayModeChoice::TwoUp, tl!("Two-page view")), + (DisplayModeChoice::Continuous, tl!("Continuous scrolling")), + ], + ) { + match display_choice { + DisplayModeChoice::Single => { + defaults.layout = PageLayout::Single; + defaults.continuous = false; + } + DisplayModeChoice::TwoUp => { + defaults.layout = PageLayout::TwoUp; + defaults.continuous = false; + } + DisplayModeChoice::Continuous => { + defaults.layout = PageLayout::Single; + defaults.continuous = true; + } + } + } + }); + ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); + ui.label(RichText::new(tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged.")).small().color(t.text_muted)); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Default zoom")); + segmented_pill_choice( + ui, + t, + &mut defaults.fit, + &[(Fit::Width, tl!("Fit to width")), (Fit::Page, tl!("Zoom to page level")), (Fit::None, tl!("Custom"))], + ); + let mut percent = defaults.zoom * 100.0; + if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() { + (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); + } + }); + ui.label( + RichText::new(tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages.")) + .small() + .color(t.text_muted), + ); + ui.add(widgets::checkbox(&mut app.always_default_view, tl!("Always use my default zoom and page layout"))); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Units")); + segmented_pill_choice( + ui, + t, + &mut app.units, + &[ + (Unit::Inches, Unit::Inches.label()), + (Unit::Millimetres, Unit::Millimetres.label()), + (Unit::Centimetres, Unit::Centimetres.label()), + (Unit::Points, Unit::Points.label()), + ], + ); + }); + ui.label(RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); + segmented_pill_choice( + ui, + t, + &mut app.scroll_speed, + &[ + (ScrollSpeed::Slow, tl_ctx!("scroll speed", ScrollSpeed::Slow.label())), + (ScrollSpeed::Normal, tl_ctx!("scroll speed", ScrollSpeed::Normal.label())), + (ScrollSpeed::Fast, tl_ctx!("scroll speed", ScrollSpeed::Fast.label())), + ], + ); + }); +} + +/// Content for Card 4: Identity +fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, _t: &Tokens) { + ui.horizontal(|ui| { + let label = ui.label(tl!("Name on new comments")); + ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(240.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); + }); + ui.add(widgets::checkbox(&mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"))); +} + +/// Content for Card 5: Fill & Sign +fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); + ui.label(RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted)); + ui.add_space(8.0); + date_format(ui, app, t); +} + +/// Content for Card 6: JavaScript +fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + let mut on = app.session.javascript(); + if ui.add(widgets::checkbox(&mut on, tl!("Enable Acrobat JavaScript"))).changed() { + app.session.set_javascript(on); + } + ui.label( + RichText::new(tl!( + "Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work." + )) + .small() + .color(t.text_muted), + ); +} + +/// Preferences dialog buttons: Cancel and OK on the right. Returns true to close. +pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { + ui.add_space(10.0); + let mut close = false; + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + }); + close +} + +/// Fill & Sign date pattern and date language picker. +fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS}; + ui.label(RichText::new(tl!("Date format")).font(theme::semibold(12.5))); + let sample = |app: &PdfCraftApp, f: &str| format!("{} {f}", app.date_text(Some(f)).unwrap_or_default()); + ui.horizontal(|ui| { + let label = ui.label(tl!("Fill & Sign dates")); + let current = app.session.date_format().to_string(); + let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; + craft_ui::ComboBox::from_id_salt("date-format") + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + for f in DATE_FORMATS { + if ui.selectable_label(current == f, sample(app, f)).clicked() && app.session.set_date_format(f).is_ok() { + app.date_format_draft = None; + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + }); + ui.horizontal(|ui| { + let label = ui.label(tl!("Pattern")); + let mut text = app.date_format_draft.clone().unwrap_or_else(|| app.session.date_format().to_string()); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0).char_limit(MAX_DATE_FORMAT_CHARS)).labelled_by(label.id); + if edit.changed() { + let _ = app.session.set_date_format(&text); + app.date_format_draft = Some(text.clone()); + } + if !edit.has_focus() && pdfcraft_engine::dates::check_date_format(&text).is_ok() { + app.date_format_draft = None; + } + match app.date_text(Some(&text)) { + Ok(today) => ui.label(RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)), + Err(e) => ui.label(RichText::new(e).color(Color32::from_rgb(0xD1, 0x3B, 0x3B))), + }; + }); + let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); + if !bad.is_empty() { + ui.label( + RichText::new(crate::i18n::fmt( + tl!( + "{chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy." + ), + &[("chars", &bad)], + )) + .color(Color32::from_rgb(0xD1, 0x3B, 0x3B)), + ); + } + ui.horizontal(|ui| { + let label = ui.label(tl!("Language")); + let (follow, selected, current, names) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let interface = crate::i18n::current().picker_name(drawable); + let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); + let current = app.session.date_language().map(str::to_string); + let selected = DATE_LANGUAGES + .iter() + .find(|l| current.as_deref() == Some(l.code)) + .map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string()); + let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); + (follow, selected, current, names) + }; + craft_ui::ComboBox::from_id_salt("date-language") + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { + ui.close(); + } + for (l, name) in &names { + if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok() + { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); + }); + ui.label( + RichText::new(crate::i18n::fmt( + tl!("yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is."), + &[("escape", "\\")], + )) + .small() + .color(t.text_muted), + ); +} + +/// Reading colours settings: presets, preview and custom replacement options. +fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + ui.label(RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); + let mode = app.reading_colors.mode_name(); + ui.horizontal_wrapped(|ui| { + for (name, label, colors) in [ + ("off", "Off", pdfcraft_render::ReadingColors::default()), + ("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE), + ("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK), + ("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK), + ("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK), + ] { + let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams { + text: [0, 0, 0], + background: [255, 255, 255], + only_black: false, + line_art: true, + }); + ui.vertical(|ui| { + ui.set_width(100.0); + let sample = RichText::new("Aa").size(20.0).color(Color32::from_rgb(params.text[0], params.text[1], params.text[2])); + let button = egui::Button::new(sample) + .fill(Color32::from_rgb(params.background[0], params.background[1], params.background[2])) + .min_size(vec2(90.0, 40.0)) + .stroke(Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border })); + let response = ui.add(button); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label))); + if response.clicked() { + app.set_reading_colors(colors); + } + ui.label(tl!(label)); + }); + } + }); + ui.add_space(4.0); + if let Some(params) = app.reading_colors.params { + let text = Color32::from_rgb(params.text[0], params.text[1], params.text[2]); + let background = Color32::from_rgb(params.background[0], params.background[1], params.background[2]); + egui::Frame::new().fill(background).inner_margin(12.0).corner_radius(CornerRadius::same(6)).show(ui, |ui| { + ui.set_min_width((ui.available_width() - 24.0).max(0.0)); + ui.label(RichText::new(tl!("Reading colours")).size(16.0).color(text)); + ui.label(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text)); + }); + } else { + ui.label(tl!("Original")); + } + egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| { + let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE }; + let mut changed = false; + if let Some(params) = colors.params.as_mut() { + ui.horizontal(|ui| { + let text = ui.label(tl!("Text")); + changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); + let background = ui.label(tl!("Page background")); + changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); + }); + changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed(); + changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed(); + } + if changed { + app.set_reading_colors(colors); + } + }); + ui.label(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); +} diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 70c3f775e9..0a9b07764c 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -316,445 +316,10 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok close } -/// The height the Preferences dialog keeps below its scrolling settings for the OK row: the -/// button, and the space above it. -pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; - -/// The least height the scrolling settings are given. The dialog around them takes about 80 more, -/// so this still fits the smallest window (520 pt) at the largest interface size (300%, which -/// leaves 173 pt): a taller floor would push OK off the screen again. -pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; - -/// Whether the interface can draw a language picker's native name. On the web the browser's glyph -/// rasterizer draws scripts no embedded face has, so the native names stay; on native, ask the -/// fonts this build installed (including an installed fallback face, unless system fonts are off). -fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool { - #[cfg(target_arch = "wasm32")] - { - let _ = (ui, text); - true - } - #[cfg(not(target_arch = "wasm32"))] - { - ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text)) - } -} - -/// The date picker's displayed name. Its languages are the interface languages, so it uses the -/// same drawable-name fallback: a native name whose script this build has no face for would -/// otherwise abort under egui 0.37's `MissingGlyphPolicy::Panic` (for example Hindi without a -/// Devanagari face and installed system fonts turned off). -fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str { - match crate::i18n::Lang::from_code(l.code) { - Some(language) => language.picker_name(drawable), - None => l.name, - } -} - -/// Preferences: interface language and size, appearance, identity and JavaScript. Drawn in a scroll -/// area; [`preferences_buttons`] goes below it, outside the scroll, so OK is always in reach. -pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); - ui.label(egui::RichText::new(tl!("Appearance")).font(theme::semibold(13.0))); - if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { - ui.ctx().request_repaint(); - } - ui.horizontal(|ui| { - let label = ui.label(tl!("Interface language")); - // A native name is only drawn when these fonts can draw it; otherwise the English name - // keeps the list readable instead of aborting on a missing glyph (for example Hindi - // without a Devanagari face, and installed system fonts turned off). - let (selected, picker) = { - let drawable = |text: &str| picker_text_is_drawable(ui, text); - let selected = crate::i18n::Lang::from_code(&app.language) - .map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned()); - let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable)) - .into_iter() - .map(|language| (language, language.picker_name(drawable))) - .collect::>(); - (selected, picker) - }; - let before = app.language.clone(); - craft_ui::ComboBox::from_id_salt("interface-language") - .selected_text(selected) - .show_ui(ui, |ui| { - if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { - ui.close(); - } - for (language, name) in &picker { - if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() { - ui.close(); - } - } - }) - .response - .labelled_by(label.id); - // Relabel the rest of this dialog in the new language right away, not next frame. - if app.language != before { - crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); - } - }); - ui.horizontal(|ui| { - use crate::ui_scale; - let label = ui.label(tl!("Interface size")); - // Auto shows the size it draws at: the display's scale times the system's text scale. - let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); - let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); - let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); - let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); - craft_ui::ComboBox::from_id_salt("interface-size") - .selected_text(selected) - .show_ui(ui, |ui| { - if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { - ui.close(); - } - for p in ui_scale::PRESETS { - let factor = f32::from(p) / 100.0; - if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { - app.ui_scale = Some(factor); - ui.close(); - } - } - }) - .response - .labelled_by(label.id); - }); - ui.label( - egui::RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")) - .small() - .color(t.text_muted), - ); - ui.horizontal(|ui| { - ui.label(tl!("Tools panel side")); - let right = app.side_panels.tools_on_right(); - if ui.selectable_label(!right, tl!("Left")).clicked() { - app.side_panels.tools_side = crate::side_panels::ToolsSide::Left; - } - if ui.selectable_label(right, tl!("Right")).clicked() { - app.side_panels.tools_side = crate::side_panels::ToolsSide::Right; - } - }); - ui.horizontal(|ui| { - ui.label(tl!("Navigation rail side")); - let right = app.side_panels.rail_on_right(); - let left_btn = ui.selectable_label(!right, tl!("Left")); - left_btn.widget_info(|| { - egui::WidgetInfo::selected(egui::Role::Button, true, !right, format!("{}: {}", tl!("Navigation rail side"), tl!("Left"))) - }); - if left_btn.clicked() { - app.side_panels.rail_side = crate::side_panels::ToolsSide::Left; - } - let right_btn = ui.selectable_label(right, tl!("Right")); - right_btn.widget_info(|| { - egui::WidgetInfo::selected(egui::Role::Button, true, right, format!("{}: {}", tl!("Navigation rail side"), tl!("Right"))) - }); - if right_btn.clicked() { - app.side_panels.rail_side = crate::side_panels::ToolsSide::Right; - } - }); - ui.add_space(8.0); - crate::appearance::preferences_section(ui, app, t); - ui.add_space(8.0); - reading_colours_section(ui, app, t); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0))); - ui.horizontal(|ui| { - ui.label(tl!("Default workspace mode")); - for (mode, label) in [ - (crate::Mode::AllTools, "All tools"), - (crate::Mode::Read, "Read"), - (crate::Mode::Edit, "Edit"), - (crate::Mode::Convert, "Convert"), - (crate::Mode::Sign, "E-Sign"), - ] { - widgets::radio_value(ui, &mut app.default_mode, mode, tl!(label)); - } - }); - ui.label( - egui::RichText::new(tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session.")) - .small() - .color(t.text_muted), - ); - let defaults = &mut app.view_defaults; - ui.horizontal(|ui| { - ui.label(tl!("Default page display")); - for l in crate::canvas::PageLayout::ORDER { - widgets::radio_value(ui, &mut defaults.layout, l, tl!(l.label())); - } - ui.add(widgets::checkbox(&mut defaults.continuous, tl!("Continuous scrolling"))); - }); - ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); - ui.label(egui::RichText::new(tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged.")).small().color(t.text_muted)); - ui.horizontal(|ui| { - use crate::canvas::Fit; - ui.label(tl!("Default zoom")); - widgets::radio_value(ui, &mut defaults.fit, Fit::Width, tl!("Fit to width")); - widgets::radio_value(ui, &mut defaults.fit, Fit::Page, tl!("Zoom to page level")); - widgets::radio_value(ui, &mut defaults.fit, Fit::None, tl!("Custom")); - // Editing the percentage selects Custom. - let mut percent = defaults.zoom * 100.0; - if ui.add(egui::DragValue::new(&mut percent).range(8.0..=6400.0).max_decimals(0).suffix("%")).changed() { - (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); - } - }); - ui.label( - egui::RichText::new(tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages.")) - .small() - .color(t.text_muted), - ); - ui.add(widgets::checkbox(&mut app.always_default_view, tl!("Always use my default zoom and page layout"))); - ui.horizontal(|ui| { - ui.label(tl!("Units")); - for u in crate::units::Unit::ALL { - widgets::radio_value(ui, &mut app.units, u, u.label()); - } - }); - ui.label(egui::RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); - // The web build has no file paths to list. - #[cfg(not(target_arch = "wasm32"))] - { - ui.horizontal(|ui| { - let label = ui.label(tl!("Recent files to keep")); - ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id); - }); - ui.label(egui::RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted)); - } - // The web build has no file paths to reopen. - #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); - // Acrobat's "Restore last view settings when reopening documents" (#1767). - #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"))); - // The web build has no files on disk to watch. - #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); - // The notice's "Don't show again" turns this off (#4035); the restrictions stay either way. - ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); - ui.horizontal(|ui| { - ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); - for speed in crate::scroll_speed::ScrollSpeed::ALL { - widgets::radio_value(ui, &mut app.scroll_speed, speed, tl_ctx!("scroll speed", speed.label())); - } - }); - // Whether anything animates, the wheel's scrolling and zooming included; left alone it - // follows the system's animation setting. - ui.horizontal(|ui| { - ui.label(tl!("Animation effects")); - for choice in crate::motion::AnimationEffects::ALL { - widgets::radio_value(ui, &mut app.animation_effects, choice, tl_ctx!("animation effects", choice.label())); - } - }); - // Only where the app draws the title bar (GNOME on Wayland); elsewhere the desktop draws it. - if app.window_controls.is_some() { - ui.add(widgets::checkbox(&mut app.system_title_bar, tl!("Use system title bar"))) - .on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme.")); - } - ui.add_space(8.0); - // Identity: the author of new comments (Acrobat: Preferences ▸ Identity). - ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0))); - ui.horizontal(|ui| { - let label = ui.label(tl!("Name on new comments")); - ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(220.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); - }); - ui.add(widgets::checkbox(&mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"))); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); - ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); - ui.label( - egui::RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted), - ); - ui.add_space(8.0); - date_format(ui, app, t); - ui.add_space(8.0); - ui.label(egui::RichText::new("JavaScript").font(theme::semibold(13.0))); - egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { - ui.set_width(ui.available_width()); - let mut on = app.session.javascript(); - if ui.add(widgets::checkbox(&mut on, tl!("Enable Acrobat JavaScript"))).changed() { - app.session.set_javascript(on); - } - ui.label( - egui::RichText::new(tl!("Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work.")) - .small() - .color(t.text_muted), - ); - }); -} - -/// The Preferences dialog's OK row. Returns `true` to close. -pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { - ui.add_space(10.0); - buttons(ui, tl!("OK"), &[]).is_some() -} - -/// Preferences ▸ Date format: a preset, or any pattern typed in, for Fill & Sign dates, and the -/// language of its month and weekday names. A valid pattern applies as it's typed; an invalid -/// one stays in the box with the reason, and the last valid one stays in use. -fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS}; - ui.label(egui::RichText::new(tl!("Date format")).font(theme::semibold(13.0))); - let sample = |app: &PdfCraftApp, f: &str| format!("{} {f}", app.date_text(Some(f)).unwrap_or_default()); - ui.horizontal(|ui| { - let label = ui.label(tl!("Fill & Sign dates")); - let current = app.session.date_format().to_string(); - let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; - craft_ui::ComboBox::from_id_salt("date-format") - .selected_text(selected) - .width(240.0) - .show_ui(ui, |ui| { - for f in DATE_FORMATS { - if ui.selectable_label(current == f, sample(app, f)).clicked() && app.session.set_date_format(f).is_ok() { - app.date_format_draft = None; - ui.close(); - } - } - }) - .response - .labelled_by(label.id); - }); - ui.horizontal(|ui| { - let label = ui.label(tl!("Pattern")); - let mut text = app.date_format_draft.clone().unwrap_or_else(|| app.session.date_format().to_string()); - let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0).char_limit(MAX_DATE_FORMAT_CHARS)).labelled_by(label.id); - // The box keeps the text as typed (a trailing space included) while it has focus; the - // setting takes the trimmed pattern whenever it's valid. - if edit.changed() { - let _ = app.session.set_date_format(&text); - app.date_format_draft = Some(text.clone()); - } - if !edit.has_focus() && pdfcraft_engine::dates::check_date_format(&text).is_ok() { - app.date_format_draft = None; - } - match app.date_text(Some(&text)) { - Ok(today) => ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)), - // The engine's reason, in English. - Err(e) => ui.label(egui::RichText::new(e).color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B))), - }; - }); - // Fill & Sign refuses a date it would save as "?"; say so before anyone tries. - let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); - if !bad.is_empty() { - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!( - "{chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy." - ), - &[("chars", &bad)], - )) - .color(egui::Color32::from_rgb(0xD1, 0x3B, 0x3B)), - ); - } - ui.horizontal(|ui| { - let label = ui.label(tl!("Language")); - // Build every label before the combo box mutably borrows `ui`, with the same drawable - // fallback the interface language picker uses. - let (follow, selected, current, names) = { - let drawable = |text: &str| picker_text_is_drawable(ui, text); - let interface = crate::i18n::current().picker_name(drawable); - let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); - let current = app.session.date_language().map(str::to_string); - let selected = DATE_LANGUAGES - .iter() - .find(|l| current.as_deref() == Some(l.code)) - .map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string()); - let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); - (follow, selected, current, names) - }; - craft_ui::ComboBox::from_id_salt("date-language") - .selected_text(selected) - .width(240.0) - .show_ui(ui, |ui| { - if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { - ui.close(); - } - for (l, name) in &names { - if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok() - { - ui.close(); - } - } - }) - .response - .labelled_by(label.id); - }); - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!("yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is."), - &[("escape", "\\")], - )) - .small() - .color(t.text_muted), - ); -} - -fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - // Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are - // remapped while rendering, and the page clears to the background colour. The preference - // is app-wide: every open document follows it, and documents open with it. - ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); - let mode = app.reading_colors.mode_name(); - ui.horizontal_wrapped(|ui| { - for (name, label, colors) in [ - ("off", "Off", pdfcraft_render::ReadingColors::default()), - ("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE), - ("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK), - ("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK), - ("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK), - ] { - let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams { - text: [0, 0, 0], - background: [255, 255, 255], - only_black: false, - line_art: true, - }); - ui.vertical(|ui| { - ui.set_width(104.0); - let sample = egui::RichText::new("Aa").size(22.0).color(egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2])); - let button = egui::Button::new(sample) - .fill(egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2])) - .min_size(egui::vec2(94.0, 44.0)) - .stroke(egui::Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border })); - let response = ui.add(button); - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label))); - if response.clicked() { - app.set_reading_colors(colors); - } - ui.label(tl!(label)); - }); - } - }); - ui.add_space(8.0); - if let Some(params) = app.reading_colors.params { - let text = egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]); - let background = egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]); - egui::Frame::new().fill(background).inner_margin(16.0).show(ui, |ui| { - ui.set_min_width((ui.available_width() - 32.0).max(0.0)); - ui.label(egui::RichText::new(tl!("Reading colours")).size(18.0).color(text)); - ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text)); - }); - } else { - ui.label(tl!("Original")); - } - egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| { - let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE }; - let mut changed = false; - if let Some(params) = colors.params.as_mut() { - ui.horizontal(|ui| { - let text = ui.label(tl!("Text")); - changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); - let background = ui.label(tl!("Page background")); - changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); - }); - changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed(); - changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed(); - } - if changed { - app.set_reading_colors(colors); - } - }); - ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); - ui.add_space(8.0); -} +#[expect(unused_imports, reason = "re-exported for tests and backward compatibility")] +pub(crate) use crate::dialogs::preferences::{ + PREFERENCES_BUTTON_ROW, PREFERENCES_MIN_SCROLL, date_language_picker_name, preferences_body, preferences_buttons, +}; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 1ebe06b7a0..a20d786965 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -102,3 +102,98 @@ fn a_very_tall_window_does_not_leave_a_blank_gap_above_ok() { assert!(on_screen(ok, 2600.0) && on_screen(last, 2600.0), "{last:?} {ok:?}"); assert!(ok.min.y - last.max.y < 120.0, "a gap of {} pt between the last setting and OK", ok.min.y - last.max.y); } + +#[test] +fn segmented_pill_controls_switch_modes_and_layouts() { + let mut h = harness(900.0); + // Switch workspace mode via segmented pill + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Convert").scroll_to_me(); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Convert").click(); + h.run_steps(2); + assert_eq!(h.state().default_mode, pdfcraft_ui_egui::Mode::Convert); + + // Switch page layout via segmented pill + h.get_by_label("Two-page view").scroll_to_me(); + h.run_steps(3); + h.get_by_label("Two-page view").click(); + h.run_steps(2); + assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + + // Switch continuous scrolling pill + let continuous_before = h.state().view_defaults.continuous; + h.get_by_label("Continuous scrolling").click(); + h.run_steps(2); + assert_eq!(h.state().view_defaults.continuous, !continuous_before); + + // Switch to single page mode + h.get_by_label("Single page").click(); + h.run_steps(2); + assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(!h.state().view_defaults.continuous); + + // Switch tools panel side via segmented pill + h.get_by_label("Left").scroll_to_me(); + h.run_steps(3); + h.get_by_label("Left").click(); + h.run_steps(2); + assert_eq!(h.state().side_panels.tools_side, pdfcraft_ui_egui::side_panels::ToolsSide::Left); + + // Switch measurement units via segmented pill + h.get_by_label("pt").scroll_to_me(); + h.run_steps(3); + h.get_by_label("pt").click(); + h.run_steps(2); + assert_eq!(h.state().units, pdfcraft_ui_egui::units::Unit::Points); +} + +#[test] +fn cancel_button_closes_preferences_dialog() { + let mut h = harness(900.0); + assert!(h.state().dialog.is_some()); + h.get_by_label("Cancel").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); +} + +#[test] +fn search_settings_filters_cards_in_real_time() { + let mut h = harness(900.0); + // Initially all card titles exist + assert!(h.query_by_label("General & Interface").is_some()); + assert!(h.query_by_label("Comment").is_some()); + assert!(h.query_by_label("JavaScript").is_some()); + + // Type in search box to filter to JavaScript card + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click(); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("javascript"); + h.run_steps(3); + + // JavaScript card is visible, General & Interface is filtered out + assert!(h.query_by_label("JavaScript").is_some()); + assert!(h.query_by_label("General & Interface").is_none()); + + // Clearing search shows everything again + h.get_by_label("Clear search").click(); + h.run_steps(3); + assert!(h.query_by_label("General & Interface").is_some()); + assert!(h.query_by_label("Comment").is_some()); + assert!(h.query_by_label("JavaScript").is_some()); +} + +#[test] +fn preferences_dialog_scrollbar_spacing_and_controls() { + let h = harness(600.0); + let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(); + let search = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").rect(); + let ok = h.get_by_label("OK").rect(); + let cancel = h.get_by_label("Cancel").rect(); + assert!(cancel.min.x < ok.min.x); + assert!(on_screen(ok, 600.0)); + assert!(on_screen(cancel, 600.0)); + // Header title and search box are vertically aligned + assert!((title.center().y - search.center().y).abs() <= 2.0); + // Search box right edge is aligned within the dialog + assert!(search.max.x <= ok.max.x); +} diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index a02c5c05ca..1f2709cd56 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -242,11 +242,11 @@ fn preferences_theme_cards_and_use_system_setting_stay_in_sync() { assert_theme(&h, ThemePreference::System, ThemeKind::Dark); assert_eq!(mode_shown(&h), "Use system setting"); - // The OS is dark: the Dark card's radio row picks Dark, and the combo box says so. + // The OS is dark: the Dark card picks Dark, and the combo box says so. // The cards sit below the fold of the Preferences scroll area at this window size. - h.get_by_label("Dark gray").scroll_to_me(); + h.get_by_label("Dark theme").scroll_to_me(); h.run_steps(3); - h.get_by_label("Dark gray").click(); + h.get_by_label("Dark theme").click(); h.run_steps(3); assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark); assert_eq!(mode_shown(&h), "Dark"); From f035218cb47e414ccc649c59229315bab8d89f0b Mon Sep 17 00:00:00 2001 From: Elarionus <29511515+Elarionus@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:49:10 -0800 Subject: [PATCH 127/562] fix(ui): don't check recent files on the UI thread at startup (#5682) (#5692) * fix(ui): don't check recent files on the UI thread at startup (#5682) Restoring settings dropped recent files whose files no longer exist by asking the file system about each one on the UI thread, before the first frame. On a network mount that has stopped answering (davfs2 in the report) that call blocks in the kernel, so the window never appears. Settings now keep every recent entry when they load. Once the window is up, a worker checks the restored paths and each frame drops the ones it has found gone. An entry it hasn't reached, or can't tell about (the mount answers with an error), stays listed. A worker stuck on a dead mount stays stuck, but nothing waits for it. This follows the pinned folders' worker. Fixes #5682 Co-Authored-By: Claude Opus 5.5 * Recent files: a file opened while the check is pending stays listed The worker's answer that a restored path is gone is out of date once the user opens that path; keep the new entry (CodeRabbit on #5692). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/files.rs | 1 + crates/ui-egui/src/app/prefs.rs | 6 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/persist.rs | 1 + crates/ui-egui/src/persist/recent_check.rs | 125 +++++++++++++++++++++ crates/ui-egui/tests/it/recent_files.rs | 33 ++++++ 7 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/persist/recent_check.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43d0066e63..c326b479fe 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -144,6 +144,7 @@ impl PdfCraftApp { #[cfg(all(test, not(target_arch = "wasm32")))] open_prepare_gate: None, pinned: Default::default(), + recent_check: Default::default(), pinned_tools: Vec::new(), pin_tool_search: String::new(), toast: None, @@ -452,6 +453,7 @@ impl eframe::App for PdfCraftApp { } self.poll_pending_opens(); } + self.check_restored_recent(); // Chinese wants its own faces before the Japanese ones (one baseline per line). let cjk = theme::CjkPreference::for_language(lang.code()); if !self.styled { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 29e6978461..9991047077 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -332,6 +332,7 @@ impl PdfCraftApp { #[cfg(target_os = "macos")] macos_association::repair(std::path::Path::new(&p)); self.recent.retain(|r| r.path != p); + self.recent_check.opened(&p); self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size }); self.recent.truncate(self.recent_limit); } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 7bd8943774..7eddee0af0 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -182,9 +182,9 @@ impl PdfCraftApp { self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end()); } if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) { - // Only keep entries whose files still exist. - #[cfg(not(target_arch = "wasm32"))] - let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); + // Entries whose files are gone leave once the window is up: asking here could block + // startup on a mount that has stopped answering (#5682). + self.recent_check.restored(r.iter().map(|f| f.path.clone()).collect()); self.recent = r; } // Nonsense keeps the limit it had; settings from before the limit have none. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe0138f27f..c25dae98e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -392,6 +392,8 @@ pub struct PdfCraftApp { open_prepare_gate: Option>, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, + /// The recent files restored from settings, checked off the UI thread (#5682). + recent_check: persist::recent_check::RecentCheck, /// Tools pinned to Home, in user-defined order. pub pinned_tools: Vec, /// Search query for the Pin a tool selection dialog. diff --git a/crates/ui-egui/src/persist.rs b/crates/ui-egui/src/persist.rs index 32a8ec048d..20ccecdd3f 100644 --- a/crates/ui-egui/src/persist.rs +++ b/crates/ui-egui/src/persist.rs @@ -1,6 +1,7 @@ pub mod last_session; pub mod last_view; pub mod paper; +pub(crate) mod recent_check; pub(super) mod recovery; pub mod units; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/persist/recent_check.rs b/crates/ui-egui/src/persist/recent_check.rs new file mode 100644 index 0000000000..8c7db75f8b --- /dev/null +++ b/crates/ui-egui/src/persist/recent_check.rs @@ -0,0 +1,125 @@ +//! Recent files whose files are gone leave the list, without the window waiting to find out +//! (#5682). +//! +//! A recent file can be on a network or cloud mount that has stopped answering, where even +//! asking whether the file exists blocks in the kernel, and no timeout can cancel that. So +//! settings keep every recent entry when they load, and a worker asks about them once the window +//! is up; each frame only takes the answers it has so far. An entry the worker hasn't reached +//! yet, or can't tell about (the mount answers with an error), stays listed and can be opened or +//! removed by hand. A worker stuck on a dead mount stays stuck, but nothing waits for it. + +use std::sync::{Arc, Mutex, PoisonError}; + +use crate::PdfCraftApp; + +/// The recent files restored from settings that haven't been checked yet, and the check under way. +#[derive(Debug, Default)] +pub struct RecentCheck { + /// Paths restored from settings, checked on the next frame. + unchecked: Vec, + /// The paths the worker has found gone so far; it adds to them as it goes. + gone: Option>>>, + /// Paths opened while a check was pending: the file is there now, whatever the worker found. + opened: Vec, +} + +impl RecentCheck { + /// Settings just restored these recent files: check them once the window is up. + pub(crate) fn restored(&mut self, paths: Vec) { + self.unchecked = paths; + } + + /// `path` was just opened: an answer the worker gives about it later is out of date. + pub(crate) fn opened(&mut self, path: &str) { + if self.gone.is_some() || !self.unchecked.is_empty() { + self.opened.push(path.to_string()); + } + } +} + +/// Whether `path` is known to be gone. Any other answer, an error included, keeps the entry. +fn gone(path: &str) -> bool { + matches!(std::fs::exists(path), Ok(false)) +} + +impl PdfCraftApp { + /// Start checking the recent files restored from settings, and drop the ones found gone so + /// far (called each frame). The check runs on a worker thread, inline in tests + /// ([`PdfCraftApp::run_inline`]). + pub(crate) fn check_restored_recent(&mut self) { + let paths = std::mem::take(&mut self.recent_check.unchecked); + // The web build's recent files aren't paths on a file system. + if !paths.is_empty() && !cfg!(target_arch = "wasm32") { + let slot = Arc::new(Mutex::new(Vec::new())); + let out = slot.clone(); + let ctx = self.ctx.clone(); + let work = move || { + for path in paths { + if gone(&path) { + out.lock().unwrap_or_else(PoisonError::into_inner).push(path); + if let Some(ctx) = &ctx { + ctx.request_repaint(); + } + } + } + }; + if self.run_inline { + work(); + } else if std::thread::Builder::new().name("pdfcraft-recent".into()).spawn(work).is_err() { + // No worker: the entries stay listed, as if the check hadn't reached them. + return; + } + self.recent_check.gone = Some(slot); + } + let Some(slot) = &self.recent_check.gone else { return }; + // Asked before taking, so a path the worker adds after this is taken on a later frame. + let finished = Arc::strong_count(slot) == 1; + let gone = std::mem::take(&mut *slot.lock().unwrap_or_else(PoisonError::into_inner)); + if !gone.is_empty() { + // A file opened since the check began is there, whatever the worker found earlier. + let opened = &self.recent_check.opened; + self.recent.retain(|r| !gone.contains(&r.path) || opened.contains(&r.path)); + } + // The worker has finished (or panicked) and everything it found is taken. + if finished { + self.recent_check.gone = None; + self.recent_check.opened.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file opened after the worker found it gone, but before that answer was taken, stays + /// listed (CodeRabbit on #5692). + #[test] + fn a_file_opened_while_the_check_is_pending_stays() { + let mut app = PdfCraftApp::new(); + app.recent = vec![crate::state::RecentFile { name: "a.pdf".into(), path: "/gone/a.pdf".into(), pages: 1, size: 0 }]; + // The worker's answer is waiting: it found the file gone. + app.recent_check.gone = Some(Arc::new(Mutex::new(vec!["/gone/a.pdf".to_string()]))); + app.recent_check.opened("/gone/a.pdf"); + app.check_restored_recent(); + assert_eq!(app.recent.len(), 1, "the reopened file stays"); + assert!(app.recent_check.gone.is_none() && app.recent_check.opened.is_empty()); + // Without the open, the answer removes it. + app.recent_check.gone = Some(Arc::new(Mutex::new(vec!["/gone/a.pdf".to_string()]))); + app.check_restored_recent(); + assert!(app.recent.is_empty()); + } + + #[test] + fn only_a_path_known_to_be_missing_is_gone() { + let dir = std::env::temp_dir().join(format!("pdfcraft-recent-check-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let here = dir.join("here.pdf"); + std::fs::write(&here, b"%PDF-1.7").unwrap(); + assert!(!gone(&here.to_string_lossy())); + assert!(gone(&dir.join("missing.pdf").to_string_lossy())); + // A path that can't be asked about (here: one with a NUL) is not known to be gone. + assert!(!gone("bad\0path.pdf")); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/crates/ui-egui/tests/it/recent_files.rs b/crates/ui-egui/tests/it/recent_files.rs index b4eccfb10e..e3f7721d2f 100644 --- a/crates/ui-egui/tests/it/recent_files.rs +++ b/crates/ui-egui/tests/it/recent_files.rs @@ -221,6 +221,39 @@ fn the_limit_is_saved_and_restored_and_cuts_a_longer_list() { std::fs::remove_dir_all(&dir).unwrap(); } +/// #5682: restoring settings never asks the file system about recent files, which can block on a +/// mount that has stopped answering. A gone file leaves the list once frames run, and a file that +/// is there stays. +#[test] +fn restoring_settings_does_not_check_recent_files_and_a_frame_drops_the_gone_ones() { + let dir = std::env::temp_dir().join(format!("pdfcraft-recent-restore-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let here = dir.join("here.pdf"); + std::fs::write(&here, PDF).unwrap(); + let here = here.to_string_lossy().into_owned(); + let gone = dir.join("gone.pdf").to_string_lossy().into_owned(); + let entries: Vec = + [&here, &gone].iter().map(|p| RecentFile { name: "x.pdf".into(), path: (*p).clone(), pages: 1, size: 0 }).collect(); + let json = serde_json::json!({ "recent": entries }).to_string(); + + let mut restored = PdfCraftApp::new(); + restored.restore(&json); + let paths: Vec<&str> = restored.recent.iter().map(|r| r.path.as_str()).collect(); + assert_eq!(paths, [here.as_str(), gone.as_str()], "restoring keeps every entry without asking whether its file exists"); + + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.restore(&json); + app + }); + h.run_steps(2); + let paths: Vec<&str> = h.state().recent.iter().map(|r| r.path.as_str()).collect(); + assert_eq!(paths, [here.as_str()], "the gone file leaves once the window is up"); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn a_settings_file_can_not_ask_for_a_limit_that_is_not_one() { for (saved, want) in [ From 8faebd0d96eb158ade0eea4c6825fac6fc682319 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 05:49:14 +0100 Subject: [PATCH 128/562] Fix Matrix::invert rejecting valid matrices with a small scale (#5703) * Fix Matrix::invert rejecting valid matrices with a small scale Found while testing crates/content/src/lib.rs:732-735 (Matrix::invert) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 * Matrix::invert: a matrix whose inverse overflows has none A subnormal determinant passes the relative check, and dividing by it overflows (CodeRabbit on #5703). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- crates/content/src/lib.rs | 7 +++++-- crates/content/src/tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c52916d332..4957f86861 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -730,11 +730,14 @@ impl Matrix { pub fn invert(&self) -> Option { let [a, b, c, d, e, f] = self.0; let det = a * d - b * c; - if det.abs() < 1e-12 || !det.is_finite() { + if det == 0.0 || !det.is_finite() || det.abs() <= 1e-12 * ((a * d).abs() + (b * c).abs()) { return None; } let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); - Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)])) + let inverse = [ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]; + // A determinant that underflows toward zero can still pass the relative check above, + // and dividing by it overflows: such a matrix has no usable inverse. + inverse.iter().all(|v| v.is_finite()).then_some(Matrix(inverse)) } /// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index c5c7a3aaac..9ac9297cd2 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -552,3 +552,17 @@ fn inline_images_end_where_the_renderer_ends_them() { assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); } } + +#[test] +fn invert_accepts_small_scale_and_rejects_singular() { + let m = Matrix([1e-7, 0., 0., 1e-7, 0., 0.]); + let inv = m.invert().expect("a uniform 1e-7 scale is invertible"); + let id = m.then(&inv).0; + for (got, want) in id.iter().zip(Matrix::IDENTITY.0) { + assert!((got - want).abs() < 1e-9, "{id:?}"); + } + assert!(Matrix([1., 2., 2., 4., 0., 0.]).invert().is_none()); + assert!(Matrix([f64::NAN, 0., 0., 1., 0., 0.]).invert().is_none()); + // A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703). + assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none()); +} From d3384340a628bd728389eba71371fa6f7c9b0b4e Mon Sep 17 00:00:00 2001 From: AdrianTM Date: Sun, 1 Nov 2026 23:49:18 -0500 Subject: [PATCH 129/562] M7: make page-mark replacement transactional (#5657) (#5704) * M7: make page-mark replacement transactional (#5657) * ROADMAP: drop the header line (the log entry stays) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 3 + crates/edit/src/apply.rs | 32 +++- crates/edit/src/lib.rs | 5 + crates/edit/src/mark_transaction_tests.rs | 211 ++++++++++++++++++++++ crates/edit/src/place.rs | 27 ++- crates/edit/src/tests.rs | 3 + docs/roadmap.md | 5 +- parity/acrobat-features.toml | 12 +- 8 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 crates/edit/src/mark_transaction_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6e7140b909..a39d6f8b6c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. + - **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another. - **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged. @@ -743,6 +745,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index ff570d5483..0d0213904b 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -7,9 +7,25 @@ use super::place::{ }; use super::{Document, EditError, Object, added, backdrop, helvetica_width}; +/// Commit one complete mark operation only after every selected page succeeds. The snapshot +/// shares original bytes, cross-references, caches and unchanged objects; only the overlay table +/// and document metadata are copied. Dropping a failed stage also drops removed marks, changed +/// page/resources/backdrop dictionaries and new objects, including the allocation cursor. +fn apply_mark_transaction(doc: &mut Document, apply: impl FnOnce(&mut Document) -> Result<(), EditError>) -> Result<(), EditError> { + let mut staged = doc.clone(); + apply(&mut staged)?; + *doc = staged; + Ok(()) +} + /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on -/// those pages are removed first (Acrobat's Replace Existing). +/// those pages are removed first (Acrobat's Replace Existing). The whole call is atomic: +/// on error, the document (including earlier selected pages) is unchanged. pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_header_footer_in_place(staged, pages, hf, replace, cx)) +} + +fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; if hf.text.iter().all(|t| t.trim().is_empty()) { @@ -121,8 +137,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, Ok(()) } -/// Add a text watermark to `pages`. +/// Add a text or picture watermark to `pages`. All selected pages, including mark replacement, +/// form one transaction; on error, the document is unchanged. pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_watermark_in_place(staged, pages, wm, replace)) +} + +fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; let text = wm.text.trim(); @@ -201,8 +222,13 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac /// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a /// white backdrop a page paints itself is replaced first, so the background shows (see -/// [`backdrop`]); a picture backdrop (a scan) still hides it. +/// [`backdrop`]); a picture backdrop (a scan) still hides it. All selected pages, including +/// replacement and backdrop edits, form one transaction; on error, the document is unchanged. pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { + apply_mark_transaction(doc, |staged| add_background_in_place(staged, pages, bg, replace)) +} + +fn add_background_in_place(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; if replace { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index aa11617c14..44a6730acc 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -1,6 +1,11 @@ //! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer, //! Watermark, Background and Bates numbering (execution plan M7.6). //! +//! **Atomic page-mark operations.** Adding or replacing headers/footers, watermarks and backgrounds +//! stages the whole call on a document snapshot and commits only after every selected page succeeds. +//! An error preserves existing marks, content, resources, pending edits and allocated objects. The +//! snapshot shares original PDF bytes and unchanged objects; it copies the overlay table and metadata. +//! //! **How marks are stored.** Each mark is one content stream appended to (or, for content behind //! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in //! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so diff --git a/crates/edit/src/mark_transaction_tests.rs b/crates/edit/src/mark_transaction_tests.rs new file mode 100644 index 0000000000..2623da88ba --- /dev/null +++ b/crates/edit/src/mark_transaction_tests.rs @@ -0,0 +1,211 @@ +//! The public mark APIs either commit the complete operation or leave the document untouched. +use super::{cx, fixture, reopen, streams}; +use crate::place::MARK_PLACEMENT_FAILURE; +use crate::*; +use pdfcraft_cos::{ObjRef, SaveOptions, write_incremental}; + +const SELECTED: &[usize] = &[0, 1, 2]; + +struct PlacementFailure; + +impl PlacementFailure { + fn after_two_pages(kind: MarkKind) -> Self { + MARK_PLACEMENT_FAILURE.with(|failure| { + assert!(failure.get().is_none()); + failure.set(Some((kind, 2, 0))); + }); + Self + } + + fn completed(&self) -> usize { + MARK_PLACEMENT_FAILURE.with(|failure| failure.get().unwrap().2) + } +} + +impl Drop for PlacementFailure { + fn drop(&mut self) { + MARK_PLACEMENT_FAILURE.with(|failure| failure.set(None)); + } +} + +/// Four pages share an original stream and resources, both inherited and directly referenced. +/// All have all three existing marks, including a picture background with a replaced white fill. +fn marked_document() -> (Document, MarkSource) { + let mut doc = fixture(); + let parent = ObjRef::new(2, 0); + let extra = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(parent)), + (b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0))), + ]))); + doc.update_dict(parent, |d| { + d.set(b"Kids".to_vec(), Object::Array([3, 4, 5].map(|n| Object::Ref(ObjRef::new(n, 0))).into_iter().chain([Object::Ref(extra)]).collect())); + d.set(b"Count".to_vec(), Object::Int(4)); + }) + .unwrap(); + doc.set(ObjRef::new(7, 0), Object::Stream(Stream::from_raw(Dict::new(), b"1 g 0 0 600 800 re f 0 g BT /F1 9 Tf (Original page) Tj ET".to_vec()))); + let picture = |doc: &mut Document, gray| { + let image = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Image")), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::name("DeviceGray")), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ]), + vec![gray], + ))); + MarkSource { xobject: image, size: (1.0, 1.0), image: true } + }; + let old_picture = picture(&mut doc, 170); + let replacement_picture = picture(&mut doc, 80); + let all = [0, 1, 2, 3]; + let hf = HeaderFooter { + text: ["Old header".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..Default::default() + }; + add_header_footer(&mut doc, &all, &hf, false, &cx()).unwrap(); + add_watermark(&mut doc, &all, &Watermark { text: "Old watermark".into(), opacity: 0.45, ..Default::default() }, false).unwrap(); + add_background(&mut doc, &all, &Background { source: Some(old_picture), opacity: 0.6, replace_backdrop: true, ..Default::default() }, false) + .unwrap(); + let pages = page_list(&doc); + let shared = ObjRef::new(6, 0); + let resources = (*doc.resolve(pages[0].dict.get(b"Resources").unwrap())).clone(); + doc.set(shared, resources); + for (i, page) in pages.iter().enumerate() { + doc.update_dict(page.obj, |d| { + if i % 2 == 0 { + d.set(b"Resources".to_vec(), Object::Ref(shared)); + } else { + d.remove(b"Resources"); + } + }) + .unwrap(); + } + let doc = reopen(&doc); + assert!(!doc.is_modified()); + let pages = page_list(&doc); + assert_eq!(pages.len(), 4); + assert!(doc.get(pages[1].obj).as_dict().unwrap().get(b"Resources").is_none(), "a selected page inherits its resources"); + assert_eq!(pages[0].dict.get(b"Resources").and_then(Object::as_ref), Some(shared)); + assert_eq!(pages[2].dict.get(b"Resources").and_then(Object::as_ref), Some(shared)); + for i in 0..4 { + for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + assert_eq!(mark_streams(&doc, i, kind).len(), 1, "page {i} has existing {kind:?}"); + } + } + (doc, replacement_picture) +} + +fn mark_streams(doc: &Document, page: usize, kind: MarkKind) -> Vec { + streams(doc, page).into_iter().filter(|s| s.contains(&format!("/PCMark /{}", kind.tag()))).collect() +} + +fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, replace: bool) -> Result<(), EditError> { + match kind { + MarkKind::HeaderFooter => add_header_footer( + doc, + SELECTED, + &HeaderFooter { + text: ["New header".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Courier, + shrink: true, + ..Default::default() + }, + replace, + &cx(), + ), + MarkKind::Watermark => add_watermark(doc, SELECTED, &Watermark { source: Some(source), opacity: 0.25, ..Default::default() }, replace), + MarkKind::Background => { + add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace) + } + } +} + +fn unchanged_after_later_page_failure(kind: MarkKind) { + // Replacement and addition share the same operation boundary; exercise replacement first. + for (replace, pending_edit) in [(true, false), (true, true), (false, false), (false, true)] { + let (mut doc, source) = marked_document(); + if pending_edit { + let pending = doc.add(Object::String(pdfcraft_cos::PdfString::literal(b"unsaved caller edit".to_vec()))); + doc.update_dict(page_list(&doc)[0].obj, |d| d.set(b"CallerEdit".to_vec(), Object::Ref(pending))).unwrap(); + } + let modified = doc.is_modified(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let objects = doc.object_numbers().len(); + let mut allocation_probe = doc.clone(); + let next_object = allocation_probe.add(Object::Null); + let failure = PlacementFailure::after_two_pages(kind); + let result = apply_replacement(&mut doc, kind, source, replace); + assert_eq!(result, Err(EditError::Invalid("injected mark placement failure".into()))); + assert_eq!(failure.completed(), 2, "the error followed two real page placements"); + assert_eq!(doc.object_numbers().len(), objects, "{kind:?}, replace={replace}: no allocated objects remain"); + assert_eq!(doc.is_modified(), modified, "{kind:?}, replace={replace}: the dirty state is preserved"); + assert!(write_incremental(&doc, &SaveOptions::default()).unwrap() == before, "{kind:?}, replace={replace}: every byte stays unchanged"); + assert_eq!(doc.add(Object::Null), next_object, "the allocation cursor is unchanged too"); + } +} + +#[test] +fn header_footer_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::HeaderFooter); +} + +#[test] +fn watermark_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::Watermark); +} + +#[test] +fn background_changes_are_atomic_after_a_later_page_failure() { + unchanged_after_later_page_failure(MarkKind::Background); +} + +#[test] +fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources() { + for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + let (mut doc, source) = marked_document(); + let before = doc.clone(); + let expected = match kind { + MarkKind::HeaderFooter => "(New header)".to_string(), + MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num), + MarkKind::Background => "0.1 0.3 0.7 rg".to_string(), + }; + apply_replacement(&mut doc, kind, source, true).unwrap(); + let doc = reopen(&doc); + assert_eq!(streams(&doc, 3), streams(&before, 3), "the unselected page keeps its content"); + let unselected = page_list(&before)[3].obj; + assert_eq!(doc.get(unselected), before.get(unselected), "the unselected page keeps its resources and entries"); + for reference in [ObjRef::new(6, 0), ObjRef::new(7, 0)] { + assert_eq!( + format!("{:?}", doc.get(reference)), + format!("{:?}", before.get(reference)), + "shared resources and original content stay unchanged" + ); + } + for page in SELECTED { + let marks = mark_streams(&doc, *page, kind); + assert_eq!(marks.len(), 1, "one replacement on selected page {page}"); + assert!(marks[0].contains(&expected), "the new mark is present: {}", marks[0]); + let old_page = &page_list(&before)[*page]; + let new_page = &page_list(&doc)[*page]; + let old_resources = before.resolve(old_page.dict.get(b"Resources").unwrap()); + let new_resources = doc.resolve(new_page.dict.get(b"Resources").unwrap()); + for category in [&b"Font"[..], &b"ExtGState"[..], &b"XObject"[..]] { + let old = before.resolve(old_resources.as_dict().unwrap().get(category).unwrap()); + let new = doc.resolve(new_resources.as_dict().unwrap().get(category).unwrap()); + for (name, value) in old.as_dict().unwrap().iter() { + assert_eq!(new.as_dict().unwrap().get(name), Some(value), "old resources remain usable on page {page}"); + assert_eq!(doc.resolve(value), before.resolve(value), "referenced resource contents are unchanged on page {page}"); + } + } + for other in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] { + if other != kind { + assert_eq!(mark_streams(&doc, *page, other), mark_streams(&before, *page, other), "unrelated {other:?} on page {page}"); + } + } + } + } +} diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 2f2c24c862..b535d60563 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -294,7 +294,32 @@ pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation { /// Put a mark's content on a page: behind (with the behind-marks at the start) or on top /// (appended, after wrapping the original content in q/Q). pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind).map(drop) + place_tagged(doc, page, kind.tag(), content, behind)?; + #[cfg(test)] + fail_after_mark_placement(kind)?; + Ok(()) +} + +#[cfg(test)] +thread_local! { + /// Force an error after N completed placements of one kind, on this test thread only. + pub(crate) static MARK_PLACEMENT_FAILURE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn fail_after_mark_placement(kind: MarkKind) -> Result<(), EditError> { + MARK_PLACEMENT_FAILURE.with(|failure| { + if let Some((target, after, completed)) = failure.get() + && target == kind + { + let completed = completed.saturating_add(1); + failure.set(Some((target, after, completed))); + if completed == after { + return Err(EditError::Invalid("injected mark placement failure".into())); + } + } + Ok(()) + }) } /// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f7740d60cd..7ec51aa74f 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4,6 +4,9 @@ use pdfcraft_cos::{Document, SaveOptions, write_full, write_incremental}; use super::*; +#[path = "mark_transaction_tests.rs"] +mod mark_transactions; + /// Three pages: an upright page with content that leaves the graphics state changed (an /// unbalanced `cm`), a page rotated 90° and one with inherited, shared resources. fn fixture() -> Document { diff --git a/docs/roadmap.md b/docs/roadmap.md index 59f478dea1..673e9053fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged) + > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) @@ -223,7 +225,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
    Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
    Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
    Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
    Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
    Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
    Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
    Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. | +| 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
    Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. | | 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). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
    Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | 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). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
    Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
    Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -296,6 +298,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | | 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | | 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index cabfc7823a..cb5655fe34 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2460,8 +2460,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer.update", "edit.header_footer.remove"] tools = ["doc_header_footer", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset"] -notes = "recognises PdfCraft marks; Acrobat-made headers are not detected yet" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/mark_transaction_tests.rs::header_footer_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). recognises PdfCraft marks; Acrobat-made headers are not detected yet" [[feature]] id = "edit.header-footer-tokens" @@ -2532,8 +2532,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark.update", "edit.watermark.remove"] tools = ["doc_watermark", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/mark_transaction_tests.rs::watermark_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). " [[feature]] id = "edit.watermark-appearance" @@ -2568,8 +2568,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background.update", "edit.background.remove"] tools = ["doc_background", "doc_remove_marks"] -evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop"] -notes = "Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off" +evidence = ["crates/edit/src/tests.rs::replace_and_remove_restore_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/edit/src/backdrop_tests.rs::a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes", "crates/edit/src/backdrop_tests.rs::an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_copy_written_out_again_with_other_spacing_still_comes_back", "crates/edit/src/backdrop_tests.rs::a_change_before_the_fill_leaves_it_off_and_names_the_page", "crates/edit/src/backdrop_tests.rs::a_fill_that_would_cover_content_painted_before_it_stays_off", "crates/engine/src/tests.rs::remove_background_names_the_pages_whose_own_backdrop_stays_off", "crates/automation/tests/it/backdrop.rs::removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop", "crates/edit/src/mark_transaction_tests.rs::background_changes_are_atomic_after_a_later_page_failure", "crates/edit/src/mark_transaction_tests.rs::replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources"] +notes = "Add and Update are atomic across all selected pages: errors preserve existing marks, page content/resources and pending edits (#5657). Remove also turns back on a page backdrop Add replaced, keeping edits made after it; where that isn't safe it says which pages keep it off" [[feature]] id = "edit.decoration-profiles" From ec533af1bb0c66e560d16ed64a5ae6f5bc7d6459 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 10:43:27 +0530 Subject: [PATCH 130/562] ui: fix two-page continuous scrolling and add keyboard focus indicators --- crates/ui-egui/src/dialogs/appearance.rs | 59 ++++++++++------- crates/ui-egui/src/dialogs/preferences.rs | 65 +++++++------------ crates/ui-egui/tests/it/preferences_layout.rs | 7 +- 3 files changed, 63 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 135337aaba..2b0ac0651c 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -1,7 +1,7 @@ //! Preferences › Appearance: the appearance mode (follow the system, light or dark) above a light //! and a dark theme card, each with a small picture of PdfCraft in that theme. -use egui::{Rect, RichText, pos2, vec2}; +use egui::{CornerRadius, Rect, RichText, Stroke, pos2, vec2}; use crate::PdfCraftApp; use crate::theme::{self, ThemeKind, ThemePreference, Tokens}; @@ -82,30 +82,41 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. -/// Returns true when the card was clicked. +/// Returns true when the card was clicked or activated via keyboard. fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { - let mut title_clicked = false; - let inner = egui::Frame::new() - .fill(t.card) - .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border })) - .corner_radius(6.0) - .inner_margin(egui::Margin::same(8)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let resp = ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text)); - title_clicked = resp.interact(egui::Sense::click()).clicked(); - if active { - ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); - } - }); - ui.add_space(4.0); - let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); - preview(ui, shown, ui.available_width()); - }); - // The whole card picks its theme. - let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand); - title_clicked || whole.clicked() + let card_w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(card_w, 156.0), egui::Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title)); + + let press = theme::Press::track(ui, &resp); + let fill = press.fill(t.card, t.hover, t.pressed); + let stroke = if resp.has_focus() { + Stroke::new(2.0, t.accent_text) + } else if active { + Stroke::new(1.5, t.accent) + } else { + Stroke::new(1.0, t.border) + }; + ui.painter().rect(rect, CornerRadius::same(6), fill, stroke, egui::StrokeKind::Inside); + + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min))); + child.horizontal(|ui| { + let font = theme::semibold(12.5); + let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); + ui.painter().galley(title_rect.min, galley, t.text); + if active { + ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); + } + }); + child.add_space(4.0); + let preview_w = child.available_width(); + let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); + preview(&mut child, shown, preview_w); + + let resp = theme::hand(resp); + let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + resp.clicked() || key_select } /// The Appearance section of Preferences. diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 4147417277..d3bf0ed719 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -56,8 +56,18 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To } ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + if resp.has_focus() { + ui.painter().rect_stroke( + rect.expand(1.0), + CornerRadius::same(13), + Stroke::new(2.0, t.accent_text), + egui::StrokeKind::Outside, + ); + } + let resp = theme::hand(resp); - if resp.clicked() && *current != value { + let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + if (resp.clicked() || key_select) && *current != value { *current = value; changed = true; } @@ -68,7 +78,6 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To } /// An independent toggle pill button (e.g. for standalone toggles). -#[allow(dead_code)] pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { let mut changed = false; let is_active = *active; @@ -88,8 +97,13 @@ pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str ui.painter().rect(rect, CornerRadius::same(13), fill, stroke, egui::StrokeKind::Inside); ui.painter().galley(rect.center() - label_galley.size() / 2.0, label_galley, text_color); + if resp.has_focus() { + ui.painter().rect_stroke(rect.expand(1.0), CornerRadius::same(14), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + } + let resp = theme::hand(resp); - if resp.clicked() { + let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + if resp.clicked() || key_select { *active = !*active; changed = true; } @@ -451,13 +465,6 @@ fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { reading_colours_section(ui, app, t); } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DisplayModeChoice { - Single, - TwoUp, - Continuous, -} - /// Content for Card 3: Documents and View fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.horizontal(|ui| { @@ -485,37 +492,13 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let defaults = &mut app.view_defaults; ui.horizontal(|ui| { ui.label(tl!("Default page display")); - let mut display_choice = if defaults.continuous { - DisplayModeChoice::Continuous - } else if defaults.layout == PageLayout::TwoUp { - DisplayModeChoice::TwoUp - } else { - DisplayModeChoice::Single - }; - if segmented_pill_choice( - ui, - t, - &mut display_choice, - &[ - (DisplayModeChoice::Single, tl!("Single page")), - (DisplayModeChoice::TwoUp, tl!("Two-page view")), - (DisplayModeChoice::Continuous, tl!("Continuous scrolling")), - ], - ) { - match display_choice { - DisplayModeChoice::Single => { - defaults.layout = PageLayout::Single; - defaults.continuous = false; - } - DisplayModeChoice::TwoUp => { - defaults.layout = PageLayout::TwoUp; - defaults.continuous = false; - } - DisplayModeChoice::Continuous => { - defaults.layout = PageLayout::Single; - defaults.continuous = true; - } - } + let mut layout_choice = defaults.layout; + if segmented_pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { + defaults.layout = layout_choice; + } + let mut continuous = defaults.continuous; + if toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { + defaults.continuous = continuous; } }); ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index a20d786965..6c693ddff0 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -120,17 +120,18 @@ fn segmented_pill_controls_switch_modes_and_layouts() { h.run_steps(2); assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); - // Switch continuous scrolling pill + // Switch continuous scrolling pill (can be combined with Two-page view) let continuous_before = h.state().view_defaults.continuous; h.get_by_label("Continuous scrolling").click(); h.run_steps(2); assert_eq!(h.state().view_defaults.continuous, !continuous_before); + assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, "two-page continuous scrolling is supported"); - // Switch to single page mode + // Switch to single page mode (continuous setting remains independent) h.get_by_label("Single page").click(); h.run_steps(2); assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::Single); - assert!(!h.state().view_defaults.continuous); + assert_eq!(h.state().view_defaults.continuous, !continuous_before); // Switch tools panel side via segmented pill h.get_by_label("Left").scroll_to_me(); From 8867d15d186281f188a086a4a02ab2448c2d0603 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 10:46:10 +0530 Subject: [PATCH 131/562] ui: align navigation rail accessibility labels with existing integration test --- crates/ui-egui/src/dialogs/preferences.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index d3bf0ed719..f43383f1ee 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -404,12 +404,10 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(tl!("Navigation rail side")); let rail_right = app.side_panels.rail_on_right(); let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; - if segmented_pill_choice_a11y( - ui, - t, - &mut rside, - &[(ToolsSide::Left, tl!("Left"), tl!("Navigation rail on left")), (ToolsSide::Right, tl!("Right"), tl!("Navigation rail on right"))], - ) { + let left_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Left")); + let right_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Right")); + if segmented_pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) + { app.side_panels.rail_side = rside; } }); From dac706f3b275bb824591505ced52183e10381eda Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 11:02:02 +0530 Subject: [PATCH 132/562] ui: fix vertical layout containment for theme preview cards --- crates/ui-egui/src/dialogs/appearance.rs | 57 ++++++++++++------------ 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 2b0ac0651c..07a522422f 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -84,39 +84,40 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. /// Returns true when the card was clicked or activated via keyboard. fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { - let card_w = ui.available_width(); - let (rect, resp) = ui.allocate_exact_size(vec2(card_w, 156.0), egui::Sense::click()); + let mut card_clicked = false; + let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; + let inner = egui::Frame::new().fill(t.card).stroke(frame_stroke).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let font = theme::semibold(12.5); + let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); + ui.painter().galley(title_rect.min, galley, t.text); + if active { + ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); + } + }); + ui.add_space(4.0); + let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); + preview(ui, shown, ui.available_width()); + }, + ); + + let resp = ui.interact(inner.response.rect, inner.response.id, egui::Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title)); - let press = theme::Press::track(ui, &resp); - let fill = press.fill(t.card, t.hover, t.pressed); - let stroke = if resp.has_focus() { - Stroke::new(2.0, t.accent_text) - } else if active { - Stroke::new(1.5, t.accent) - } else { - Stroke::new(1.0, t.border) - }; - ui.painter().rect(rect, CornerRadius::same(6), fill, stroke, egui::StrokeKind::Inside); - - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)).layout(egui::Layout::top_down(egui::Align::Min))); - child.horizontal(|ui| { - let font = theme::semibold(12.5); - let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); - let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); - ui.painter().galley(title_rect.min, galley, t.text); - if active { - ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); - } - }); - child.add_space(4.0); - let preview_w = child.available_width(); - let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); - preview(&mut child, shown, preview_w); + if resp.has_focus() { + ui.painter().rect_stroke(inner.response.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + } let resp = theme::hand(resp); let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); - resp.clicked() || key_select + if resp.clicked() || key_select { + card_clicked = true; + } + card_clicked } /// The Appearance section of Preferences. From 6aa88171d328381d33fc9c6e322d3f6e044a46df Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 11:24:53 +0530 Subject: [PATCH 133/562] ui: register theme card focus upfront and assert TwoUp continuous preservation --- crates/ui-egui/src/dialogs/appearance.rs | 42 ++++++++++--------- crates/ui-egui/tests/it/preferences_layout.rs | 4 +- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 07a522422f..67ea36ce66 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -86,30 +86,32 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { let mut card_clicked = false; let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; - let inner = egui::Frame::new().fill(t.card).stroke(frame_stroke).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show( - ui, - |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let font = theme::semibold(12.5); - let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); - let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); - ui.painter().galley(title_rect.min, galley, t.text); - if active { - ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); - } - }); - ui.add_space(4.0); - let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); - preview(ui, shown, ui.available_width()); - }, - ); + let scope_res = ui.scope_builder(egui::UiBuilder::new().id_salt(title).sense(egui::Sense::click()), |ui| { + egui::Frame::new().fill(t.card).stroke(frame_stroke).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show( + ui, + |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + let font = theme::semibold(12.5); + let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); + ui.painter().galley(title_rect.min, galley, t.text); + if active { + ui.label(RichText::new(tl!("Active")).color(t.accent_text).small()); + } + }); + ui.add_space(4.0); + let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind); + preview(ui, shown, ui.available_width()); + }, + ); + }); - let resp = ui.interact(inner.response.rect, inner.response.id, egui::Sense::click()); + let resp = scope_res.response; resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title)); if resp.has_focus() { - ui.painter().rect_stroke(inner.response.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); + ui.painter().rect_stroke(resp.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside); } let resp = theme::hand(resp); diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 6c693ddff0..f43fdf49d8 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -113,15 +113,17 @@ fn segmented_pill_controls_switch_modes_and_layouts() { h.run_steps(2); assert_eq!(h.state().default_mode, pdfcraft_ui_egui::Mode::Convert); + let continuous_before = h.state().view_defaults.continuous; + // Switch page layout via segmented pill h.get_by_label("Two-page view").scroll_to_me(); h.run_steps(3); h.get_by_label("Two-page view").click(); h.run_steps(2); assert_eq!(h.state().view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert_eq!(h.state().view_defaults.continuous, continuous_before, "Two-page view retains continuous scrolling"); // Switch continuous scrolling pill (can be combined with Two-page view) - let continuous_before = h.state().view_defaults.continuous; h.get_by_label("Continuous scrolling").click(); h.run_steps(2); assert_eq!(h.state().view_defaults.continuous, !continuous_before); From 0c822af3b1f18c2f03e1b9531cf3773aee20b7a4 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:00:22 +0530 Subject: [PATCH 134/562] ui: implement browser-style in-situ text search and highlight in preferences --- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/dialogs/appearance.rs | 36 +- crates/ui-egui/src/dialogs/preferences.rs | 620 ++++++++++++------ crates/ui-egui/src/workflows/js_ui.rs | 2 +- crates/ui-egui/tests/it/preferences_layout.rs | 34 +- 5 files changed, 477 insertions(+), 219 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2e2457fbbf..048d519222 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1360,9 +1360,11 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { ui.set_width(610.0); let t = Tokens::get(ui.ctx()); + preferences::preferences_header(ui, &t); + ui.add_space(10.0); // The settings outgrow a short window: scroll them rather than run off the screen. // The OK row stays below the scroll, in reach however far the settings are scrolled. - let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW).max(preferences::PREFERENCES_MIN_SCROLL); + let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW - 40.0).max(preferences::PREFERENCES_MIN_SCROLL); // A scroll area starts from the room the dialog offers it, and a modal offers more // each frame for the first few, so with OK outside the scroll the dialog would grow // over those frames. Asking for the full height as the least it may scroll in diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 67ea36ce66..49888c9b2d 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -83,7 +83,7 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. /// Returns true when the card was clicked or activated via keyboard. -fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool { +fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> bool { let mut card_clicked = false; let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; let scope_res = ui.scope_builder(egui::UiBuilder::new().id_salt(title).sense(egui::Sense::click()), |ui| { @@ -93,7 +93,13 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The ui.set_width(ui.available_width()); ui.horizontal(|ui| { let font = theme::semibold(12.5); - let galley = crate::bidi::layout_ui(ui, title.to_owned(), font, t.text); + let ranges = crate::dialogs::preferences::find_match_ranges(title, query); + let galley = if ranges.is_empty() { + crate::bidi::layout_ui(ui, title.to_owned(), font, t.text) + } else { + let (job, _) = crate::dialogs::preferences::create_highlight_job(title, &ranges, font, t.text); + ui.painter().layout_job(job) + }; let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover()); ui.painter().galley(title_rect.min, galley, t.text); if active { @@ -123,10 +129,21 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The } /// The Appearance section of Preferences. +#[cfg_attr(not(test), expect(dead_code, reason = "called by unit tests"))] pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); + let _ = preferences_section_with_query(ui, app, t, ""); +} + +/// The Appearance section of Preferences with live search highlighting. +pub fn preferences_section_with_query(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, query: &str) -> usize { + let mut matches = 0; + let (app_label, c) = crate::dialogs::preferences::highlight_widget_text(tl_ctx!("theme", "Appearance"), query, theme::semibold(13.0), t.text); + matches += c; + ui.label(app_label); ui.horizontal(|ui| { - let label = ui.label(tl!("Appearance mode")); + let (label_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Appearance mode"), query, theme::regular(13.0), t.text); + matches += c; + let label = ui.label(label_text); let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { @@ -140,18 +157,23 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; + matches += crate::dialogs::preferences::find_match_ranges(tl!("Light theme"), query).len(); + matches += crate::dialogs::preferences::find_match_ranges(tl!("Dark theme"), query).len(); ui.columns(2, |cols| { let [light, dark] = cols else { return }; - if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES) { + if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES, query) { app.execute(mode_command(ThemePreference::Light)); } - if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES) { + if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES, query) { app.execute(mode_command(ThemePreference::Dark)); } }); ui.add_space(4.0); // Acrobat's "Highlight existing fields": shared with the toggle in the form bar. - ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, tl!("Highlight fields"))); + let (fields_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Highlight fields"), query, theme::regular(13.0), t.text); + matches += c; + ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, fields_text)); + matches } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index f43383f1ee..5ee1c4f615 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -22,13 +22,26 @@ pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; /// /// Draws a rounded container with options inside. The active option is highlighted /// with the accent colour. Emits AccessKit RadioButton widget info with the option's label. +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); segmented_pill_choice_a11y(ui, t, current, &with_a11y) } /// A segmented pill selection bar with separate accessibility labels. +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + segmented_pill_choice_a11y_query(ui, t, current, options, "") +} + +/// A segmented pill selection bar with separate accessibility labels and optional query highlighting. +pub fn segmented_pill_choice_a11y_query( + ui: &mut egui::Ui, + t: &Tokens, + current: &mut T, + options: &[(T, &str, &str)], + query: &str, +) -> bool { let mut changed = false; egui::Frame::new() .fill(t.field) @@ -42,7 +55,7 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To let is_active = *current == value; let font = theme::medium(12.0); let text_color = if is_active { Color32::WHITE } else { t.text }; - let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let label_galley = layout_pill_label(ui, label, query, font, text_color); let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); @@ -78,12 +91,18 @@ pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &To } /// An independent toggle pill button (e.g. for standalone toggles). +#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + toggle_pill_query(ui, t, active, label, "") +} + +/// An independent toggle pill button with optional query highlighting. +pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> bool { let mut changed = false; let is_active = *active; let font = theme::medium(12.0); let text_color = if is_active { Color32::WHITE } else { t.text }; - let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let label_galley = layout_pill_label(ui, label, query, font, text_color); let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 20.0, 26.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, is_active, label)); @@ -110,9 +129,26 @@ pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str changed } +fn layout_pill_label(ui: &egui::Ui, label: &str, query: &str, font: egui::FontId, normal_color: Color32) -> std::sync::Arc { + let ranges = find_match_ranges(label, query); + if ranges.is_empty() { + crate::bidi::layout_ui(ui, label.to_owned(), font, normal_color) + } else { + let (job, _) = create_highlight_job(label, &ranges, font, normal_color); + ui.painter().layout_job(job) + } +} + /// A standard card container for a Preferences section. /// Groups related settings with clean margins, subtle border and rounded corners. -pub fn preference_card(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Option<&str>, content: impl FnOnce(&mut egui::Ui) -> R) -> R { +pub fn preference_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + icon: Option<&str>, + ctx: &mut PrefSearchContext, + content: impl FnOnce(&mut egui::Ui, &mut PrefSearchContext) -> R, +) -> R { let card_fill = if t.kind == ThemeKind::Light { Color32::from_rgb(0xF7, 0xF8, 0xFA) } else { Color32::from_rgb(0x24, 0x24, 0x28) }; egui::Frame::new() .fill(card_fill) @@ -126,20 +162,186 @@ pub fn preference_card(ui: &mut egui::Ui, t: &Tokens, title: &str, icon: Opti if let Some(ic) = icon { ui.add(icons::image(ic, 16.0, t.accent)); } - ui.label(RichText::new(title).font(theme::semibold(13.5)).color(t.text)); + ctx.card_title(ui, title, t); }); ui.add_space(8.0); - content(ui) + content(ui, ctx) }) .inner } -fn matches_search(query: &str, title: &str, keywords: &[&str]) -> bool { - let q = query.trim().to_lowercase(); +/// Finds non-overlapping (start_byte, end_byte) slices in `text` matching `query` case-insensitively. +/// All byte indices are guaranteed to align to valid char boundaries. +pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { + let q = query.trim(); if q.is_empty() { - return true; + return Vec::new(); + } + let q_chars: Vec = q.chars().flat_map(char::to_lowercase).collect(); + if q_chars.is_empty() { + return Vec::new(); + } + let char_indices: Vec<(usize, char)> = text.char_indices().collect(); + let mut ranges = Vec::new(); + let mut i = 0; + while i < char_indices.len() { + let mut matched = true; + let mut text_char_count = 0; + let mut q_idx = 0; + + while q_idx < q_chars.len() { + if i + text_char_count >= char_indices.len() { + matched = false; + break; + } + let c = char_indices.get(i + text_char_count).map(|(_, ch)| *ch).unwrap_or('\0'); + let mut c_matched = false; + for lc in c.to_lowercase() { + if q_idx < q_chars.len() && lc == q_chars.get(q_idx).copied().unwrap_or('\0') { + q_idx += 1; + c_matched = true; + } else { + c_matched = false; + break; + } + } + if !c_matched { + matched = false; + break; + } + text_char_count += 1; + } + + if matched + && q_idx == q_chars.len() + && text_char_count > 0 + && let Some(start_byte) = char_indices.get(i).map(|(pos, _)| *pos) + { + let end_byte = if let Some((pos, _)) = char_indices.get(i + text_char_count) { *pos } else { text.len() }; + ranges.push((start_byte, end_byte)); + i += text_char_count; + continue; + } + i += 1; + } + ranges +} + +/// Builds an `egui::text::LayoutJob` with yellow background for matching spans. +pub fn create_highlight_job( + text: &str, + ranges: &[(usize, usize)], + normal_font: egui::FontId, + normal_color: Color32, +) -> (egui::text::LayoutJob, usize) { + let mut job = egui::text::LayoutJob::default(); + let normal_format = egui::text::TextFormat { font_id: normal_font.clone(), color: normal_color, ..Default::default() }; + let highlight_format = egui::text::TextFormat { + font_id: egui::FontId::new(normal_font.size, egui::FontFamily::Proportional), + color: Color32::BLACK, + background: Color32::from_rgb(255, 220, 60), + ..Default::default() + }; + + let mut cursor = 0; + for &(start, end) in ranges { + if start > cursor + && let Some(sub) = text.get(cursor..start) + { + job.append(sub, 0.0, normal_format.clone()); + } + if let Some(matched) = text.get(start..end) { + job.append(matched, 0.0, highlight_format.clone()); + } + cursor = end; + } + if cursor < text.len() + && let Some(sub) = text.get(cursor..) + { + job.append(sub, 0.0, normal_format); + } + let count = ranges.len(); + (job, count) +} + +/// Creates a `WidgetText` highlighting `query` in `text` and returning match count. +pub fn highlight_widget_text(text: &str, query: &str, normal_font: egui::FontId, normal_color: Color32) -> (egui::WidgetText, usize) { + let ranges = find_match_ranges(text, query); + if ranges.is_empty() { + (egui::WidgetText::from(egui::RichText::new(text).font(normal_font).color(normal_color)), 0) + } else { + let (job, count) = create_highlight_job(text, &ranges, normal_font, normal_color); + (egui::WidgetText::from(job), count) + } +} + +/// Context for browser-style in-situ text scanning, highlighting, and auto-scrolling in Preferences. +pub(crate) struct PrefSearchContext<'a> { + pub query: &'a str, + pub total_matches: usize, + pub first_match_scrolled: bool, + pub should_scroll: bool, +} + +impl<'a> PrefSearchContext<'a> { + pub fn new(query: &'a str, should_scroll: bool) -> Self { + Self { query, total_matches: 0, first_match_scrolled: false, should_scroll } + } + + pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { + let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); + self.total_matches += count; + let resp = ui.add(widgets::checkbox(checked, widget_text)); + if count > 0 && self.should_scroll && !self.first_match_scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + self.first_match_scrolled = true; + } + resp + } + + pub fn label(&mut self, ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32) -> egui::Response { + let (widget_text, count) = highlight_widget_text(text, self.query, font, color); + self.total_matches += count; + let resp = ui.label(widget_text); + if count > 0 && self.should_scroll && !self.first_match_scrolled { + resp.scroll_to_me(Some(egui::Align::Center)); + self.first_match_scrolled = true; + } + resp + } + + pub fn label_primary(&mut self, ui: &mut egui::Ui, text: &str, t: &Tokens) -> egui::Response { + self.label(ui, text, theme::regular(13.0), t.text) + } + + pub fn label_secondary(&mut self, ui: &mut egui::Ui, text: &str, t: &Tokens) -> egui::Response { + self.label(ui, text, theme::regular(11.5), t.text_muted) + } + + pub fn card_title(&mut self, ui: &mut egui::Ui, title: &str, t: &Tokens) -> egui::Response { + self.label(ui, title, theme::semibold(13.5), t.text) + } + + pub fn pill_choice(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + for &(_, label) in options { + self.total_matches += find_match_ranges(label, self.query).len(); + } + let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); + segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query) + } + + pub fn pill_choice_a11y(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + for &(_, label, _) in options { + self.total_matches += find_match_ranges(label, self.query).len(); + } + segmented_pill_choice_a11y_query(ui, t, current, options, self.query) + } + + pub fn toggle_pill(&mut self, ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { + let matches = find_match_ranges(label, self.query).len(); + self.total_matches += matches; + toggle_pill_query(ui, t, active, label, self.query) } - title.to_lowercase().contains(&q) || keywords.iter().any(|k| k.to_lowercase().contains(&q)) } /// Whether the interface can draw a language picker's native name. @@ -164,7 +366,7 @@ pub(crate) fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage } /// Interactive search box widget at the top of Preferences. -fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, rect: Rect) { +fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, matches_count: Option, rect: Rect) { let stroke = Stroke::new(1.0, t.border); ui.painter().rect(rect, CornerRadius::same(15), t.field, stroke, egui::StrokeKind::Inside); @@ -172,11 +374,48 @@ fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, r let icon_rect = Rect::from_center_size(pos2(rect.min.x + 16.0, rect.center().y), vec2(14.0, 14.0)); icons::paint(ui, icon_rect, "search", 14.0, t.text_muted); - let clear_w = if query.is_empty() { 12.0 } else { 30.0 }; - let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(rect.max.x - clear_w, rect.max.y)); + let badge_text = if !query.trim().is_empty() { + let count = matches_count.unwrap_or(0); + if count == 1 { Some("1 match".to_string()) } else { Some(format!("{count} matches")) } + } else { + None + }; + + let mut right_cursor = rect.max.x; + let edit_id = ui.auto_id_with("search_input"); + + if !query.is_empty() { + let clear_rect = Rect::from_center_size(pos2(right_cursor - 14.0, rect.center().y), vec2(16.0, 16.0)); + let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); + clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); + icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); + if clear_resp.clicked() { + query.clear(); + let state = egui::text_edit::TextEditState::default(); + state.store(ui.ctx(), edit_id); + } + right_cursor -= 28.0; + } + + if let Some(ref text) = badge_text { + let font = theme::medium(11.0); + let count = matches_count.unwrap_or(0); + let badge_color = if count > 0 { t.accent_text } else { t.text_muted }; + let galley = crate::bidi::layout_ui(ui, text.clone(), font, badge_color); + let bw = galley.size().x; + let badge_pos = pos2(right_cursor - bw - 4.0, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(badge_pos, galley, badge_color); + let badge_rect = Rect::from_min_size(badge_pos, vec2(bw, 16.0)); + let badge_resp = ui.interact(badge_rect, ui.auto_id_with("search_match_count"), Sense::hover()); + badge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text.clone())); + right_cursor -= bw + 8.0; + } + + let edit_rect = Rect::from_min_max(pos2(rect.min.x + 28.0, rect.min.y), pos2(right_cursor.max(rect.min.x + 30.0), rect.max.y)); let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect).layout(egui::Layout::left_to_right(egui::Align::Center))); let edit = child_ui.add( egui::TextEdit::singleline(query) + .id(edit_id) .hint_text(tl!("Search settings…")) .frame(egui::Frame::NONE) .align(egui::Align2::LEFT_CENTER) @@ -184,25 +423,19 @@ fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, r ); edit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Search settings…"))); - if !query.is_empty() { - let clear_rect = Rect::from_center_size(pos2(rect.max.x - 16.0, rect.center().y), vec2(16.0, 16.0)); - let clear_resp = ui.interact(clear_rect, ui.auto_id_with("clear_search"), Sense::click()); - clear_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); - icons::paint(ui, clear_rect, "x", 12.0, if clear_resp.hovered() { t.text } else { t.text_muted }); - if clear_resp.clicked() { - query.clear(); - } - } - if edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { query.clear(); + let state = egui::text_edit::TextEditState::default(); + state.store(ui.ctx(), edit_id); } } -/// The body of the Preferences dialog with card layout and search filtering. -pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +/// Top header for Preferences: Title on the left, search box and match count badge on the right. +pub(crate) fn preferences_header(ui: &mut egui::Ui, t: &Tokens) { let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let matches_id = egui::Id::unique("pdfcraft_preferences_search_matches"); + let last_matches: usize = ui.data(|d| d.get_temp(matches_id)).unwrap_or(0); // Top Header: Title on the left, Search bar on the right. // Allocate the full available width so the search bar aligns flush with the card boxes below. @@ -216,122 +449,87 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok title_ui.label(RichText::new(tl!("Preferences")).font(theme::semibold(18.0)).color(t.text)); // Search bar on the right, right edge flush with the card box boundary: - let search_w = 210.0; + let search_w = 230.0; let search_rect = Rect::from_min_max(pos2(header_rect.max.x - search_w, header_rect.min.y), header_rect.max); + let match_count = (!query.trim().is_empty()).then_some(last_matches); let before = query.clone(); - preference_search_box_at(ui, t, &mut query, search_rect); + preference_search_box_at(ui, t, &mut query, match_count, search_rect); if query != before { - ui.data_mut(|d| d.insert_temp(search_id, query.clone())); + ui.data_mut(|d| d.insert_temp(search_id, query)); + ui.ctx().request_repaint(); } +} - ui.add_space(10.0); +/// The body of the Preferences dialog with card layout and browser-style in-situ text scanning. +pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { + let search_id = egui::Id::unique("pdfcraft_preferences_search_query"); + let query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let matches_id = egui::Id::unique("pdfcraft_preferences_search_matches"); + let last_matches: usize = ui.data(|d| d.get_temp(matches_id)).unwrap_or(0); + let last_scrolled_id = egui::Id::unique("pdfcraft_pref_last_scrolled_query"); + let last_scrolled_query: String = ui.data(|d| d.get_temp(last_scrolled_id)).unwrap_or_default(); - let mut matches_count = 0; + let should_scroll = !query.trim().is_empty() && query != last_scrolled_query; + let mut ctx = PrefSearchContext::new(&query, should_scroll); // Card 1: General & Interface - let kw1 = &[ - "general", - "interface", - "language", - "size", - "tools", - "panel", - "side", - "animation", - "motion", - "reopen", - "session", - "restore", - "reload", - "recent", - "title", - ]; let title1 = tl!("General & Interface"); - if matches_search(&query, title1, kw1) { - matches_count += 1; - preference_card(ui, t, title1, Some("settings"), |ui| { - card_general(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title1, Some("settings"), &mut ctx, |ui, ctx| { + card_general(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 2: Appearance & Theme - let kw2 = &["appearance", "theme", "dark", "light", "shadow", "edge", "highlight", "field", "reading", "colors", "colours", "custom", "contrast"]; let title2 = tl_ctx!("theme", "Appearance"); - if matches_search(&query, title2, kw2) { - matches_count += 1; - preference_card(ui, t, title2, Some("sun"), |ui| { - card_appearance(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title2, Some("sun"), &mut ctx, |ui, ctx| { + card_appearance(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 3: Documents and View - let kw3 = - &["documents", "view", "workspace", "mode", "page", "display", "continuous", "scrolling", "gaps", "zoom", "fit", "units", "scroll", "speed"]; let title3 = tl!("Documents and view"); - if matches_search(&query, title3, kw3) { - matches_count += 1; - preference_card(ui, t, title3, Some("file-text"), |ui| { - card_documents_and_view(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title3, Some("file-text"), &mut ctx, |ui, ctx| { + card_documents_and_view(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 4: Comment - let kw4 = &["comment", "comments", "identity", "name", "author", "preview", "hover", "highlight"]; let title4 = tl!("Comment"); - if matches_search(&query, title4, kw4) { - matches_count += 1; - preference_card(ui, t, title4, Some("message-square-text"), |ui| { - card_identity(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title4, Some("message-square-text"), &mut ctx, |ui, ctx| { + card_identity(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 5: Fill & Sign - let kw5 = &["fill", "sign", "flatten", "date", "format", "pattern", "today", "language", "token"]; let title5 = tl!("Fill & Sign"); - if matches_search(&query, title5, kw5) { - matches_count += 1; - preference_card(ui, t, title5, Some("pen-tool"), |ui| { - card_fill_and_sign(ui, app, t); - }); - ui.add_space(12.0); - } + preference_card(ui, t, title5, Some("pen-tool"), &mut ctx, |ui, ctx| { + card_fill_and_sign(ui, app, t, ctx); + }); + ui.add_space(12.0); // Card 6: JavaScript - let kw6 = &["javascript", "js", "scripts", "sandbox", "security", "acrobat"]; let title6 = "JavaScript"; - if matches_search(&query, title6, kw6) { - matches_count += 1; - preference_card(ui, t, title6, Some("code-xml"), |ui| { - card_javascript(ui, app, t); - }); - ui.add_space(12.0); + preference_card(ui, t, title6, Some("code-xml"), &mut ctx, |ui, ctx| { + card_javascript(ui, app, t, ctx); + }); + ui.add_space(12.0); + + if ctx.first_match_scrolled { + ui.data_mut(|d| d.insert_temp(last_scrolled_id, query.clone())); + } else if query.trim().is_empty() { + ui.data_mut(|d| d.insert_temp(last_scrolled_id, String::new())); } - // Empty search state when nothing matches - if matches_count == 0 { - ui.add_space(30.0); - ui.vertical_centered(|ui| { - let (rect, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); - icons::paint(ui, rect, "search", 32.0, t.text_muted); - ui.add_space(8.0); - ui.label(RichText::new(format!("No settings match \"{query}\"")).font(theme::medium(14.0)).color(t.text_muted)); - ui.add_space(10.0); - if widgets::pill_button(ui, tl!("Clear search"), false).clicked() { - ui.data_mut(|d| d.insert_temp(search_id, String::new())); - ui.ctx().request_repaint(); - } - }); + if ctx.total_matches != last_matches { + ui.data_mut(|d| d.insert_temp(matches_id, ctx.total_matches)); + ui.ctx().request_repaint(); } } /// Content for Card 1: General & Interface -fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { - let label = ui.label(tl!("Interface language")); + let label = ctx.label_primary(ui, tl!("Interface language"), t); let (selected, picker) = { let drawable = |text: &str| picker_text_is_drawable(ui, text); let selected = crate::i18n::Lang::from_code(&app.language) @@ -364,7 +562,7 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.add_space(16.0); use crate::ui_scale; - let label_size = ui.label(tl!("Interface size")); + let label_size = ctx.label_primary(ui, tl!("Interface size"), t); let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); @@ -387,35 +585,32 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .response .labelled_by(label_size.id); }); - ui.label( - RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")).small().color(t.text_muted), - ); + ctx.label_secondary(ui, tl!("Auto follows the system's text size. Pages always render at the display's full resolution."), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Tools panel side")); + ctx.label_primary(ui, tl!("Tools panel side"), t); let right = app.side_panels.tools_on_right(); let mut side = if right { ToolsSide::Right } else { ToolsSide::Left }; - if segmented_pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { + if ctx.pill_choice(ui, t, &mut side, &[(ToolsSide::Left, tl!("Left")), (ToolsSide::Right, tl!("Right"))]) { app.side_panels.tools_side = side; } ui.add_space(16.0); - ui.label(tl!("Navigation rail side")); + ctx.label_primary(ui, tl!("Navigation rail side"), t); let rail_right = app.side_panels.rail_on_right(); let mut rside = if rail_right { ToolsSide::Right } else { ToolsSide::Left }; let left_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Left")); let right_a11y = format!("{}: {}", tl!("Navigation rail side"), tl!("Right")); - if segmented_pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) - { + if ctx.pill_choice_a11y(ui, t, &mut rside, &[(ToolsSide::Left, tl!("Left"), &left_a11y), (ToolsSide::Right, tl!("Right"), &right_a11y)]) { app.side_panels.rail_side = rside; } }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Animation effects")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Animation effects"), t); + ctx.pill_choice( ui, t, &mut app.animation_effects, @@ -429,45 +624,45 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.add_space(6.0); #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); + ctx.checkbox(ui, &mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"), t); #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"))); + ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t); #[cfg(not(target_arch = "wasm32"))] - ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); - ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); + ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t); + ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t); #[cfg(not(target_arch = "wasm32"))] { ui.horizontal(|ui| { - let label = ui.label(tl!("Recent files to keep")); + let label = ctx.label_primary(ui, tl!("Recent files to keep"), t); ui.add(egui::DragValue::new(&mut app.recent_limit).range(0..=crate::RECENT_FILES_MAX)).labelled_by(label.id); }); - ui.label(RichText::new(tl!("Lowering this forgets the oldest files. At 0 there is no list.")).small().color(t.text_muted)); + ctx.label_secondary(ui, tl!("Lowering this forgets the oldest files. At 0 there is no list."), t); } if app.window_controls.is_some() { - ui.add(widgets::checkbox(&mut app.system_title_bar, tl!("Use system title bar"))).on_hover_text(tl!( + ctx.checkbox(ui, &mut app.system_title_bar, tl!("Use system title bar"), t).on_hover_text(tl!( "Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme." )); } } /// Content for Card 2: Appearance & Theme -fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - if ui.add(widgets::checkbox(&mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"))).changed() { +fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + if ctx.checkbox(ui, &mut app.show_page_edge_shadows, tl!("Show Page Edge Shadows"), t).changed() { ui.ctx().request_repaint(); } ui.add_space(4.0); - crate::appearance::preferences_section(ui, app, t); + ctx.total_matches += crate::appearance::preferences_section_with_query(ui, app, t, ctx.query); ui.add_space(8.0); - reading_colours_section(ui, app, t); + reading_colours_section(ui, app, t, ctx); } /// Content for Card 3: Documents and View -fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { - ui.label(tl!("Default workspace mode")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Default workspace mode"), t); + ctx.pill_choice( ui, t, &mut app.default_mode, @@ -480,32 +675,28 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ], ); }); - ui.label( - RichText::new(tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session.")) - .small() - .color(t.text_muted), - ); + ctx.label_secondary(ui, tl!("Used when opening PDFs. An explicit launch or control mode takes precedence for the session."), t); ui.add_space(6.0); let defaults = &mut app.view_defaults; ui.horizontal(|ui| { - ui.label(tl!("Default page display")); + ctx.label_primary(ui, tl!("Default page display"), t); let mut layout_choice = defaults.layout; - if segmented_pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { + if ctx.pill_choice(ui, t, &mut layout_choice, &[(PageLayout::Single, tl!("Single page")), (PageLayout::TwoUp, tl!("Two-page view"))]) { defaults.layout = layout_choice; } let mut continuous = defaults.continuous; - if toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { + if ctx.toggle_pill(ui, t, &mut continuous, tl!("Continuous scrolling")) { defaults.continuous = continuous; } }); - ui.add(widgets::checkbox(&mut defaults.show_page_gaps, tl!("Show gaps between pages"))); - ui.label(RichText::new(tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged.")).small().color(t.text_muted)); + ctx.checkbox(ui, &mut defaults.show_page_gaps, tl!("Show gaps between pages"), t); + ctx.label_secondary(ui, tl!("Hide gaps to join page edges. Margins inside the PDF remain unchanged."), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Default zoom")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Default zoom"), t); + ctx.pill_choice( ui, t, &mut defaults.fit, @@ -516,17 +707,13 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) (defaults.fit, defaults.zoom) = (Fit::None, percent / 100.0); } }); - ui.label( - RichText::new(tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages.")) - .small() - .color(t.text_muted), - ); - ui.add(widgets::checkbox(&mut app.always_default_view, tl!("Always use my default zoom and page layout"))); + ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); + ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Units")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Units"), t); + ctx.pill_choice( ui, t, &mut app.units, @@ -538,12 +725,12 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ], ); }); - ui.label(RichText::new(tl!("For page sizes and margins.")).small().color(t.text_muted)); + ctx.label_secondary(ui, tl!("For page sizes and margins."), t); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); - segmented_pill_choice( + ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t); + ctx.pill_choice( ui, t, &mut app.scroll_speed, @@ -557,34 +744,34 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } /// Content for Card 4: Identity -fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, _t: &Tokens) { +fn card_identity(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { ui.horizontal(|ui| { - let label = ui.label(tl!("Name on new comments")); + let label = ctx.label_primary(ui, tl!("Name on new comments"), t); ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(240.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); }); - ui.add(widgets::checkbox(&mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"))); + ctx.checkbox(ui, &mut app.comment_prefs.hide_empty_highlight_hover, tl!("Hide hover previews for highlights without comment text"), t); } /// Content for Card 5: Fill & Sign -fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); - ui.label(RichText::new(tl!("Text, marks and signatures become part of the page. Other comments stay editable.")).small().color(t.text_muted)); +fn card_fill_and_sign(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ctx.checkbox(ui, &mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"), t); + ctx.label_secondary(ui, tl!("Text, marks and signatures become part of the page. Other comments stay editable."), t); ui.add_space(8.0); - date_format(ui, app, t); + date_format(ui, app, t, ctx); } /// Content for Card 6: JavaScript -fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { let mut on = app.session.javascript(); - if ui.add(widgets::checkbox(&mut on, tl!("Enable Acrobat JavaScript"))).changed() { + if ctx.checkbox(ui, &mut on, tl!("Enable Acrobat JavaScript"), t).changed() { app.session.set_javascript(on); } - ui.label( - RichText::new(tl!( + ctx.label_secondary( + ui, + tl!( "Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work." - )) - .small() - .color(t.text_muted), + ), + t, ); } @@ -606,12 +793,12 @@ pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { } /// Fill & Sign date pattern and date language picker. -fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { +fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { use pdfcraft_engine::dates::{DATE_FORMATS, DATE_LANGUAGES, MAX_DATE_FORMAT_CHARS}; - ui.label(RichText::new(tl!("Date format")).font(theme::semibold(12.5))); + ctx.label(ui, tl!("Date format"), theme::semibold(12.5), t.text); let sample = |app: &PdfCraftApp, f: &str| format!("{} {f}", app.date_text(Some(f)).unwrap_or_default()); ui.horizontal(|ui| { - let label = ui.label(tl!("Fill & Sign dates")); + let label = ctx.label_primary(ui, tl!("Fill & Sign dates"), t); let current = app.session.date_format().to_string(); let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; craft_ui::ComboBox::from_id_salt("date-format") @@ -629,7 +816,7 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .labelled_by(label.id); }); ui.horizontal(|ui| { - let label = ui.label(tl!("Pattern")); + let label = ctx.label_primary(ui, tl!("Pattern"), t); let mut text = app.date_format_draft.clone().unwrap_or_else(|| app.session.date_format().to_string()); let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(160.0).char_limit(MAX_DATE_FORMAT_CHARS)).labelled_by(label.id); if edit.changed() { @@ -640,24 +827,25 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { app.date_format_draft = None; } match app.date_text(Some(&text)) { - Ok(today) => ui.label(RichText::new(crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)])).color(t.text_muted)), - Err(e) => ui.label(RichText::new(e).color(Color32::from_rgb(0xD1, 0x3B, 0x3B))), + Ok(today) => { + let today_str = crate::i18n::fmt(tl!("Today: {date}"), &[("date", &today)]); + ctx.label_secondary(ui, &today_str, t); + } + Err(e) => { + ctx.label(ui, &e, theme::regular(13.0), Color32::from_rgb(0xD1, 0x3B, 0x3B)); + } }; }); let bad = app.date_text(None).map(|today| pdfcraft_engine::dates::unwritable(&today)).unwrap_or_default(); if !bad.is_empty() { - ui.label( - RichText::new(crate::i18n::fmt( - tl!( - "{chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy." - ), - &[("chars", &bad)], - )) - .color(Color32::from_rgb(0xD1, 0x3B, 0x3B)), + let bad_msg = crate::i18n::fmt( + tl!("{chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy."), + &[("chars", &bad)], ); + ctx.label(ui, &bad_msg, theme::regular(13.0), Color32::from_rgb(0xD1, 0x3B, 0x3B)); } ui.horizontal(|ui| { - let label = ui.label(tl!("Language")); + let label = ctx.label_primary(ui, tl!("Language"), t); let (follow, selected, current, names) = { let drawable = |text: &str| picker_text_is_drawable(ui, text); let interface = crate::i18n::current().picker_name(drawable); @@ -687,19 +875,18 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .response .labelled_by(label.id); }); - ui.label( - RichText::new(crate::i18n::fmt( - tl!("yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is."), - &[("escape", "\\")], - )) - .small() - .color(t.text_muted), + let date_help = crate::i18n::fmt( + tl!( + "yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is." + ), + &[("escape", "\\")], ); + ctx.label_secondary(ui, &date_help, t); } /// Reading colours settings: presets, preview and custom replacement options. -fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - ui.label(RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); +fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) { + ctx.label(ui, tl!("Reading colours"), theme::semibold(13.0), t.text); let mode = app.reading_colors.mode_name(); ui.horizontal_wrapped(|ui| { for (name, label, colors) in [ @@ -727,7 +914,7 @@ fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) if response.clicked() { app.set_reading_colors(colors); } - ui.label(tl!(label)); + ctx.label(ui, tl!(label), theme::regular(12.0), t.text); }); } }); @@ -737,28 +924,53 @@ fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) let background = Color32::from_rgb(params.background[0], params.background[1], params.background[2]); egui::Frame::new().fill(background).inner_margin(12.0).corner_radius(CornerRadius::same(6)).show(ui, |ui| { ui.set_min_width((ui.available_width() - 24.0).max(0.0)); - ui.label(RichText::new(tl!("Reading colours")).size(16.0).color(text)); - ui.label(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text)); + ctx.label(ui, tl!("Reading colours"), theme::semibold(16.0), text); + ctx.label(ui, tl!("Colours are remapped while rendering; the document itself never changes."), theme::regular(13.0), text); }); } else { - ui.label(tl!("Original")); + ctx.label_primary(ui, tl!("Original"), t); } egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| { let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE }; let mut changed = false; if let Some(params) = colors.params.as_mut() { ui.horizontal(|ui| { - let text = ui.label(tl!("Text")); + let text = ctx.label_primary(ui, tl!("Text"), t); changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed(); - let background = ui.label(tl!("Page background")); + let background = ctx.label_primary(ui, tl!("Page background"), t); changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed(); }); - changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed(); - changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed(); + changed |= ctx.checkbox(ui, &mut params.only_black, tl!("Only change the colour of black text or line art"), t).changed(); + changed |= ctx.checkbox(ui, &mut params.line_art, tl!("Change the colour of line art as well as text"), t).changed(); } if changed { app.set_reading_colors(colors); } }); - ui.label(RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted)); + ctx.label_secondary(ui, tl!("Colours are remapped while rendering; the document itself never changes."), t); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_match_ranges_case_insensitive() { + let text = "Hide hover previews for highlights without comment text"; + let ranges = find_match_ranges(text, "hide hover"); + assert_eq!(ranges, vec![(0, 10)]); + assert_eq!(text.get(ranges[0].0..ranges[0].1), Some("Hide hover")); + + let text2 = "Highlight fields and highlight text"; + let ranges2 = find_match_ranges(text2, "highlight"); + assert_eq!(ranges2.len(), 2); + assert_eq!(text2.get(ranges2[0].0..ranges2[0].1), Some("Highlight")); + assert_eq!(text2.get(ranges2[1].0..ranges2[1].1), Some("highlight")); + + let ranges3 = find_match_ranges(text, "not found"); + assert!(ranges3.is_empty()); + + let ranges4 = find_match_ranges(text, ""); + assert!(ranges4.is_empty()); + } } diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 0a9b07764c..c19ffcb66a 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -318,7 +318,7 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok #[expect(unused_imports, reason = "re-exported for tests and backward compatibility")] pub(crate) use crate::dialogs::preferences::{ - PREFERENCES_BUTTON_ROW, PREFERENCES_MIN_SCROLL, date_language_picker_name, preferences_body, preferences_buttons, + PREFERENCES_BUTTON_ROW, PREFERENCES_MIN_SCROLL, date_language_picker_name, preferences_body, preferences_buttons, preferences_header, }; #[cfg(test)] diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index f43fdf49d8..43e1019c10 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -160,26 +160,48 @@ fn cancel_button_closes_preferences_dialog() { } #[test] -fn search_settings_filters_cards_in_real_time() { +fn search_settings_scans_and_highlights_in_situ_without_hiding_cards() { let mut h = harness(900.0); // Initially all card titles exist assert!(h.query_by_label("General & Interface").is_some()); assert!(h.query_by_label("Comment").is_some()); assert!(h.query_by_label("JavaScript").is_some()); - // Type in search box to filter to JavaScript card + // Type "Hide hover" in search box + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click(); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("Hide hover"); + h.run_steps(4); + + // In browser-style search, cards are NOT hidden + assert!(h.query_by_label("General & Interface").is_some()); + assert!(h.query_by_label("Comment").is_some()); + assert!(h.query_by_label("JavaScript").is_some()); + + // The setting "Hide hover previews for highlights without comment text" is present and matched + assert!(h.query_by_label("Hide hover previews for highlights without comment text").is_some()); + // Match badge displays 1 match + assert!(h.query_by_label("1 match").is_some()); + + // Clearing search clears match badge + h.get_by_label("Clear search").click(); + h.run_steps(4); + assert!(h.query_by_label("1 match").is_none()); + + // Search for "javascript" h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click(); h.run_steps(1); h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("javascript"); - h.run_steps(3); + h.run_steps(4); - // JavaScript card is visible, General & Interface is filtered out + // Cards remain visible and multiple matches are reported assert!(h.query_by_label("JavaScript").is_some()); - assert!(h.query_by_label("General & Interface").is_none()); + assert!(h.query_by_label("General & Interface").is_some()); + assert!(h.query_by_label_contains("matches").is_some()); // Clearing search shows everything again h.get_by_label("Clear search").click(); - h.run_steps(3); + h.run_steps(4); assert!(h.query_by_label("General & Interface").is_some()); assert!(h.query_by_label("Comment").is_some()); assert!(h.query_by_label("JavaScript").is_some()); From 80ffea6df419c5006a9f05d5d73ede05e2997e67 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:28:19 +0530 Subject: [PATCH 135/562] ui: remove misleading cancel button from preferences dialog --- crates/ui-egui/src/dialogs/preferences.rs | 5 +---- crates/ui-egui/tests/it/preferences_layout.rs | 12 ------------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 5ee1c4f615..b8b85f3c47 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -775,7 +775,7 @@ fn card_javascript(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &m ); } -/// Preferences dialog buttons: Cancel and OK on the right. Returns true to close. +/// Preferences dialog buttons: OK on the right. Returns true to close. pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { ui.add_space(10.0); let mut close = false; @@ -784,9 +784,6 @@ pub(crate) fn preferences_buttons(ui: &mut egui::Ui) -> bool { if widgets::pill_button(ui, tl!("OK"), true).clicked() { close = true; } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } }); }); close diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index 43e1019c10..cb5e2570dd 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -150,15 +150,6 @@ fn segmented_pill_controls_switch_modes_and_layouts() { assert_eq!(h.state().units, pdfcraft_ui_egui::units::Unit::Points); } -#[test] -fn cancel_button_closes_preferences_dialog() { - let mut h = harness(900.0); - assert!(h.state().dialog.is_some()); - h.get_by_label("Cancel").click(); - h.run_steps(3); - assert!(h.state().dialog.is_none()); -} - #[test] fn search_settings_scans_and_highlights_in_situ_without_hiding_cards() { let mut h = harness(900.0); @@ -213,10 +204,7 @@ fn preferences_dialog_scrollbar_spacing_and_controls() { let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(); let search = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").rect(); let ok = h.get_by_label("OK").rect(); - let cancel = h.get_by_label("Cancel").rect(); - assert!(cancel.min.x < ok.min.x); assert!(on_screen(ok, 600.0)); - assert!(on_screen(cancel, 600.0)); // Header title and search box are vertically aligned assert!((title.center().y - search.center().y).abs() <= 2.0); // Search box right edge is aligned within the dialog From 7f42c375dd5e929a26955b545ce9d8e0be8211d4 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 12:45:46 +0530 Subject: [PATCH 136/562] ui: translate search match-count text and remove unused toggle_pill --- crates/ui-egui/src/dialogs/preferences.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index b8b85f3c47..baa7094b2d 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -90,12 +90,6 @@ pub fn segmented_pill_choice_a11y_query( changed } -/// An independent toggle pill button (e.g. for standalone toggles). -#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] -pub fn toggle_pill(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { - toggle_pill_query(ui, t, active, label, "") -} - /// An independent toggle pill button with optional query highlighting. pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> bool { let mut changed = false; @@ -376,7 +370,7 @@ fn preference_search_box_at(ui: &mut egui::Ui, t: &Tokens, query: &mut String, m let badge_text = if !query.trim().is_empty() { let count = matches_count.unwrap_or(0); - if count == 1 { Some("1 match".to_string()) } else { Some(format!("{count} matches")) } + Some(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} match", "{n} matches")) } else { None }; From b5e847d3b93a0cafc7bf606a6bbae23a9451825c Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 14:18:21 +0530 Subject: [PATCH 137/562] ui: scroll to matches in segmented pill options, toggle pills, and theme cards --- crates/ui-egui/src/dialogs/appearance.rs | 49 ++++++++------- crates/ui-egui/src/dialogs/preferences.rs | 60 +++++++++++++------ crates/ui-egui/tests/it/preferences_layout.rs | 41 +++++++++++++ 3 files changed, 110 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 49888c9b2d..2b1b35400f 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -82,8 +82,8 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) { } /// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme. -/// Returns true when the card was clicked or activated via keyboard. -fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> bool { +/// Returns whether the card was clicked and the control's Response. +fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> (bool, egui::Response) { let mut card_clicked = false; let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) }; let scope_res = ui.scope_builder(egui::UiBuilder::new().id_salt(title).sense(egui::Sense::click()), |ui| { @@ -125,25 +125,26 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The if resp.clicked() || key_select { card_clicked = true; } - card_clicked + (card_clicked, resp) } /// The Appearance section of Preferences. #[cfg_attr(not(test), expect(dead_code, reason = "called by unit tests"))] pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { - let _ = preferences_section_with_query(ui, app, t, ""); + let mut ctx = crate::dialogs::preferences::PrefSearchContext::new("", false); + preferences_section_with_context(ui, app, t, &mut ctx); } -/// The Appearance section of Preferences with live search highlighting. -pub fn preferences_section_with_query(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, query: &str) -> usize { - let mut matches = 0; - let (app_label, c) = crate::dialogs::preferences::highlight_widget_text(tl_ctx!("theme", "Appearance"), query, theme::semibold(13.0), t.text); - matches += c; - ui.label(app_label); +/// The Appearance section of Preferences with live search highlighting and scrolling context. +pub(crate) fn preferences_section_with_context( + ui: &mut egui::Ui, + app: &mut PdfCraftApp, + t: &Tokens, + ctx: &mut crate::dialogs::preferences::PrefSearchContext, +) { + ctx.label(ui, tl_ctx!("theme", "Appearance"), theme::semibold(13.0), t.text); ui.horizontal(|ui| { - let (label_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Appearance mode"), query, theme::regular(13.0), t.text); - matches += c; - let label = ui.label(label_text); + let label = ctx.label(ui, tl!("Appearance mode"), theme::regular(13.0), t.text); let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { @@ -157,23 +158,29 @@ pub fn preferences_section_with_query(ui: &mut egui::Ui, app: &mut PdfCraftApp, }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; - matches += crate::dialogs::preferences::find_match_ranges(tl!("Light theme"), query).len(); - matches += crate::dialogs::preferences::find_match_ranges(tl!("Dark theme"), query).len(); + let light_title = tl!("Light theme"); + let dark_title = tl!("Dark theme"); + let light_matches = crate::dialogs::preferences::find_match_ranges(light_title, ctx.query).len(); + let dark_matches = crate::dialogs::preferences::find_match_ranges(dark_title, ctx.query).len(); + ctx.total_matches += light_matches + dark_matches; + ui.columns(2, |cols| { let [light, dark] = cols else { return }; - if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES, query) { + let (light_clicked, light_resp) = card(light, t, light_title, light_active, LIGHT_THEMES, ctx.query); + if light_clicked { app.execute(mode_command(ThemePreference::Light)); } - if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES, query) { + ctx.scroll_if_first_match(&light_resp, light_matches); + + let (dark_clicked, dark_resp) = card(dark, t, dark_title, !light_active, DARK_THEMES, ctx.query); + if dark_clicked { app.execute(mode_command(ThemePreference::Dark)); } + ctx.scroll_if_first_match(&dark_resp, dark_matches); }); ui.add_space(4.0); // Acrobat's "Highlight existing fields": shared with the toggle in the form bar. - let (fields_text, c) = crate::dialogs::preferences::highlight_widget_text(tl!("Highlight fields"), query, theme::regular(13.0), t.text); - matches += c; - ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, fields_text)); - matches + ctx.checkbox(ui, &mut app.view_defaults.highlight_fields, tl!("Highlight fields"), t); } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index baa7094b2d..42f9f17798 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -31,18 +31,20 @@ pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, /// A segmented pill selection bar with separate accessibility labels. #[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { - segmented_pill_choice_a11y_query(ui, t, current, options, "") + segmented_pill_choice_a11y_query(ui, t, current, options, "").0 } /// A segmented pill selection bar with separate accessibility labels and optional query highlighting. +/// Returns whether the selection changed and the Response of the first matching option, if any. pub fn segmented_pill_choice_a11y_query( ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)], query: &str, -) -> bool { +) -> (bool, Option) { let mut changed = false; + let mut first_match_resp: Option = None; egui::Frame::new() .fill(t.field) .stroke(Stroke::new(1.0, t.border)) @@ -60,6 +62,10 @@ pub fn segmented_pill_choice_a11y_query( let (rect, resp) = ui.allocate_exact_size(vec2(w + 18.0, 24.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, is_active, a11y_label)); + if first_match_resp.is_none() && !find_match_ranges(label, query).is_empty() { + first_match_resp = Some(resp.clone()); + } + if is_active { ui.painter().rect(rect, CornerRadius::same(12), t.accent, Stroke::NONE, egui::StrokeKind::Inside); } else { @@ -87,11 +93,12 @@ pub fn segmented_pill_choice_a11y_query( } }); }); - changed + (changed, first_match_resp) } /// An independent toggle pill button with optional query highlighting. -pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> bool { +/// Returns whether the active state changed and the control's Response. +pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str, query: &str) -> (bool, egui::Response) { let mut changed = false; let is_active = *active; let font = theme::medium(12.0); @@ -120,7 +127,7 @@ pub fn toggle_pill_query(ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label *active = !*active; changed = true; } - changed + (changed, resp) } fn layout_pill_label(ui: &egui::Ui, label: &str, query: &str, font: egui::FontId, normal_color: Color32) -> std::sync::Arc { @@ -282,14 +289,18 @@ impl<'a> PrefSearchContext<'a> { Self { query, total_matches: 0, first_match_scrolled: false, should_scroll } } - pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { - let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); - self.total_matches += count; - let resp = ui.add(widgets::checkbox(checked, widget_text)); + pub fn scroll_if_first_match(&mut self, resp: &egui::Response, count: usize) { if count > 0 && self.should_scroll && !self.first_match_scrolled { resp.scroll_to_me(Some(egui::Align::Center)); self.first_match_scrolled = true; } + } + + pub fn checkbox(&mut self, ui: &mut egui::Ui, checked: &mut bool, label: &str, t: &Tokens) -> egui::Response { + let (widget_text, count) = highlight_widget_text(label, self.query, theme::regular(13.0), t.text); + self.total_matches += count; + let resp = ui.add(widgets::checkbox(checked, widget_text)); + self.scroll_if_first_match(&resp, count); resp } @@ -297,10 +308,7 @@ impl<'a> PrefSearchContext<'a> { let (widget_text, count) = highlight_widget_text(text, self.query, font, color); self.total_matches += count; let resp = ui.label(widget_text); - if count > 0 && self.should_scroll && !self.first_match_scrolled { - resp.scroll_to_me(Some(egui::Align::Center)); - self.first_match_scrolled = true; - } + self.scroll_if_first_match(&resp, count); resp } @@ -317,24 +325,38 @@ impl<'a> PrefSearchContext<'a> { } pub fn pill_choice(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { + let mut matches = 0; for &(_, label) in options { - self.total_matches += find_match_ranges(label, self.query).len(); + matches += find_match_ranges(label, self.query).len(); } + self.total_matches += matches; let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); - segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query) + let (changed, match_resp) = segmented_pill_choice_a11y_query(ui, t, current, &with_a11y, self.query); + if let Some(resp) = match_resp { + self.scroll_if_first_match(&resp, matches); + } + changed } pub fn pill_choice_a11y(&mut self, ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { + let mut matches = 0; for &(_, label, _) in options { - self.total_matches += find_match_ranges(label, self.query).len(); + matches += find_match_ranges(label, self.query).len(); } - segmented_pill_choice_a11y_query(ui, t, current, options, self.query) + self.total_matches += matches; + let (changed, match_resp) = segmented_pill_choice_a11y_query(ui, t, current, options, self.query); + if let Some(resp) = match_resp { + self.scroll_if_first_match(&resp, matches); + } + changed } pub fn toggle_pill(&mut self, ui: &mut egui::Ui, t: &Tokens, active: &mut bool, label: &str) -> bool { let matches = find_match_ranges(label, self.query).len(); self.total_matches += matches; - toggle_pill_query(ui, t, active, label, self.query) + let (changed, resp) = toggle_pill_query(ui, t, active, label, self.query); + self.scroll_if_first_match(&resp, matches); + changed } } @@ -647,7 +669,7 @@ fn card_appearance(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &m ui.ctx().request_repaint(); } ui.add_space(4.0); - ctx.total_matches += crate::appearance::preferences_section_with_query(ui, app, t, ctx.query); + crate::appearance::preferences_section_with_context(ui, app, t, ctx); ui.add_space(8.0); reading_colours_section(ui, app, t, ctx); } diff --git a/crates/ui-egui/tests/it/preferences_layout.rs b/crates/ui-egui/tests/it/preferences_layout.rs index cb5e2570dd..e0a5d4e1cb 100644 --- a/crates/ui-egui/tests/it/preferences_layout.rs +++ b/crates/ui-egui/tests/it/preferences_layout.rs @@ -210,3 +210,44 @@ fn preferences_dialog_scrollbar_spacing_and_controls() { // Search box right edge is aligned within the dialog assert!(search.max.x <= ok.max.x); } + +#[test] +fn search_scrolls_option_only_and_theme_card_matches_into_visible_viewport() { + let mut h = harness(480.0); + let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(); + let ok = h.get_by_label("OK").rect(); + + // "Two-page view" is an option in Card 3 (Documents and view). + // In a 480 pt window, it starts out below the visible scroll viewport. + let opt_before = h.get_by_label("Two-page view").rect(); + assert!(opt_before.min.y >= ok.min.y, "Two-page option starts out below the visible viewport: {opt_before:?} vs OK {ok:?}"); + + // Searching "Two-page" (which only occurs inside that segmented pill option) + // auto-scrolls the matching option into the visible viewport between title and OK. + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click(); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("Two-page"); + h.run_steps(4); + + let opt_after = h.get_by_label("Two-page view").rect(); + assert!( + opt_after.min.y >= title.max.y && opt_after.max.y <= ok.min.y, + "Two-page option scrolled into visible scroll viewport: {opt_after:?} between title {title:?} and OK {ok:?}" + ); + + // Clear search and test a theme-card-only match + h.get_by_label("Clear search").click(); + h.run_steps(4); + + // Search for "Dark theme" (which occurs only in the dark theme card in Card 2). + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").click(); + h.run_steps(1); + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Search settings…").type_text("Dark theme"); + h.run_steps(4); + + let dark_card = h.get_by_label("Dark theme").rect(); + assert!( + dark_card.min.y >= title.max.y && dark_card.max.y <= ok.min.y, + "Dark theme card scrolled into visible viewport: {dark_card:?} between title {title:?} and OK {ok:?}" + ); +} From d266527b98b7f684aaa73339dbbb0e0f68be07cf Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 14:41:41 +0530 Subject: [PATCH 138/562] ui: fold dotted and dotless i in preferences text search --- crates/ui-egui/src/dialogs/preferences.rs | 59 ++++++++++++++++++----- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 42f9f17798..49609461d8 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -171,6 +171,20 @@ pub fn preference_card( .inner } +/// Folds a character for case-insensitive search, folding dotted/dotless i variants +/// ('I', 'i', 'İ', 'ı') to 'i', Greek sigmas to 'σ', and otherwise following `char::to_lowercase`. +fn fold_search_char(c: char, mut f: impl FnMut(char)) { + match c { + 'I' | 'i' | '\u{130}' | '\u{131}' => f('i'), + 'Σ' | 'σ' | 'ς' => f('σ'), + _ => { + for lc in c.to_lowercase() { + f(lc); + } + } + } +} + /// Finds non-overlapping (start_byte, end_byte) slices in `text` matching `query` case-insensitively. /// All byte indices are guaranteed to align to valid char boundaries. pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { @@ -178,7 +192,10 @@ pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { if q.is_empty() { return Vec::new(); } - let q_chars: Vec = q.chars().flat_map(char::to_lowercase).collect(); + let mut q_chars = Vec::new(); + for c in q.chars() { + fold_search_char(c, |ch| q_chars.push(ch)); + } if q_chars.is_empty() { return Vec::new(); } @@ -196,20 +213,19 @@ pub fn find_match_ranges(text: &str, query: &str) -> Vec<(usize, usize)> { break; } let c = char_indices.get(i + text_char_count).map(|(_, ch)| *ch).unwrap_or('\0'); - let mut c_matched = false; - for lc in c.to_lowercase() { - if q_idx < q_chars.len() && lc == q_chars.get(q_idx).copied().unwrap_or('\0') { - q_idx += 1; - c_matched = true; - } else { - c_matched = false; - break; + let mut c_folded = ['\0'; 4]; + let mut len = 0; + fold_search_char(c, |lc| { + if len < c_folded.len() { + c_folded[len] = lc; + len += 1; } - } - if !c_matched { + }); + if len == 0 || q_idx + len > q_chars.len() || q_chars.get(q_idx..q_idx + len) != Some(&c_folded[..len]) { matched = false; break; } + q_idx += len; text_char_count += 1; } @@ -986,4 +1002,25 @@ mod tests { let ranges4 = find_match_ranges(text, ""); assert!(ranges4.is_empty()); } + + #[test] + fn test_find_match_ranges_turkish_i() { + let text = "Doldur ve İmzala"; + let ranges = find_match_ranges(text, "imzala"); + assert_eq!(ranges.len(), 1); + assert_eq!(text.get(ranges[0].0..ranges[0].1), Some("İmzala")); + + let ranges2 = find_match_ranges(text, "İMZALA"); + assert_eq!(ranges2.len(), 1); + assert_eq!(text.get(ranges2[0].0..ranges2[0].1), Some("İmzala")); + + let text2 = "Doldur ve imzala"; + let ranges3 = find_match_ranges(text2, "İmzala"); + assert_eq!(ranges3.len(), 1); + assert_eq!(text2.get(ranges3[0].0..ranges3[0].1), Some("imzala")); + + let ranges4 = find_match_ranges(text, "ımzala"); + assert_eq!(ranges4.len(), 1); + assert_eq!(text.get(ranges4[0].0..ranges4[0].1), Some("İmzala")); + } } From 4a0b1dd158f4f8a73af1b2dc1e10f97dda576dc7 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:02:12 +0500 Subject: [PATCH 139/562] Repair scan: an object whose header names another generation is still found (#5580) (#5584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * A rebuilt xref finds an object whose header names another generation (#5580) `poppler-85140-0.pdf` is 340 bytes with no cross-reference table, and the header of its one page reads `3 18446744073709551616 obj` while the page tree points at `3 0 R`. Before #427 the digits wrapped and 2^64 wrapped to exactly 0, so the header read as `3 0 obj` and the file opened by accident. Once an overflowing integer became a real, the object was unreachable and the file stopped opening at all, which `cargo xtask check` reports as a regression. The generation is not really the point. `3 99999 obj` with the same page tree is refused in exactly the same way on main -- the control for the new test fails on that row first, with "page 1 does not exist" -- so this is a mismatched generation in a rebuilt table, not an integer overflow. A table rebuilt by scanning the file has no revision history to tell two generations of an object apart, and a file that needs rebuilding references everything as `N 0 R` whatever its headers say. So, in the reconstruction path only: - a generation number that cannot be read is taken as 0, the value such a file's references use (`read_object_header`). A generation is at most 65,535 (ISO 32000-2 §7.5.4), so there is nothing to lose by it; - every scanned object is also filed under generation 0, with `or_insert` so a real `N 0 obj` is never displaced by the alias; - the object is read back behind the same lenient header, since reading it parses the header again and the strict identifier check rejected it (`read_repaired_body`, gated on `repaired`). Reading numbers anywhere else is unchanged, and a table read from the file's own xref keeps the exact identifier match. The scan already files objects found in object streams under generation 0, and cos's table parser already takes an unreadable generation as 0, so this follows what the code does elsewhere. The test covers generations 0, 99999 and 2^64 with no table and no `startxref`, and asserts the page is found at its own size. Its fixture is built in the test rather than taken from the corpus, so it holds without the pinned checkout. * The generation-0 stand-in is one key, not a second object Two consequences of filing a scanned object under generation 0 as well, both from review. `XRef::objects` walks the table's keys, so an object with a stand-in came out once per key: a document whose page tree cannot be read is recovered by looking for objects that are pages, and it reported the page twice. Enumeration now yields one object per location, after the sort it already does. Both keys stay for lookups; only this walk is deduplicated. And `or_insert` never replaces, so a later `3 1 obj` moved the exact `3 1` key to the later body while the `3 0` stand-in stayed on the earlier one -- `3 0 R` and `3 1 R` would then read different objects. The scan now tracks which generation-0 keys are stand-ins: a real `N 0 obj` takes the key and keeps it, and a later header for the same number replaces a stand-in. The tests check both through what comes out: page 2 must not exist for a single-page file recovered without a page tree, and with two `3 1 obj` headers the page comes back at the later body's 300x150 rather than the earlier 200x100. * M2: the repair scan keeps its place on a non-header, and an object stream's first member is enumerated (#5580) - read_object_header's lenient path works on a clone, so `42 3 0 obj` no longer costs the header after the stray number (CodeRabbit, minor). - XRef::objects deduplicates by location and object number: an object stream and its first member share (offset, 0) and are two objects (CodeRabbit, minor). - vendor/README.md: hayro-syntax patch (34) and its tests. Both new tests fail with their fix reverted. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/render/src/raster/tests.rs | 93 ++++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-syntax/src/xref.rs | 125 +++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 1c24408e1b..67b9c86906 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -8491,6 +8491,99 @@ mod devicen_inks; // patch 27). mod isolated_cmyk_groups; +/// The generation-0 stand-in a rebuilt table files an object under must not make the object a +/// second object: enumeration yielded it once per key, so a document recovered without a readable +/// page tree reported the page twice (noted in review of #5580). +/// +/// And a later header for the same object number replaces the stand-in, so `N 0 R` cannot be left +/// pointing at a body the exact key no longer names. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn a_rebuilt_tables_generation_0_stand_in_is_not_a_second_object() { + // No table, no `startxref`, and no readable page tree: the pages are recovered by looking for + // objects that are pages, which is where a doubled object shows up as a doubled page. + let pdf = "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 3 99999 obj << /Type /Page /MediaBox [0 0 200 100] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length 0 >> stream\n\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n"; + let mut r = PageRenderer::new(Arc::new(pdf.to_string().into_bytes()), RenderConfig::default()); + let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(first.error.is_none(), "the page is recovered: {:?}", first.error); + let second = r.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(second.error.is_some(), "and it is one page, not two: page 2 must not exist"); + + // Two headers for object 3, the second the one the exact key names: the stand-in follows it. + // `(B)` is drawn by the later body, so the page that comes back says which one was read. + let twice = "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 1 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 5 0 R >> endobj\n\ + 3 1 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] /Contents 5 0 R >> endobj\n\ + 5 0 obj << /Length 0 >> stream\n\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n"; + let mut r = PageRenderer::new(Arc::new(twice.to_string().into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "the page is found: {:?}", page.error); + assert_eq!((page.width, page.height), (300, 150), "`3 0 R` reads the later body, as `3 1` does"); +} + +/// A file whose cross-reference table has to be rebuilt keeps an object whose header names a +/// generation the reference does not. +/// +/// `poppler-85140-0.pdf` is 340 bytes with no table at all, and its one page's header reads +/// `3 18446744073709551616 obj` while the page tree points at `3 0 R`. Before #427 the digits +/// wrapped, and 2^64 wrapped to exactly 0, so the header read as `3 0 obj` and the file opened by +/// accident; once an overflowing integer became a real the object was unreachable and the file +/// stopped opening at all, which `cargo xtask check` reports as a regression (#5580). +/// +/// A rebuilt table has no revision history to tell two generations of an object apart -- the scan +/// already files objects found in object streams under generation 0 -- so the object number is +/// what identifies one. A readable generation that simply is not the one referenced +/// (`3 99999 obj`) was lost the same way, and is covered here too. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn a_rebuilt_table_finds_an_object_whose_header_names_another_generation() { + for generation in [ + // The generation the reference names, which always worked. + "0", + // One that reads as an integer but is not the one referenced. + "99999", + // And one no generation may be: 2^64, which is what the reported file carries. A + // generation number is at most 65,535 (ISO 32000-2 §7.5.4). + "18446744073709551616", + ] { + // No table and no `startxref`, so the objects are recovered by scanning the file. + let pdf = format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 {generation} obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >> endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n" + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "generation {generation}: the page is found: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 100), "generation {generation}: and it is the page's own size"); + } + + // A stray number before a header (`42 3 0 obj`) is not a header, and must not cost the one + // after it: the lenient read leaves the scan where it was when it fails (noted in review). + let pdf = "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 42 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] >> endobj\n\ + trailer << /Root 1 0 R >>\n\ + %%EOF\n"; + let mut r = PageRenderer::new(Arc::new(pdf.to_string().into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "the header after a stray number is found: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 100)); +} + /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting /// but draws no pixels itself. #[test] diff --git a/vendor/README.md b/vendor/README.md index 3173659698..35cca11813 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -43,7 +43,7 @@ Other resource categories retain their existing parent-chain lookup. | 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, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | | 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; (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 | (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`; (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` | | 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; (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 | (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`; (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) | +| 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; (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 | (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`; (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) | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 356bad6b15..d3ab7d324f 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -4,6 +4,7 @@ use crate::crypto::{DecryptionError, DecryptionTarget, Decryptor, get}; use crate::data::Data; use crate::metadata::Metadata; use crate::object::Name; +use crate::object::Number; use crate::object::ObjectIdentifier; use crate::object::Stream; use crate::object::dict::keys::{ @@ -80,6 +81,65 @@ pub(crate) fn fallback(data: PdfData, password: &[u8]) -> Option { } } +/// PdfCraft patch: an `N G obj` header for the repair scan, taking a generation number that +/// cannot be read as 0. +/// +/// A generation number is at most 65,535 (ISO 32000-2 §7.5.4), so `3 18446744073709551616 obj` is +/// malformed and [`ObjectIdentifier::read`] is right to refuse it everywhere else. This scan runs +/// only when a file's cross-reference table is unusable and its objects are being recovered by +/// reading the file, where refusing the header drops the object and whatever it holds (#5580). +/// Before #427 the digits wrapped, and 2^64 wrapped to exactly 0, which is the only reason such a +/// file used to open. +/// +/// Only here: reading numbers anywhere else is unchanged. +fn read_object_header<'a>(r: &mut Reader<'a>, ctx: &ReaderContext<'a>) -> Option { + let mut strict = r.clone(); + if let Some(obj_id) = strict.read::(ctx) { + *r = strict; + return Some(obj_id); + } + // On a clone as well, so a header that isn't one leaves `r` where it was for the caller. + let mut lenient = r.clone(); + let obj_number = lenient.read_without_context::()?; + lenient.skip_white_spaces_and_comments(); + // Any number at all in the generation position, including one no generation may be. + lenient.skip::(false)?; + lenient.skip_white_spaces_and_comments(); + lenient.forward_tag(b"obj")?; + *r = lenient; + Some(ObjectIdentifier::new(obj_number, 0)) +} + +/// PdfCraft patch: the body of the object at `offset` whose `N G obj` header cannot be read +/// strictly, for a table rebuilt by scanning the file (#5580). The steps are +/// `IndirectObject::read`'s, with [`read_object_header`] in place of the strict identifier. +fn read_repaired_body<'a, T>( + data: &'a [u8], + offset: usize, + id: ObjectIdentifier, + ctx: &ReaderContext<'a>, +) -> Option +where + T: ObjectLike<'a>, +{ + let mut r = Reader::new(data); + r.jump(offset); + // The number is what identifies the object; the generation is what could not be read. + if read_object_header(&mut r, ctx)?.obj_number != id.obj_number { + return None; + } + let mut ctx = ctx.clone(); + if ctx.parent_chain_contains(&id) { + warn!("cycle detected in indirect object: {id:?}"); + + return None; + } + ctx.set_obj_number(id); + ctx.parent_chain_push(id); + r.skip_white_spaces_and_comments(); + r.read_with_context::(&ctx) +} + fn fallback_xref_map<'a>(data: &'a PdfData, password: &[u8]) -> (XrefMap, Option>) { fallback_xref_map_inner(data, ReaderContext::dummy(), true, password) } @@ -91,6 +151,10 @@ fn fallback_xref_map_inner<'a>( password: &[u8], ) -> (XrefMap, Option>) { let mut xref_map = FxHashMap::default(); + // PdfCraft patch: the generation-0 keys below that stand in for a header with another + // generation, so a later header can replace one while a real `N 0 obj` is never displaced + // (noted in review of #5580). + let mut aliases: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); let mut trailer_dicts = vec![]; let mut root_ref = None; @@ -105,12 +169,32 @@ fn fallback_xref_map_inner<'a>( // First try to check if we have an object identifier. if r.peek_byte().is_some_and(|b: u8| b.is_ascii_digit()) { - if let Some(obj_id) = r.read::(&dummy_ctx) { + if let Some(obj_id) = read_object_header(&mut r, &dummy_ctx) { let mut cloned = r.clone(); // Check that the object following it is actually valid before inserting it. cloned.skip_white_spaces_and_comments(); if cloned.skip::>(false).is_some() { xref_map.insert(obj_id, EntryType::Normal(cur_pos)); + // PdfCraft patch: also reachable by object number alone, so a reference that + // names a different generation than the header still finds it. A rebuilt + // table has no revision history to tell two generations apart, and a file + // that needs rebuilding references everything as `N 0 R` whatever its headers + // say: `poppler-85140-0.pdf` has one page whose header reads + // `3 18446744073709551616 obj` with a page tree pointing at `3 0 R`, so the + // page was lost and the file did not open at all (#5580). `or_insert`, so a + // real `N 0 obj` is never displaced by this alias. + let by_number = ObjectIdentifier::new(obj_id.obj_number, 0); + if obj_id.gen_number == 0 { + // A real `N 0 obj`: it owns the key from here on, and the last one wins + // as every other entry does. + aliases.remove(&by_number); + } else if !xref_map.contains_key(&by_number) || aliases.contains(&by_number) { + // Stand in for it, and let a later header for the same number replace the + // stand-in -- the exact key moves on, so an alias left behind would send + // `N 0 R` to a body the exact key no longer names (noted in review). + xref_map.insert(by_number, EntryType::Normal(cur_pos)); + aliases.insert(by_number); + } last_obj_num = Some(obj_id); dummy_ctx.set_obj_number(obj_id); } @@ -498,6 +582,14 @@ impl XRef { // Try to yield in the order the objects appeared in the // PDF. elements.sort_by(|e1, e2| e1.1.cmp(&e2.1)); + // PdfCraft patch: one object per location. A rebuilt table can hold two keys for + // one object -- its own generation and the generation-0 stand-in (#5580) -- and + // enumeration yielded the object once for each, so a document recovered by + // `Pages::new_brute_force` reported the page twice (noted in review). Both keys + // stay for lookups; only this walk is deduplicated. The object number is part of + // the key: an object stream at (offset, 0) and its first member, also + // (offset, 0), are two objects. + elements.dedup_by(|a, b| a.1 == b.1 && a.0.obj_number == b.0.obj_number); let mut iter = elements.into_iter(); @@ -653,10 +745,27 @@ impl XRef { } if let Some(object) = r.read_with_context::>(&ctx) { - if object.id() == &id { + // PdfCraft patch: in a table rebuilt by scanning the file, the object number + // is what identifies an object. There is no revision history to tell two + // generations apart, the scan already files objects found in object streams + // under generation 0, and a malformed generation in a header would otherwise + // make the object unreadable through the reference that names it (#5580). A + // table read from the file's own xref keeps the exact match. + if object.id() == &id || (repaired && object.id().obj_number == id.obj_number) { return Some(Fetched::Object(object.get())); } } else { + // PdfCraft patch: the strict read of the header failed. In a table rebuilt by + // scanning the file that can be a generation number no generation may be -- + // `3 18446744073709551616 obj` -- and refusing it loses the object the + // reference names (#5580). The object number still has to be the one asked + // for; only an unreadable generation is tolerated, and only here. + if repaired + && let Some(object) = + read_repaired_body::(repr.data.get().as_ref(), offset, id, &ctx) + { + return Some(Fetched::Object(object)); + } // There is a valid object at the offset, it's just not of the type the caller // expected, which is fine. if r.skip::>>(false).is_some() { @@ -1734,6 +1843,18 @@ mod tests { } } + #[test] + fn an_object_stream_and_its_first_member_are_both_enumerated() { + // Both sit at (stream offset, 0) in `objects`' ordering; one object per location must + // not cost either of them. + let pdf = crate::Pdf::new(object_stream_fixture(false)).unwrap(); + let objects: Vec<_> = pdf.xref().objects().into_iter().collect(); + assert!(objects.iter().any(|o| matches!(o, Object::Stream(_)))); + assert!(objects.iter().any( + |o| matches!(o, Object::Dict(d) if d.get::(b"Marker") == Some(123)) + )); + } + #[test] fn recursive_object_stream_locations_fail_without_recursing() { let pdf = crate::Pdf::new(object_stream_fixture(true)).unwrap(); From d7dd1de647ee186d2161b2c464bdb8bb68f085fb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:07:42 +0100 Subject: [PATCH 140/562] Reject '+' sign in page selection numbers (#5715) Found while testing crates/ui-egui/src/panels/page_selection_ui.rs:24-27 (parse, `number` closure) Co-authored-by: Claude Haiku 5.5 --- crates/ui-egui/src/panels/page_selection_ui.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/page_selection_ui.rs b/crates/ui-egui/src/panels/page_selection_ui.rs index eb97b16b09..deb811768e 100644 --- a/crates/ui-egui/src/panels/page_selection_ui.rs +++ b/crates/ui-egui/src/panels/page_selection_ui.rs @@ -22,7 +22,9 @@ pub(crate) fn parse(text: &str, count: usize) -> Result, PrintError> let mut pages = BTreeSet::new(); let mut budget = 1_000_000usize; let number = |token: &str| { - let n = token.trim().parse::().map_err(|_| PrintError::NotAPage(token.into()))?; + let t = token.trim(); + let n = + if t.bytes().all(|b| b.is_ascii_digit()) { t.parse::().ok() } else { None }.ok_or_else(|| PrintError::NotAPage(token.into()))?; if n == 0 || n > count { Err(PrintError::OutOfRange(n, count)) } else { Ok(n - 1) } }; for part in text.split([',', ';']).map(str::trim) { @@ -132,6 +134,13 @@ impl PageSelectionInput { mod tests { use super::*; + #[test] + fn plus_sign_is_not_a_page_number() { + for text in ["+3", "+2-4", "2-+4"] { + assert!(matches!(parse(text, 100), Err(PrintError::NotAPage(_))), "{text}"); + } + } + #[test] fn selection_ranges_are_sorted_unique_and_bounded() { let pages = parse("1,12,16-18,89,92,93,97,99", 100).unwrap(); From 8baae14c34e393a4fda0646bc6a9d1a894af5a82 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 16:36:17 +0530 Subject: [PATCH 141/562] fix(ui): prevent panel resize handle collision with scrollbar (#5730) --- crates/ui-egui/src/panels/side_panels.rs | 16 ++++ crates/ui-egui/src/panels/tools.rs | 2 +- crates/ui-egui/tests/it/side_panels.rs | 106 ++++++++++++++++++++++- 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index 447aeac9fb..c546251474 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -415,6 +415,22 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.panel)) .body_margin(egui::Margin::ZERO) .show(ui, &open, |pane| Some(state_of(&panels, *pane)), &mut Content { app }); + + // Ensure panel resize handles do not bleed inward over panel interiors and scrollbars. + // The handle's active target is kept to the divider strip centered on the border. + for pane in &open { + let host_id = egui::Id::unique("pdfcraft-side-panels"); + let s_id = host_id.with(("pane", *pane)); + let resize_id = s_id.with("resize"); + if let Some(resp) = ui.ctx().read_response(resize_id) { + let edge_layer = egui::LayerId::new(ui.layer_id().order, s_id.with("resize-layer")); + let edge_ui = ui.new_child(egui::UiBuilder::new().layer_id(edge_layer)); + let old_rect = resp.rect; + let border = (old_rect.min.x + old_rect.max.x) / 2.0; + let new_rect = egui::Rect::from_x_y_ranges((border - 2.0)..=(border + 2.0), old_rect.y_range()); + edge_ui.interact(new_rect, resize_id, egui::Sense::drag()); + } + } for request in output.requests { match request { PaneRequest::Resize(pane, width) => app.side_panels.set_width(pane, width), diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 73326fe571..81695dc250 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -17,7 +17,7 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if app.left == LeftPanel::Tool("organize") && app.active.and_then(|i| app.views.get(i)).is_some_and(|v| !v.organize) { app.left = crate::organize_panel::workspace_panel(app.mode); } - egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( + egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 14, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( ui, |ui| match app.left { // Organize pages has its own panel whenever the grid shows (with a document open). diff --git a/crates/ui-egui/tests/it/side_panels.rs b/crates/ui-egui/tests/it/side_panels.rs index 2436d41b61..6febd26703 100644 --- a/crates/ui-egui/tests/it/side_panels.rs +++ b/crates/ui-egui/tests/it/side_panels.rs @@ -15,8 +15,8 @@ trailer << /Root 1 0 R >> const WIDTH: f32 = 1400.0; const NO_BOOKMARKS: &str = "This document has no bookmarks."; -fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(WIDTH, 900.0)).build_eframe(move |_cc| { +fn harness_sized(size: egui::Vec2, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); @@ -27,6 +27,10 @@ fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, P h } +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + harness_sized(egui::vec2(WIDTH, 900.0), setup) +} + fn rail(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { h.get_by_role_and_label(Role::Button, "Bookmarks").rect() } @@ -131,3 +135,101 @@ fn f4_toggles_the_side_panel_and_shift_f4_the_tools_panel() { h.run_steps(3); h.get_by_label("Expand all tools"); } + +#[test] +fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { + // 1. Left docking: tools panel on the left (resize handle on the right, scrollbar on the right). + let mut h = harness_sized(egui::vec2(1200.0, 450.0), |app| { + app.all_tools_expanded = true; + }); + h.run_steps(4); + + let splitters: Vec<_> = h.get_all_by_label("Resize panels").collect(); + assert!(!splitters.is_empty(), "Splitter handle should exist"); + let splitter_rect = splitters[0].rect(); + + // Find the scrollbar belonging to the Tools panel (left side of the window). + let sb_node = h.get_all_by_role(Role::ScrollBar).find(|s| s.rect().max.x < 300.0).expect("Tools panel scrollbar should exist"); + let sb_rect = sb_node.rect(); + + // The scrollbar must sit strictly inside the panel, with clear gutter before the splitter. + assert!(sb_rect.max.x < splitter_rect.min.x); + assert!(splitter_rect.min.x - sb_rect.max.x >= 12.0, "Gutter must be at least 12px"); + + // Hovering directly over the scrollbar must NOT trigger panel resize cursor. + for x in [sb_rect.min.x, sb_rect.center().x, sb_rect.max.x - 0.5] { + h.hover_at(egui::pos2(x, sb_rect.center().y)); + h.run_steps(1); + let cursor = h.output().platform_output.cursor_icon; + assert_ne!(cursor, egui::CursorIcon::ResizeHorizontal, "Hovering scrollbar at x={x} should not trigger resize handle"); + } + + // Hovering directly on the splitter handle MUST trigger panel resize cursor. + h.hover_at(splitter_rect.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + + // Dragging the scrollbar down scrolls the content up without resizing the panel. + let initial_width = h.state().side_panels.tools_width; + let first_row_before = h.get_by_label("Edit a PDF").rect().top(); + h.hover_at(sb_rect.center()); + h.run_steps(1); + h.drag_at(sb_rect.center()); + h.run_steps(1); + h.hover_at(sb_rect.center() + egui::vec2(0.0, 60.0)); + h.run_steps(1); + h.drop_at(sb_rect.center() + egui::vec2(0.0, 60.0)); + h.run_steps(2); + let first_row_after = h.get_by_label("Edit a PDF").rect().top(); + assert!(first_row_after < first_row_before, "Content should scroll up when dragging scrollbar down"); + assert_eq!(h.state().side_panels.tools_width, initial_width, "Dragging scrollbar must not resize the panel"); + + // Dragging the splitter smoothly resizes the panel. + h.hover_at(splitter_rect.center()); + h.run_steps(1); + h.drag_at(splitter_rect.center()); + h.run_steps(1); + h.hover_at(splitter_rect.center() + egui::vec2(40.0, 0.0)); + h.run_steps(1); + h.drop_at(splitter_rect.center() + egui::vec2(40.0, 0.0)); + h.run_steps(2); + let after_width = h.state().side_panels.tools_width; + assert!(after_width > initial_width); + assert!((after_width - (initial_width + 40.0)).abs() <= 5.0); + + // 2. Right docking: tools panel on the right (resize handle on the left). + let mut h_right = harness_sized(egui::vec2(1200.0, 450.0), |app| { + app.set_option("tools-side", "right").unwrap(); + app.all_tools_expanded = true; + }); + h_right.run_steps(4); + + let splitters_right: Vec<_> = h_right.get_all_by_label("Resize panels").collect(); + assert!(!splitters_right.is_empty()); + let right_splitter_rect = splitters_right[0].rect(); + + // Splitter is at the left boundary of the right-docked pane. + h_right.hover_at(right_splitter_rect.center()); + h_right.run_steps(1); + assert_eq!(h_right.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + + // Dragging the splitter on right-docked pane smoothly resizes. + let initial_right_width = h_right.state().side_panels.tools_width; + h_right.hover_at(right_splitter_rect.center()); + h_right.run_steps(1); + h_right.drag_at(right_splitter_rect.center()); + h_right.run_steps(1); + // Moving left increases width of right-docked pane + h_right.hover_at(right_splitter_rect.center() - egui::vec2(30.0, 0.0)); + h_right.run_steps(1); + h_right.drop_at(right_splitter_rect.center() - egui::vec2(30.0, 0.0)); + h_right.run_steps(2); + let after_right_width = h_right.state().side_panels.tools_width; + assert!(after_right_width > initial_right_width); + assert!((after_right_width - (initial_right_width + 30.0)).abs() <= 5.0); + + // Inside the right-docked panel (e.g. 15px inside from the splitter), cursor is not resize. + h_right.hover_at(right_splitter_rect.center() + egui::vec2(15.0, 0.0)); + h_right.run_steps(1); + assert_ne!(h_right.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); +} From 89efc79ecde48da0b782bec4b3e848dea03a7924 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:23:20 +1100 Subject: [PATCH 142/562] fix(ui): the size field stays within its room in the row in every language (#5634) It reserves room for a translated "Mixed", and in Czech or French that pushed the alignment button up to 54 pt past the Tools pane. The field is now no wider than SIZE_FIELD, and a longer translation scrolls in it. The language test checks the size row too. --- crates/ui-egui/src/panels/format_text_ui.rs | 11 +++++++---- crates/ui-egui/tests/it/content.rs | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 38ccb96b16..669c9b5e9a 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -109,9 +109,10 @@ const LABEL_ICON: f32 = 22.0; const PRESET_LIST: f32 = 22.0; /// The room a number field keeps for its value ("-5.0 pt"), beside the arrow in its box. const NUMBER_MIN: f32 = 52.0; -/// The size field, which reserves room for "Mixed" so it doesn't resize while typing. -/// shortcut: measured for English "Mixed"; a longer translation widens the size row past the -/// narrowest pane. Upgrade by having the panel report its measured row width to the pane. +/// The size field's room in the row: English "Mixed", its margins and the 2 pt of its box. The +/// field reserves room for "Mixed" so it doesn't resize while typing, but no more than this. +/// shortcut: a longer translation of "Mixed" scrolls in the field. Upgrade by having the panel +/// report its measured row width to the pane, if that reads badly. const SIZE_FIELD: f32 = 62.0; /// The least width the panel's widest rows need, which sets how narrow the Tools pane may be /// dragged. Size and alignment: the size field and its arrow (in their box), the swatch, the @@ -558,7 +559,9 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) -> bool { // Reserve the localized mixed label, field margins and caret without resizing while typing. let font = egui::TextStyle::Body.resolve(ui.style()); let mixed_width = ui.painter().layout_no_wrap(tl!("Mixed").into(), font.clone(), ui.visuals().text_color()).size().x; - let width = (mixed_width + margin.sum().x + 2.0).ceil().max(40.0); + // Within the row's allowance, so a long translation ("Valeurs différentes") scrolls in the + // field rather than pushing the row past the pane. + let width = (mixed_width + margin.sum().x + 2.0).ceil().clamp(40.0, SIZE_FIELD - 2.0); let field = egui::TextEdit::singleline(&mut text) .id(id) .font(font) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 80d08d1993..dc287eb48b 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -757,9 +757,9 @@ fn format_text_fits_the_tools_pane_at_its_narrowest() { } } -/// The lists in Format text fit the narrowest Tools pane in every interface language: "Auto" in -/// the line spacing list is the one word in them that is translated, and in German, Ukrainian or -/// Lao it is twice as long. The font list spans the pane, so nothing may end past it. +/// The lists and the size row in Format text fit the narrowest Tools pane in every interface +/// language: "Auto" in the line spacing list and "Mixed" in the size field are translated, and in +/// Czech or French they are much longer. The font list spans the pane, so nothing may end past it. #[test] fn format_text_lists_fit_the_narrowest_tools_pane_in_every_language() { use egui::accesskit::Role; @@ -780,6 +780,9 @@ fn format_text_lists_fit_the_narrowest_tools_pane_in_every_language() { for r in &lists { assert!(r.right() <= font.right() + 0.5, "{}: {r:?} reaches past the font list ({font:?})", lang.code()); } + // The size row too: its field reserves room for a translated "Mixed". + let align = h.get_by_label(pdfcraft_ui_egui::i18n::t("Alignment")).rect(); + assert!(align.right() <= font.right() + 0.5, "{}: Alignment {align:?} reaches past the font list ({font:?})", lang.code()); } } From b31b40356dfcbd6c1170e1fb16e1f83f040867e8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Mon, 2 Nov 2026 17:54:15 +0530 Subject: [PATCH 143/562] fix(ui): prevent wheel lockup at edge in non-continuous view (#5713) --- crates/ui-egui/src/canvas/navigation.rs | 7 +++- crates/ui-egui/src/canvas/wheel_pager.rs | 5 +++ crates/ui-egui/tests/it/view.rs | 42 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 731b2c2586..98be96a72a 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -268,7 +268,11 @@ impl DocView { let Some(forward) = self.wheel.feed(unit, dy, phase, now, can_turn) else { return false }; let before = self.current; self.step_page(forward); - self.current != before + if self.current == before { + self.wheel.unturn(); + return false; + } + true } /// One wheel event in a non-continuous view whose page is taller than the window, where the @@ -288,6 +292,7 @@ impl DocView { let before = self.current; self.step_page(forward); if self.current == before { + self.wheel.unturn(); return false; } if !forward { diff --git a/crates/ui-egui/src/canvas/wheel_pager.rs b/crates/ui-egui/src/canvas/wheel_pager.rs index d12c445a8a..adabb8bee1 100644 --- a/crates/ui-egui/src/canvas/wheel_pager.rs +++ b/crates/ui-egui/src/canvas/wheel_pager.rs @@ -73,6 +73,11 @@ impl WheelPager { self.accum = 0.0; } + /// Reset any turned state when a turn did not happen (e.g. at document boundary). + pub(crate) fn unturn(&mut self) { + self.turned = None; + } + /// Whether the current gesture already turned a page: the rest of it must not pan the page /// just shown away from its top (or bottom). pub(crate) fn gesture_turned(&self) -> bool { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 7f444bee0f..584cb064cb 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -520,6 +520,48 @@ fn single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does() ); } +#[test] +fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + h.state_mut().views[0].go_to_page(2); // Go to last page of PAGES (3 pages) + h.run_steps(20); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(4); + + // Scroll to the bottom edge of page 3 + let mut notches = 0; + let mut last = rect(&h, 2).expect("page 3 on screen").min.y; + let mut at_bottom = false; + while notches < 80 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(12); + notches += 1; + if let Some(r) = rect(&h, 2) { + if (r.min.y - last).abs() < 0.01 { + at_bottom = true; + break; + } + last = r.min.y; + } + } + assert!(at_bottom, "reached bottom edge of last page"); + assert_eq!(h.state().views[0].current, 2, "remains on page 3"); + + // Push the wheel further against the bottom edge (cannot turn because it's the last page) + for _ in 0..5 { + h.event(wheel(MouseWheelUnit::Line, -1.0, TouchPhase::Move)); + h.run_steps(12); + } + let bottom_y = rect(&h, 2).expect("page 3 on screen").min.y; + + // Roll the wheel back up: the page must move away from the bottom edge (#5713) + h.event(wheel(MouseWheelUnit::Line, 1.0, TouchPhase::Move)); + h.run_steps(12); + let after_up_y = rect(&h, 2).expect("page 3 on screen").min.y; + assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}"); +} + #[test] fn stepping_past_either_end_leaves_the_view_alone() { // Paging on past the first or last page must not jump the page back to its top. From ecd1822525a84c95da228e42988f050629a83f83 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:37:29 +1100 Subject: [PATCH 144/562] fix(ui): the size field shows "Mixed" whole in a tooltip (#5634) A long translation scrolls in the capped field, so the tooltip gives the full status. --- crates/ui-egui/src/panels/format_text_ui.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 669c9b5e9a..99ffc054e1 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -569,7 +569,11 @@ fn font_size_input(ui: &mut egui::Ui, size: &mut f64, mixed: bool) -> bool { .desired_width(width) .frame(egui::Frame::NONE) .align(egui::Align2::CENTER_CENTER); - let response = ui.add_sized([width, 26.0], field).accessible_name(tl!("Font size:")); + let mut response = ui.add_sized([width, 26.0], field).accessible_name(tl!("Font size:")); + // A long translation of "Mixed" scrolls in the field: the tooltip has it whole. + if mixed { + response = response.on_hover_text(tl!("Mixed")); + } let cancel = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); if cancel { text = initial_text(); From 08af1dc39fe5ccff14f429f1087d1cc860d87ed1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Mon, 2 Nov 2026 18:33:38 +0530 Subject: [PATCH 145/562] fix(ui): end a notch's turn when the next notch only pans (#5713) A wheel notch sets `turned`, and only a trackpad touch or a smooth burst cleared it. With a wheel that macOS reports in points, a notch that turned the page left `gesture_turned` set, so read_wheel swallowed every later notch on that page. The same happened at the last page's bottom edge. A notch that cannot turn now clears `turned`. The new tests use Point notches. The Line-unit test in b31b4035 passes without either fix, because egui's own ScrollArea still applies line scrolling. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/wheel_pager.rs | 3 ++ crates/ui-egui/tests/it/view.rs | 50 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/ui-egui/src/canvas/wheel_pager.rs b/crates/ui-egui/src/canvas/wheel_pager.rs index adabb8bee1..c8ce69c57d 100644 --- a/crates/ui-egui/src/canvas/wheel_pager.rs +++ b/crates/ui-egui/src/canvas/wheel_pager.rs @@ -149,6 +149,9 @@ impl WheelPager { }; if notch { if !can_turn { + // A notch is a gesture of its own, so one that only pans ends the gesture a + // previous notch turned; otherwise `gesture_turned` keeps swallowing the wheel (#5713). + self.turned = None; return None; } // Every notch turns a page; a smooth tail of the same gesture won't add another. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 584cb064cb..6f29132c28 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -562,6 +562,56 @@ fn single_page_wheel_at_last_page_bottom_edge_can_scroll_back_up() { assert!(after_up_y > bottom_y, "scrolling up moves the page away from the bottom edge: {after_up_y} > {bottom_y}"); } +/// One notch of a mouse wheel that macOS reports in points (a Logitech mouse sends about 14). +const POINT_NOTCH: f32 = 14.0; + +#[test] +fn single_page_point_notches_pan_the_page_a_notch_turned_to() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(20); + for _ in 0..120 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + if h.state().views[0].current == 1 { + break; + } + } + assert_eq!(h.state().views[0].current, 1, "notches pan page 1, then turn to page 2"); + h.run_steps(20); + let top = rect(&h, 1).expect("page 2 on screen").min.y; + for _ in 0..3 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + let after = rect(&h, 1).expect("page 2 on screen").min.y; + assert!(after < top, "notches pan page 2 down after the turn (#5713): {after} < {top}"); +} + +#[test] +fn single_page_point_notches_scroll_back_up_from_the_last_page_bottom() { + let mut h = harness_stepping(1.0 / 60.0, &[("layout", "single"), ("zoom", "200")]); + h.state_mut().views[0].go_to_page(2); + h.run_steps(20); + let at = h.state().views[0].viewport_rect().center(); + h.hover_at(at); + h.run_steps(4); + // Past the bottom: the extra notches push against the last page's edge. + for _ in 0..200 { + h.event(wheel(MouseWheelUnit::Point, -POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + assert_eq!(h.state().views[0].current, 2, "stays on the last page"); + let bottom = rect(&h, 2).expect("page 3 on screen").min.y; + for _ in 0..3 { + h.event(wheel(MouseWheelUnit::Point, POINT_NOTCH, TouchPhase::Move)); + h.run_steps(12); + } + let after = rect(&h, 2).expect("page 3 on screen").min.y; + assert!(after > bottom + 1.0, "notches scroll back up from the last page's bottom (#5713): {after} > {bottom}"); +} + #[test] fn stepping_past_either_end_leaves_the_view_alone() { // Paging on past the first or last page must not jump the page back to its top. From ec25e7d32778a5cbbfc68c88080995318d3ecd74 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:24:21 +0100 Subject: [PATCH 146/562] M2.6: images decode outside the renderer only through an ImageBudget `ImageBudget::decode` is now the one public way to decode an image's CCITT fax or JBIG2 data: `Document::decoded_image` and `decoded_image_within`, which decoded with no limit on the work, are test-only in pdfcraft-cos, and pdfcraft-filters exports only `decode_ccitt_within` and `decode_jbig2_within`, which spend from a caller's allowance (`decode_jbig2` is gone, `decode_ccitt` private). So an operation over many images can't decode them without sharing an allowance; the compiler holds new callers to it. Doctests that must not compile check both crates; with the old entry points public they fail. Tests outside the crates decode through an unlimited ImageBudget. Streams in general (`Stream::decoded`) are left for a follow-up. --- crates/cos/src/budget.rs | 44 +++++++++++++++++++++++++- crates/cos/src/document/objects.rs | 31 ++++++------------ crates/engine/src/tests.rs | 2 +- crates/filters/src/ccitt.rs | 41 +++++++++++++----------- crates/filters/src/jbig2.rs | 2 +- crates/filters/src/jbig2/decode.rs | 19 ++++++----- crates/filters/src/lib.rs | 28 +++++++++++++--- crates/optimize/src/images.rs | 2 +- crates/optimize/tests/optimize.rs | 51 ++++++++++++++++++------------ crates/redact/src/image.rs | 4 +-- crates/redact/src/tests.rs | 19 +++++++---- docs/architecture.md | 7 ++-- 12 files changed, 162 insertions(+), 88 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index e4373211e9..376b108be3 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -80,7 +80,49 @@ impl ImageBudget { Self { work, refused: 0 } } - /// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance. + /// The samples of image `s`, decoded and charged to the allowance: the one way to decode an + /// image outside the renderer, so that an operation over many images can't decode them without + /// one. + /// + /// They go through its filters ([`Stream::decoded`]), or, for a CCITT fax or JBIG2 image (its + /// last filter), through the filters before it and then [`pdfcraft_filters::decode_ccitt_within`] + /// with its [`Document::fax_params`], or [`pdfcraft_filters::decode_jbig2_within`] with its + /// `/JBIG2Globals`, as `CCITTFaxDecode` and `JBIG2Decode` give them (ISO 32000-2 §7.4.6, + /// §7.4.7). Parameters given by reference count, for every filter + /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`, and a + /// JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the whole page into + /// the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says; damaged + /// fax data gives fewer, so a caller checks it has the rows it needs. Damaged JBIG2 data, and + /// globals that can't be read (within [`MAX_JBIG2_GLOBALS`](crate::MAX_JBIG2_GLOBALS)), are an + /// error. + /// + /// No stage, the filters before a fax or JBIG2 decoder included, may produce more than `max` + /// bytes, nor more than [`MAX_DECODED`](crate::MAX_DECODED). A JBIG2 image's `/JBIG2Globals` + /// are another stream, shared between images, and are decoded within `MAX_JBIG2_GLOBALS` + /// whatever `max` is. Each filter as it runs, the globals when they have to be read, and the fax + /// or JBIG2 decoder spend from the allowance, and the image then pays for its pixels; with + /// nothing left an image is refused before anything is read, its globals included. + /// + /// ``` + /// # let doc = pdfcraft_cos::Document::new_empty(); + /// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new()); + /// let samples = pdfcraft_cos::ImageBudget::new(&doc).decode(&doc, &s, 1 << 20); + /// assert_eq!(samples.unwrap(), b""); + /// ``` + /// + /// `Document` has no way of its own to decode an image: + /// + /// ```compile_fail + /// # let doc = pdfcraft_cos::Document::new_empty(); + /// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new()); + /// let _ = doc.decoded_image(&s); + /// ``` + /// + /// ```compile_fail + /// # let doc = pdfcraft_cos::Document::new_empty(); + /// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new()); + /// let _ = doc.decoded_image_within(&s, 1 << 20); + /// ``` pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { let decoded = doc.decoded_image_spending(s, max, &mut self.work); self.count_refused(s, &decoded); diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index ec7078590f..2ac18cff03 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -274,34 +274,23 @@ impl Document { .unwrap_or_default() } - /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a - /// CCITT fax or JBIG2 image (its last filter), through the filters before it and then - /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], or - /// [`pdfcraft_filters::decode_jbig2`] with its `/JBIG2Globals`, as `CCITTFaxDecode` and - /// `JBIG2Decode` give them (ISO 32000-2 §7.4.6, §7.4.7). Parameters given by reference count, - /// for every filter ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its - /// `/Width`, and a JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the - /// whole page into the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says, - /// within [`MAX_DECODED`] bytes; damaged fax data gives fewer, so a caller checks it has the - /// rows it needs. Damaged JBIG2 data, and globals that can't be read (within - /// [`MAX_JBIG2_GLOBALS`]), are an error. - pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { + /// [`Document::decoded_image_spending`] with no limit on the work, for tests of what an image + /// decodes to: everything else decodes images through an [`ImageBudget`](crate::ImageBudget). + #[cfg(test)] + pub(crate) fn decoded_image(&self, s: &Stream) -> Result, CosError> { self.decoded_image_within(s, MAX_DECODED) } - /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than - /// [`MAX_DECODED`]: no stage, the filters before a fax or JBIG2 decoder included, may produce - /// more than `max` bytes. A JBIG2 image's `/JBIG2Globals` are another stream, shared between - /// images, and are decoded within [`MAX_JBIG2_GLOBALS`] whatever `max` is. - pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + /// [`Document::decoded_image`] within `max` bytes. + #[cfg(test)] + pub(crate) fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { let mut unbounded = u64::MAX; self.decoded_image_spending(s, max, &mut unbounded) } - /// [`Document::decoded_image_within`], spending from `work`, what an operation may still do - /// across its images ([`ImageBudget`](crate::ImageBudget)): each filter as it runs, a JBIG2 - /// image's globals when they have to be read, and the fax or JBIG2 decoder. With nothing left an - /// image is refused before anything is read, its globals included. + /// The samples of image `s`, decoded within `max` bytes ([`MAX_DECODED`] at most), spending from + /// `work`: see [`ImageBudget::decode`](crate::ImageBudget::decode), the way in from outside this + /// crate, which says what this decodes and how. pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result, CosError> { let max = max.min(MAX_DECODED); let s = &*self.with_direct_parms(s); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4731c3f9ff..25a185e835 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8272,7 +8272,7 @@ fn an_office_export_doesnt_call_an_unreadable_picture_truncated() { } /// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter -/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`. +/// (ASCIIHexDecode) too: Export's decoding, through `ImageBudget::decode`. #[test] fn copy_image_copies_a_jbig2_picture_behind_another_filter() { // 16 × 2: the left half of the top row black, the rest white. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs index a7138bd485..2a038568ee 100644 --- a/crates/filters/src/ccitt.rs +++ b/crates/filters/src/ccitt.rs @@ -50,7 +50,7 @@ pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, Filter } } -/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB. +/// The widest row [`decode_ccitt_within`] decodes: its row buffer stays within 128 KiB. pub const MAX_CCITT_COLUMNS: u32 = 1 << 20; /// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the @@ -74,22 +74,8 @@ impl Default for CcittParams { } } -/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most -/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`). -/// -/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by -/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a -/// caller that needs a number of rows checks it got them. -/// -/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes, -/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4 -/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever -/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the -/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each -/// code on a byte, that may be other rows than were coded. -/// -/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]). -pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { +/// What [`decode_ccitt_within`] decodes once it is paid for. +fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { let columns = u32::try_from(p.columns) .ok() .filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c)) @@ -128,7 +114,24 @@ pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result bool { /// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at /// most `rows` rows. Pixels past a row's end and rows past the last are dropped. The JBIG2 decoder -/// ([`crate::decode_jbig2`]) fills them too. +/// ([`crate::decode_jbig2_within`]) fills them too. pub(crate) struct Rows { pub(crate) out: Vec, row: Vec, diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index 471814f790..1b705b0c72 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -7,7 +7,7 @@ mod decode; mod symbols; -pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within}; +pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2_within}; use crate::FilterError; use crate::mq::{Context, MqEncoder}; diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index d067df4d6b..a018e75937 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -3,7 +3,7 @@ use crate::FilterError; use crate::ccitt::Rows; -/// The largest JBIG2 page [`decode_jbig2`] decodes, in pixels. The decoder decodes a page whole, +/// The largest JBIG2 page [`decode_jbig2_within`] decodes, in pixels. The decoder decodes a page whole, /// and takes pages of up to about this many (20,000 × 10,000 decodes). pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; @@ -22,13 +22,10 @@ pub const MAX_JBIG2_PIXELS: u64 = 1 << 28; /// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or /// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the /// same cap and can refuse the page first). -pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { - let mut unbounded = u64::MAX; - decode_jbig2_within(data, globals, columns, rows, max, &mut unbounded) -} - -/// [`decode_jbig2`], spending from `work`: what decoding may still cost across a run of images, in -/// the decoder's count of steps. Reading `data` and `globals` is charged first +/// +/// Decoding spends from `work`: what decoding may still cost across a run of images, in the +/// decoder's count of steps. There is no way in without it, so a run of images can't decode them +/// without sharing an allowance. Reading `data` and `globals` is charged first /// ([`hayro_jbig2::charge_reading`]), so an image that turns out not to decode has paid for that, /// then what decoding takes ([`hayro_jbig2::Image::decode_within`]). An image takes at most what /// one decode may on top of its reading and its page, whatever `work` allows, and one that `work` @@ -80,6 +77,12 @@ mod tests { use super::*; use crate::{encode_jbig2, encode_jbig2_generic}; + /// [`decode_jbig2_within`] with no limit on the work. + fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result, FilterError> { + let mut unbounded = u64::MAX; + decode_jbig2_within(data, globals, columns, rows, max, &mut unbounded) + } + /// `rows` rows of `columns` samples of noise, the bits that pad each row to a byte 0. fn noise(columns: u32, rows: u32) -> Vec { let stride = (columns as usize).div_ceil(8); diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9fb0cd4d77..181f96ab66 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -7,9 +7,27 @@ //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so //! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own -//! ([`decode_ccitt`], [`decode_jbig2`]), as `pdfcraft_cos::Document::decoded_image` -//! does after the filters before them. 1-bit samples can be encoded as CCITT Group 4 -//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). +//! ([`decode_ccitt_within`], [`decode_jbig2_within`]), each spending from an allowance its caller +//! shares across a run of images, as `pdfcraft_cos::ImageBudget::decode` does after the filters +//! before them. Neither decodes without one: +//! +//! ```compile_fail +//! let _ = pdfcraft_filters::decode_jbig2(&[], None, 1, 1, 1 << 20); +//! ``` +//! +//! ```compile_fail +//! let _ = pdfcraft_filters::decode_ccitt(&[], &pdfcraft_filters::CcittParams::default(), 1); +//! ``` +//! +//! ``` +//! let mut work = 1 << 20; +//! assert!(pdfcraft_filters::decode_jbig2_within(&[], None, 1, 1, 1 << 20, &mut work).is_err()); +//! let p = pdfcraft_filters::CcittParams { columns: 8, ..Default::default() }; +//! assert!(pdfcraft_filters::decode_ccitt_within(&[], &p, 1, &mut work).is_ok()); +//! ``` +//! +//! 1-bit samples can be encoded as CCITT Group 4 ([`encode_g4`]) or lossless JBIG2 +//! ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -28,8 +46,8 @@ mod runlength; use std::borrow::Cow; -pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, decode_ccitt_within, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, decode_jbig2_within, encode_jbig2, encode_jbig2_generic}; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt_within, encode_g4}; +pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2_within, encode_jbig2, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 9cec3be367..92e68582c2 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -811,7 +811,7 @@ type Pixel = fn(&[u8]) -> Option; /// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit /// image. These count: /// - one bit per pixel in one colour component, or an image mask, stored with no filter, with -/// general-purpose ones, or with CCITT fax or JBIG2 last ([`Document::decoded_image_within`], no further +/// general-purpose ones, or with CCITT fax or JBIG2 last ([`ImageBudget::decode`], no further /// than its rows can need); /// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): /// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 8eec68c841..96f9c91a72 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -9,6 +9,12 @@ use pdfcraft_optimize::{ effective_resolutions, optimize, }; +/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the +/// samples an operation would read through its `ImageBudget`. +fn decoded_image(doc: &Document, s: &Stream) -> Result, pdfcraft_cos::CosError> { + pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED) +} + fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { // A smooth gradient with a little texture (photo-like). let mut px = Vec::with_capacity((w * h) as usize * n); @@ -910,7 +916,7 @@ fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), "{i:?}" ); - assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}"); + assert_eq!(decoded_image(&doc, &s).unwrap(), text, "{i:?}"); let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None }; assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}"); } @@ -1066,7 +1072,7 @@ fn retain_keeps_a_group_3_halftone_as_it_is() { let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); // 7.68 pt for 64 samples is 600 ppi. page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q"); - assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + assert_eq!(decoded_image(&doc, &stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); assert_eq!(stream(&doc, fax).raw[..], g3[..]); @@ -1564,7 +1570,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { } assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes"); for i in [flate, fax, hex_fax] { - assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); + assert_eq!((size(&kept, i), decoded_image(&kept, &stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); } let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); @@ -1578,7 +1584,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { let s = stream(&doc, i); assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) })); assert!(matches!(s.filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "{i:?}"); - assert_eq!(doc.decoded_image(&s).unwrap(), text.clone(), "{i:?}"); + assert_eq!(decoded_image(&doc, &s).unwrap(), text.clone(), "{i:?}"); } assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. @@ -1588,12 +1594,12 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), ((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting)) ); - assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + assert_eq!(decoded_image(&doc, &s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); for i in [at_400, soft, hex_at_400] { assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); } assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped"); - assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); + assert_eq!(decoded_image(&doc, &stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched); let after = render(&doc); assert_eq!(after, before, "conservative retention preserves the rendered page exactly"); @@ -1681,7 +1687,7 @@ fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { let r = optimize(&mut doc, &Settings::default()).unwrap(); if padding == 0 { assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}"); - assert_eq!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false)); + assert_eq!(decoded_image(&doc, &stream(&doc, img)).unwrap(), bilevel(w, h, false)); } else { assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); assert_eq!(stream(&doc, img).raw, before); @@ -1776,7 +1782,7 @@ fn monochrome_images_keep_what_they_should() { assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate"); - assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample"); + assert_eq!(decoded_image(&doc, &stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample"); assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); let (mut doc, [_, wrapped, scan, _, _]) = build(); @@ -1785,7 +1791,7 @@ fn monochrome_images_keep_what_they_should() { assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is"); let s = stream(&doc, scan); assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(1200), Some(&b"FlateDecode"[..])), "Retain: {r:?}"); - assert_eq!(doc.decoded_image(&s).unwrap(), ragged, "Retain preserves every sample"); + assert_eq!(decoded_image(&doc, &s).unwrap(), ragged, "Retain preserves every sample"); } /// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first, @@ -1853,7 +1859,12 @@ fn group_3_fax_images_become_group_4() { let g3 = group_3(&runs, k > 0, on_bytes); let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some()); let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() }; - assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"); + let mut work = u64::MAX; + assert_eq!( + pdfcraft_filters::decode_ccitt_within(&g3, &p, h, &mut work).unwrap(), + bits, + "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture" + ); let mut doc = Document::new_empty(); let mut d = Dict::new(); @@ -1886,7 +1897,7 @@ fn group_3_fax_images_become_group_4() { let s = stream(&doc, fax); assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4"); assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len()); - assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); + assert_eq!(decoded_image(&doc, &s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does"); } } @@ -2102,14 +2113,14 @@ fn jbig2_scans_are_averaged_and_stored_again() { let im = mono_image(&mut doc, w, h, &squares, None, &grey); page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}"); - let averaged = doc.decoded_image(&stream(&doc, im)).unwrap(); + let averaged = decoded_image(&doc, &stream(&doc, im)).unwrap(); for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] { let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600); let r = optimize(&mut doc, &settings).unwrap(); let s = stream(&doc, im); let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}"); assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}"); - assert_eq!(doc.decoded_image(&s).unwrap(), averaged, "{what}"); + assert_eq!(decoded_image(&doc, &s).unwrap(), averaged, "{what}"); assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}"); let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec); // The smallest of what each compression may store; under Retain, JBIG2 again. @@ -2140,13 +2151,13 @@ fn jbig2_scans_are_averaged_and_stored_again() { let s = stream(&doc, im); assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}"); assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len()); - assert_eq!(doc.decoded_image(&s).unwrap(), text); + assert_eq!(decoded_image(&doc, &s).unwrap(), text); // Behind ASCIIHex it is stored again alone, being smaller so. let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); optimize(&mut doc, &jbig2_asked).unwrap(); let s = stream(&doc, im); assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..]))); - assert_eq!(doc.decoded_image(&s).unwrap(), text); + assert_eq!(decoded_image(&doc, &s).unwrap(), text); // So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex // allows, so that the stream is many times the code. let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300); @@ -2156,7 +2167,7 @@ fn jbig2_scans_are_averaged_and_stored_again() { optimize(&mut doc, &Settings::default()).unwrap(); let s = stream(&doc, im); assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone"); - assert_eq!(doc.decoded_image(&s).unwrap(), text); + assert_eq!(decoded_image(&doc, &s).unwrap(), text); // A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't // read, and stays as it is. @@ -3570,7 +3581,7 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { optimize(&mut gray, &Settings::default()).unwrap(); let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap()); let gray_stream = stream(&gray, gray_image); - let gray_samples = gray.decoded_image(&gray_stream).unwrap(); + let gray_samples = decoded_image(&gray, &gray_stream).unwrap(); assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives"); for white_first in [false, true] { @@ -3603,7 +3614,7 @@ fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() { assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}"); // The rows are 600 samples, whole bytes: flipping each byte flips each sample. let expected: Vec = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect(); - assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark"); + assert_eq!(decoded_image(&doc, &s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark"); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}"); } @@ -3664,7 +3675,7 @@ fn an_indexed_palette_is_read_by_the_luma_of_its_colours() { page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); optimize(&mut doc, &Settings::default()).unwrap(); let s = stream(&doc, image); - (s.dict.int(b"Width"), doc.decoded_image(&s).unwrap()) + (s.dict.int(b"Width"), decoded_image(&doc, &s).unwrap()) }; let (width, samples) = optimized("Indexed"); assert_eq!(width, Some(600)); @@ -3772,7 +3783,7 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin optimize(&mut doc, &Settings::default()).unwrap(); let s = stream(&doc, image); assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); - assert_eq!(doc.decoded_image(&s).unwrap(), data, "screened samples survive, not a solid patch"); + assert_eq!(decoded_image(&doc, &s).unwrap(), data, "screened samples survive, not a solid patch"); let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); assert_eq!(render(&saved), before, "local halftone {side}x{side}"); } diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index c7b1036725..2e737e2baf 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -2,7 +2,7 @@ //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it //! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it). -//! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on +//! CCITT fax and JBIG2 images decode through `ImageBudget::decode`, but for Group 3 on //! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the //! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the //! caller removes the whole image instead; that is fail-closed. @@ -54,7 +54,7 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - // A CCITT fax or JBIG2 image (its last filter) decodes through `Document::decoded_image_within`, + // A CCITT fax or JBIG2 image (its last filter) decodes through `ImageBudget::decode`, // after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other // image. let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 2b9a143a19..2d0268d525 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -520,10 +520,16 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document { ]) } +/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the +/// samples an operation would read through its `ImageBudget`. +fn decoded_image(doc: &Document, s: &pdfcraft_cos::Stream) -> Result, pdfcraft_cos::CosError> { + pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED) +} + /// The samples of the image a page's (possibly inherited) resources name `name`, decoded through -/// its image codec too (`Document::decoded_image`). +/// its image codec too ([`decoded_image`]). fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { - named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok()) + named_stream(doc, page, name).and_then(|s| decoded_image(doc, &s).ok()) } /// The image a page's (possibly inherited) resources name `name`. @@ -708,7 +714,8 @@ fn group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared() { (-1, true, "<< /K -1 /Columns 16 /EncodedByteAlign true >>", vec![0x80, 0x80], vec![], false), ] { let p = pdfcraft_filters::CcittParams { k, columns: 16, encoded_byte_align: on_bytes, ..Default::default() }; - assert_eq!(pdfcraft_filters::decode_ccitt(&data, &p, 2).unwrap(), [0xFF; 4], "{parms}: the picture is white"); + let mut work = u64::MAX; + assert_eq!(pdfcraft_filters::decode_ccitt_within(&data, &p, 2, &mut work).unwrap(), [0xFF; 4], "{parms}: the picture is white"); let dict = format!("{image} /DecodeParms {parms}"); let mut objs = vec![stream(&dict, data)]; objs.extend(extra); @@ -770,7 +777,7 @@ fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() { } let holds = |doc: &Document, pixels: &[u8]| { doc.scan_objects().any(|(_, object)| match &*object { - Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)), + Object::Stream(s) => decoded_image(doc, s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)), _ => false, }) }; @@ -1262,7 +1269,7 @@ fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() { let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?; let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?; match &*doc.get(xo.get(name)?.as_ref()?) { - Object::Stream(s) => doc.decoded_image(s).ok(), + Object::Stream(s) => decoded_image(doc, s).ok(), _ => None, } }; @@ -2956,7 +2963,7 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) { assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}"); if let Object::Stream(s) = &*object { // Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document. - let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap(); + let data = s.decoded().or_else(|_| decoded_image(&saved, s)).unwrap(); assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}"); } } diff --git a/docs/architecture.md b/docs/architecture.md index b5beb009b7..77e141d0de 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,9 +72,10 @@ The PDF object graph *is* the model (`crates/engine/README.md` §The editing mod - PDF in: any revision chain, xref tables or streams, hybrid files, damaged files (repaired and logged), all standard-security revisions. Image codecs (DCT, JPX) decode through the bootstrap - renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt`, `decode_jbig2`, - behind `Document::decoded_image`) for export, redaction and the optimizer, and through the - renderer for display. Export ▸ Images decodes JPEG itself. + renderer; CCITT and JBIG2 decode through `pdfcraft-filters` (`decode_ccitt_within`, + `decode_jbig2_within`, behind `ImageBudget::decode`, the only way to decode an image outside + the renderer, which shares one allowance across an operation's images) for export, redaction + and the optimizer, and through the renderer for display. Export ▸ Images decodes JPEG itself. - PDF out: incremental or full saves, object streams, re-encryption, resource de-duplication when combining. - Other formats: images and text in (`create`); PNG/JPEG/TIFF, text, RTF, Word and HTML out From 8f1441a3f2269974961b3c4d36deefa6d6c8f17b Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:59:26 +0100 Subject: [PATCH 147/562] fix(ui): keep pane resize edges right of the border, off the page scrollbar (#5730) The centred 4 pt strip still covered the right 3 pt of the page's scrollbar beside a right-docked pane. Register the host's resize edge again from the border rightwards, on the host's own layer, and leave an inert edge inert. The test now hovers and drags both scrollbars beside a right-docked Tools pane (CodeRabbit) and checks the width is unchanged. --- crates/ui-egui/src/panels/side_panels.rs | 44 +++++++++++++------- crates/ui-egui/tests/it/side_panels.rs | 53 ++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/panels/side_panels.rs b/crates/ui-egui/src/panels/side_panels.rs index c62cf9df52..3676d9762b 100644 --- a/crates/ui-egui/src/panels/side_panels.rs +++ b/crates/ui-egui/src/panels/side_panels.rs @@ -390,6 +390,31 @@ fn open_panes(app: &PdfCraftApp) -> Vec { open } +/// Width of a pane's resize edge that takes the pointer, right of its border. +const RESIZE_GRIP: f32 = 4.0; + +/// Moves each open pane's resize edge to the right of its border (#5730). +/// +/// The host centres the edge on the border and widens it, so it reaches over whatever ends left +/// of the border. egui puts a vertical scrollbar at the right of its area, so that is the +/// scrollbar of the page beside a right-docked pane, or of the Inspector beside a right-docked +/// Tools pane, and its right half became the edge. Right of a border starts the next area, never +/// with a scrollbar: a right-docked pane's own scrollbar is at its far side, and a left-docked +/// pane keeps a gutter left of its border. +/// +/// The edge is the host's widget registered again with a smaller rect; egui keeps the last rect +/// for the next frame's hit test, and the layer is the one the host used. +fn keep_resize_edges_off_scrollbars(ui: &mut egui::Ui, host: egui::Id, open: &[Pane]) { + for pane in open { + let id = host.with(("pane", *pane)).with("resize"); + // An inert edge (bad geometry) senses no drag: leave it so. + let Some(edge) = ui.ctx().read_response(id).filter(|r| r.sense.senses_drag() && r.rect.is_positive()) else { continue }; + let border = edge.rect.center().x; + let grip = egui::Rect::from_x_y_ranges(border..=border + RESIZE_GRIP, edge.rect.y_range()); + ui.new_child(egui::UiBuilder::new().layer_id(edge.layer_id)).interact(grip, id, egui::Sense::drag()); + } +} + /// Draws the rail and the open panes. Call after the top bars and before the central panel. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some(panel) = app.right { @@ -411,7 +436,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() }; let panels = app.side_panels; // Columns: both panes may share the right edge, Tools outermost, as `open` lists them. - let output = PaneHost::new(egui::Id::unique("pdfcraft-side-panels")) + let host = egui::Id::unique("pdfcraft-side-panels"); + let output = PaneHost::new(host) .policy(SidePolicy::Columns) .f6(false) .labels(labels) @@ -419,21 +445,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .body_margin(egui::Margin::ZERO) .show(ui, &open, |pane| Some(state_of(&panels, *pane)), &mut Content { app }); - // Ensure panel resize handles do not bleed inward over panel interiors and scrollbars. - // The handle's active target is kept to the divider strip centered on the border. - for pane in &open { - let host_id = egui::Id::unique("pdfcraft-side-panels"); - let s_id = host_id.with(("pane", *pane)); - let resize_id = s_id.with("resize"); - if let Some(resp) = ui.ctx().read_response(resize_id) { - let edge_layer = egui::LayerId::new(ui.layer_id().order, s_id.with("resize-layer")); - let edge_ui = ui.new_child(egui::UiBuilder::new().layer_id(edge_layer)); - let old_rect = resp.rect; - let border = (old_rect.min.x + old_rect.max.x) / 2.0; - let new_rect = egui::Rect::from_x_y_ranges((border - 2.0)..=(border + 2.0), old_rect.y_range()); - edge_ui.interact(new_rect, resize_id, egui::Sense::drag()); - } - } + keep_resize_edges_off_scrollbars(ui, host, &open); for request in output.requests { match request { PaneRequest::Resize(pane, width) => app.side_panels.set_width(pane, width), diff --git a/crates/ui-egui/tests/it/side_panels.rs b/crates/ui-egui/tests/it/side_panels.rs index 6febd26703..d61187c143 100644 --- a/crates/ui-egui/tests/it/side_panels.rs +++ b/crates/ui-egui/tests/it/side_panels.rs @@ -87,11 +87,11 @@ fn a_dragged_width_is_remembered_across_restarts() { // The side panel's resize edge: the one right of the page, left of the panel's text. let edge = h .get_all_by_label("Resize panels") - .map(|n| n.rect().center()) + .map(|n| n.rect().left_center()) .filter(|c| c.x < side_left && c.x > WIDTH / 2.0) .min_by(|a, b| (side_left - a.x).total_cmp(&(side_left - b.x))) .expect("the side panel has a resize edge") - // The pane's half of the edge: the page, drawn after the panes, takes the other half. + // 2 pt inside the panel: the edge lies right of the border, clear of the page's scrollbar. + egui::vec2(2.0, 0.0); h.hover_at(edge); h.run_steps(1); @@ -197,7 +197,8 @@ fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { assert!(after_width > initial_width); assert!((after_width - (initial_width + 40.0)).abs() <= 5.0); - // 2. Right docking: tools panel on the right (resize handle on the left). + // 2. Right docking: tools panel on the right (resize handle on the left), next to the page's + // scrollbar (#5730). let mut h_right = harness_sized(egui::vec2(1200.0, 450.0), |app| { app.set_option("tools-side", "right").unwrap(); app.all_tools_expanded = true; @@ -207,6 +208,51 @@ fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { let splitters_right: Vec<_> = h_right.get_all_by_label("Resize panels").collect(); assert!(!splitters_right.is_empty()); let right_splitter_rect = splitters_right[0].rect(); + let bars: Vec = h_right.get_all_by_role(Role::ScrollBar).map(|s| s.rect()).filter(|r| r.height() > 0.0).collect(); + // The page's scrollbar ends where the pane begins; the pane's own one is at its far side. + let page_bar = bars + .iter() + .copied() + .filter(|r| r.min.x < right_splitter_rect.center().x) + .max_by(|a, b| a.max.x.total_cmp(&b.max.x)) + .expect("the page has a scrollbar"); + assert!( + (right_splitter_rect.center().x - page_bar.max.x).abs() < 6.0, + "the page's scrollbar is beside the edge: {page_bar:?} {right_splitter_rect:?}" + ); + let tools_bar = bars.iter().copied().find(|r| r.min.x > right_splitter_rect.center().x).expect("Tools panel scrollbar should exist"); + let initial_right_width = h_right.state().side_panels.tools_width; + + // All of either scrollbar is the scrollbar, not the resize edge. + for bar in [page_bar, tools_bar] { + for x in [bar.min.x + 0.5, bar.center().x, bar.max.x - 0.5] { + h_right.hover_at(egui::pos2(x, bar.center().y)); + h_right.run_steps(1); + let cursor = h_right.output().platform_output.cursor_icon; + assert_ne!(cursor, egui::CursorIcon::ResizeHorizontal, "Hovering scrollbar {bar:?} at x={x} should not trigger resize handle"); + } + } + + // Dragging either scrollbar by its right edge scrolls its content and leaves the width alone. + let drag = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.hover_at(at + egui::vec2(0.0, 60.0)); + h.run_steps(1); + h.drop_at(at + egui::vec2(0.0, 60.0)); + h.run_steps(2); + }; + let page_top = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("page on screen").min.y; + let before = page_top(&h_right); + drag(&mut h_right, egui::pos2(page_bar.max.x - 1.0, page_bar.center().y)); + assert!(page_top(&h_right) < before, "dragging the page's scrollbar scrolls the page"); + assert_eq!(h_right.state().side_panels.tools_width, initial_right_width, "Dragging scrollbar must not resize the panel"); + let before = h_right.get_by_label("Edit a PDF").rect().top(); + drag(&mut h_right, egui::pos2(tools_bar.max.x - 1.0, tools_bar.center().y)); + assert!(h_right.get_by_label("Edit a PDF").rect().top() < before, "Content should scroll up when dragging scrollbar down"); + assert_eq!(h_right.state().side_panels.tools_width, initial_right_width, "Dragging scrollbar must not resize the panel"); // Splitter is at the left boundary of the right-docked pane. h_right.hover_at(right_splitter_rect.center()); @@ -214,7 +260,6 @@ fn scrollbar_and_panel_resize_handle_do_not_collide_on_either_dock_side() { assert_eq!(h_right.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); // Dragging the splitter on right-docked pane smoothly resizes. - let initial_right_width = h_right.state().side_panels.tools_width; h_right.hover_at(right_splitter_rect.center()); h_right.run_steps(1); h_right.drag_at(right_splitter_rect.center()); From 83b230ae730b9d8fe8e8d94972ea75d593a682ea Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:17:23 -0400 Subject: [PATCH 148/562] M12: PRC reader crate: container, bit primitives, schema (#1297) New standalone L0 crate pdfcraft-prc: the PRC container walk (file header, file-structure descriptions, per-section zlib inflation under a 512 MiB ceiling), the bit-level primitives of the compressed sections, and the schema interpreter a newer producer uses to describe fields it added (step and nesting ceilings). The Double prefix-code table is not in this commit: Double reads return UnknownDoubleCode, and the tests that need the table are ignored with that reason. Adapted from pdfcer (MIT, same author), crates/pdfcer-3d at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- Cargo.lock | 9 + crates/prc/Cargo.toml | 18 ++ crates/prc/README.md | 23 ++ crates/prc/src/acof.rs | 98 +++++++ crates/prc/src/bits.rs | 421 +++++++++++++++++++++++++++ crates/prc/src/container.rs | 325 +++++++++++++++++++++ crates/prc/src/error.rs | 38 +++ crates/prc/src/lib.rs | 26 ++ crates/prc/src/schema.rs | 525 ++++++++++++++++++++++++++++++++++ crates/prc/src/testw.rs | 101 +++++++ crates/prc/tests/container.rs | 114 ++++++++ crates/prc/tests/proptests.rs | 41 +++ xtask/src/layers.rs | 1 + 13 files changed, 1740 insertions(+) create mode 100644 crates/prc/Cargo.toml create mode 100644 crates/prc/README.md create mode 100644 crates/prc/src/acof.rs create mode 100644 crates/prc/src/bits.rs create mode 100644 crates/prc/src/container.rs create mode 100644 crates/prc/src/error.rs create mode 100644 crates/prc/src/lib.rs create mode 100644 crates/prc/src/schema.rs create mode 100644 crates/prc/src/testw.rs create mode 100644 crates/prc/tests/container.rs create mode 100644 crates/prc/tests/proptests.rs diff --git a/Cargo.lock b/Cargo.lock index d2f4dadad0..e5abfe499e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4551,6 +4551,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "pdfcraft-prc" +version = "0.15.0" +dependencies = [ + "flate2", + "proptest", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-preflight" version = "0.17.0" diff --git a/crates/prc/Cargo.toml b/crates/prc/Cargo.toml new file mode 100644 index 0000000000..741c0ac5bd --- /dev/null +++ b/crates/prc/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pdfcraft-prc" +description = "PRC 3D model reader (ISO 14739-1): container, bit-level primitives, schema" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +flate2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/prc/README.md b/crates/prc/README.md new file mode 100644 index 0000000000..0f8a01872f --- /dev/null +++ b/crates/prc/README.md @@ -0,0 +1,23 @@ +# pdfcraft-prc + +A reader for PRC (ISO 14739-1), the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries (ISO 32000-2 §13.6.3). + +- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. +- **Licence:** MIT OR Apache-2.0. Inflation through `flate2` with the pure-Rust `miniz_oxide` backend. +- `#![forbid(unsafe_code)]`. Never panics on input: every read is bounded and returns `PrcError`. + +## API + +```rust +PrcFile::parse(data) -> Result // header, file structures, model file +PrcFile::parse_with_limit(data, max_inflated) -> Result +FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry +FileStructure::schema() -> Result // the globals section's leading schema +Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) +BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double +``` + +- `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. +- Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). +- `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). +- Not yet in the crate: the `Double` code table, so `BitReader::double` returns `UnknownDoubleCode`; the product tree and tessellation readers. diff --git a/crates/prc/src/acof.rs b/crates/prc/src/acof.rs new file mode 100644 index 0000000000..dff5d4a6f2 --- /dev/null +++ b/crates/prc/src/acof.rs @@ -0,0 +1,98 @@ +//! The prefix-code table behind the PRC `Double` [WD 11.17.2]. +//! +//! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent +//! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to. +//! +//! The rows are not in this crate yet: until they are, [`lookup`] finds no code, so every +//! `Double` read fails with `UnknownDoubleCode` and nothing else changes. + +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Rows a complete table has. +const ROWS: usize = 2077; + +/// The longest code, in bits. +pub(crate) const MAX_CODE_BITS: u32 = 22; + +/// What a code decodes to. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(not(test), expect(dead_code, reason = "built only by the table's rows, which are not in the crate yet"))] +pub(crate) enum Entry { + /// A frequent value, returned with the sign bit applied. + Double(f64), + /// The upper 32 bits of a binary64 (exponent set, mantissa zero); the mantissa follows + /// in the stream. + Exponent(u32), +} + +/// One row: code length, code, and what it decodes to. +type Row = (u32, u32, Entry); + +/// The table's rows. +const TABLE: &[Row] = &[]; + +fn key(nbits: u32, code: u32) -> u32 { + (nbits << 24) | code +} + +/// The rows keyed by `(nbits, code)`, or `None` when the table is incomplete or not prefix +/// free (then no `Double` decodes, rather than some decoding wrongly). +fn build(rows: &[Row]) -> Option> { + let mut map = HashMap::with_capacity(rows.len()); + for &(nbits, code, entry) in rows { + if nbits == 0 || nbits > MAX_CODE_BITS || code >> nbits != 0 || map.insert(key(nbits, code), entry).is_some() { + return None; + } + } + let prefix_free = map.keys().all(|&k| { + let (n, c) = (k >> 24, k & 0xff_ffff); + (1..n).all(|m| !map.contains_key(&key(m, c >> (n - m)))) + }); + (map.len() == ROWS && prefix_free).then_some(map) +} + +fn table() -> Option<&'static HashMap> { + static MAP: OnceLock>> = OnceLock::new(); + MAP.get_or_init(|| build(TABLE)).as_ref() +} + +/// The entry for an `nbits`-long code, if one exists. +pub(crate) fn lookup(nbits: u32, code: u32) -> Option { + table()?.get(&key(nbits, code)).copied() +} + +/// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`]. +#[cfg(test)] +pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> { + table()?.iter().find(|(_, v)| **v == Entry::Exponent(e << 20)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn an_incomplete_or_ambiguous_table_decodes_nothing() { + assert!(build(&[(2, 0b01, Entry::Double(0.0))]).is_none(), "one row is not the table"); + let rows: Vec = (0..ROWS as u32).map(|i| (22, i, Entry::Exponent(i))).collect(); + assert!(build(&rows).is_some(), "2077 distinct 22-bit codes are prefix free"); + let mut ambiguous = rows.clone(); + ambiguous[0] = (1, 0, Entry::Double(1.0)); // a prefix of every code starting 0 + assert!(build(&ambiguous).is_none()); + let mut too_long = rows; + too_long[0] = (2, 0b111, Entry::Double(1.0)); // a code wider than its length + assert!(build(&too_long).is_none()); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn the_table_is_complete() { + let t = table().unwrap(); + assert_eq!(t.len(), ROWS); + assert_eq!(t.values().filter(|e| matches!(e, Entry::Double(_))).count(), 29); + assert!((0..2048).all(|e| exponent_code(e).is_some())); + assert_eq!(lookup(2, 0b01), Some(Entry::Double(0.0))); + } +} diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs new file mode 100644 index 0000000000..a94a16dc9a --- /dev/null +++ b/crates/prc/src/bits.rs @@ -0,0 +1,421 @@ +//! The bit-level reader for PRC compressed sections [WD 10.3, 11.1-11.17]. +//! +//! Values are consumed MSB-first from each inflated byte and straddle byte boundaries freely +//! [WD 10.3.1, 11.3]. Multi-byte integers are groups of eight bits, low group first. Two sign +//! conventions coexist: `Integer` is two's complement, the `*WithVariableBitNumber` types are +//! sign-magnitude. + +use crate::PrcError; +use crate::acof::{self, Entry}; + +/// A cursor over one inflated section. +#[derive(Debug, Clone)] +pub struct BitReader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> BitReader<'a> { + /// A reader positioned at the first bit of `data`. + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + /// Bits consumed so far. + pub fn position(&self) -> usize { + self.pos + } + + /// Bits left before the end of the data. + pub fn remaining(&self) -> usize { + self.data.len().saturating_mul(8).saturating_sub(self.pos) + } + + /// Step over `n` bits of opaque data (`UserData` [WD 8.6]). + /// + /// # Errors + /// [`PrcError::Truncated`] when fewer than `n` bits remain; the cursor does not move. + pub fn skip_bits(&mut self, n: usize) -> Result<(), PrcError> { + if n > self.remaining() { + return Err(PrcError::Truncated("user data")); + } + self.pos = self.pos.saturating_add(n); + Ok(()) + } + + /// One bit; a PRC `Boolean` [WD 10.3.3]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data. + pub fn bit(&mut self) -> Result { + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a bit"))?; + let b = (byte >> (7 - (self.pos % 8))) & 1 == 1; + // `pos / 8` indexed the data, so `pos + 1` is at most `8 * len`. + self.pos = self.pos.saturating_add(1); + Ok(b) + } + + /// `n` bits (at most 32), MSB-first; `UnsignedIntegerWithVariableBitNumber` [WD 11.13]. + /// + /// # Errors + /// [`PrcError::Truncated`] at the end of the data; [`PrcError::Malformed`] for `n > 32`. + pub fn bits(&mut self, n: u32) -> Result { + if n > 32 { + return Err(PrcError::Malformed(format!("{n}-bit field"))); + } + if self.remaining() < n as usize { + return Err(PrcError::Truncated("a bit field")); + } + let mut v: u64 = 0; + for _ in 0..n { + v = (v << 1) | u64::from(self.bit()?); + } + // n <= 32, so v fits. + Ok(u32::try_from(v).unwrap_or(u32::MAX)) + } + + fn byte(&mut self) -> Result { + Ok(u8::try_from(self.bits(8)?).unwrap_or(u8::MAX)) + } + + /// A `Character`: one byte [WD 10.3.4]. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn character(&mut self) -> Result { + self.byte() + } + + /// An `UnsignedInteger` [WD 11.10]: while a 1 bit precedes it, one more eight-bit group, + /// low group first. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn unsigned_integer(&mut self) -> Result { + let mut v: u32 = 0; + let mut shift = 0; + while self.bit()? { + if shift >= 32 { + return Err(PrcError::Malformed("UnsignedInteger over 32 bits".into())); + } + v |= u32::from(self.byte()?) << shift; + shift += 8; + } + Ok(v) + } + + /// An `Integer` [WD 11.11]: a 0 bit is zero; otherwise eight-bit groups, low group first, + /// each followed by a continue bit, sign-extended from the last group's top bit. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] past four groups. + pub fn integer(&mut self) -> Result { + if !self.bit()? { + return Ok(0); + } + let mut v: u32 = 0; + let mut shift = 0; + loop { + if shift >= 32 { + return Err(PrcError::Malformed("Integer over 32 bits".into())); + } + let group = self.byte()?; + v |= u32::from(group) << shift; + shift += 8; + if !self.bit()? { + if group & 0x80 != 0 && shift < 32 { + v |= u32::MAX << shift; + } + return Ok(v.cast_signed()); + } + } + } + + /// An `IntegerWithVariableBitNumber(n)` [WD 11.12]: a negative flag (1 = negative; the WD + /// prose says the opposite, its sample code and real files agree on this), then the + /// magnitude in `n - 1` bits. + /// + /// # Errors + /// [`PrcError::Truncated`]; [`PrcError::Malformed`] for `n > 33`. + pub fn integer_vbn(&mut self, n: u32) -> Result { + let Some(magnitude_bits) = n.checked_sub(1) else { + return Ok(0); + }; + let negative = self.bit()?; + let m = i64::from(self.bits(magnitude_bits)?); + Ok(if negative { -m } else { m }) + } + + /// A `DoubleWithVariableBitNumber(tolerance, n)` [WD 11.14]: a sign-magnitude quantum + /// count times `tolerance`. + /// + /// # Errors + /// As [`Self::integer_vbn`]. + pub fn double_vbn(&mut self, tolerance: f64, n: u32) -> Result { + Ok(self.integer_vbn(n)? as f64 * tolerance) + } + + /// `NumberOfBitsThenUnsignedInteger` [WD 11.15]: a 5-bit width, then the value in that + /// many bits. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn nbits_then_unsigned(&mut self) -> Result { + let n = self.bits(5)?; + self.bits(n) + } + + /// A `String` [WD 10.3.7, 11.4]: `None` for the null string, else UTF-8 (invalid + /// sequences replaced). + /// + /// # Errors + /// [`PrcError::Truncated`] when the byte count runs past the data. + pub fn string(&mut self) -> Result, PrcError> { + if !self.bit()? { + return Ok(None); + } + let len = self.unsigned_integer()? as usize; + if len.saturating_mul(8) > self.remaining() { + return Err(PrcError::Truncated("a String")); + } + let bytes = (0..len).map(|_| self.byte()).collect::, _>>()?; + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) + } + + /// `FloatAsBytes` [WD 11.5]: an IEEE binary32, least-significant byte first. + /// + /// # Errors + /// [`PrcError::Truncated`]. + pub fn float_as_bytes(&mut self) -> Result { + let b = [self.byte()?, self.byte()?, self.byte()?, self.byte()?]; + Ok(f32::from_le_bytes(b)) + } + + /// A `Double` [WD 11.17]: a prefix code from the Double table, then a sign bit and, for + /// an exponent code, a byte-wise mantissa with back-references. + /// + /// The two-bit zero code has no sign bit. Back-reference offsets 1 to 5 are all accepted, + /// distance 1 included (PDF Association pdf-issues #769). + /// + /// # Errors + /// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`] + /// for a back-reference outside the value. + pub fn double(&mut self) -> Result { + let mut code = 0u32; + let mut entry = None; + for n in 1..=acof::MAX_CODE_BITS { + code = (code << 1) | u32::from(self.bit()?); + if let Some(e) = acof::lookup(n, code) { + if n == 2 && code == 0b01 { + return Ok(0.0); + } + entry = Some(e); + break; + } + } + let entry = entry.ok_or(PrcError::UnknownDoubleCode)?; + let negative = self.bit()?; + let upper = match entry { + Entry::Double(v) => return Ok(if negative { -v } else { v }), + Entry::Exponent(upper) => upper, + }; + let mut b = (u64::from(upper) << 32).to_le_bytes(); + if self.bit()? { + self.mantissa(&mut b)?; + } + let sign = if negative { 1u64 << 63 } else { 0 }; + Ok(f64::from_bits(u64::from_le_bytes(b) | sign)) + } + + /// Mantissa bits 51..0 into `b` (little-endian byte order): a nibble, then bytes 5 down + /// to 0, each a literal or a back-reference to a byte already set. + fn mantissa(&mut self, b: &mut [u8; 8]) -> Result<(), PrcError> { + let nibble = u8::try_from(self.bits(4)?).unwrap_or(0); + let [.., b6, _] = b; + *b6 |= nibble; + let at = |b: &[u8; 8], i: usize| b.get(i).copied().ok_or_else(|| PrcError::Malformed("Double back-reference past the value".into())); + for cbi in (0..=5usize).rev() { + let prev = at(b, cbi + 1)?; + let value = if self.bit()? { + self.byte()? + } else { + match self.bits(3)? { + // Fill this byte and every lower one with the previous byte. + 0 => { + b.iter_mut().take(cbi + 1).for_each(|x| *x = prev); + return Ok(()); + } + // As 0, but the lowest byte is a literal. + 6 => { + b.iter_mut().take(cbi + 1).skip(1).for_each(|x| *x = prev); + let low = self.byte()?; + let [b0, ..] = b; + *b0 = low; + return Ok(()); + } + off @ 1..=5 => at(b, cbi + off as usize)?, + _ => return Err(PrcError::Malformed("Double mantissa offset 7".into())), + } + }; + if let Some(x) = b.get_mut(cbi) { + *x = value; + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::testw::W; + + /// The code for IEEE exponent `e`. + fn exponent_code(e: u32) -> (u32, u32) { + acof::exponent_code(e).expect("the table has a row per exponent") + } + + fn read_double(w: &W) -> f64 { + BitReader::new(&w.bytes()).double().unwrap() + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn zero_is_the_two_bit_code_with_no_sign() { + let mut w = W::default(); + w.put(0b01, 2).put(0b1111_1111, 8); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.double().unwrap(), 0.0); + assert_eq!(r.position(), 2); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn exponent_with_literal_mantissa() { + let v = -1234.5678f64; + let le = v.to_bits().to_le_bytes(); + let e = u32::try_from((v.to_bits() >> 52) & 0x7ff).unwrap(); + let (n, c) = exponent_code(e); + let mut w = W::default(); + w.put(u64::from(c), n).put(1, 1).put(1, 1).put(u64::from(le[6] & 0x0f), 4); + for byte in le[..6].iter().rev() { + w.put(1, 1).put(u64::from(*byte), 8); + } + assert_eq!(read_double(&w), v); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn exponent_without_mantissa_is_a_power_of_two() { + let (n, c) = exponent_code(1023 + 3); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(0, 1); + assert_eq!(read_double(&w), 8.0); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn fill_back_reference_and_literal_low_byte() { + // Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11. + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0xAA, 8); + w.put(0, 1).put(1, 3); + w.put(0, 1).put(2, 3); + w.put(0, 1).put(6, 3).put(0x11, 8); + assert_eq!(read_double(&w), f64::from_le_bytes([0x11, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xF5, 0x3F])); + + // Byte 5 literal, then fill 4..0. + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0x5, 4); + w.put(1, 1).put(0x77, 8).put(0, 1).put(0, 3); + assert_eq!(read_double(&w), f64::from_le_bytes([0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xF5, 0x3F])); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn a_back_reference_past_the_value_is_refused() { + let (n, c) = exponent_code(1023); + let mut w = W::default(); + w.put(u64::from(c), n).put(0, 1).put(1, 1).put(0, 4); + w.put(0, 1).put(5, 3).put(0, 16); // byte 5 + offset 5 = byte 10 + assert!(matches!(BitReader::new(&w.bytes()).double(), Err(PrcError::Malformed(_)))); + } + + #[test] + fn without_the_table_a_double_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0x40, 0, 0]); + assert_eq!(r.double(), Err(PrcError::UnknownDoubleCode)); + assert!(matches!(BitReader::new(&[0x40]).double(), Err(PrcError::Truncated(_)))); + } + + #[test] + fn integers_and_strings() { + let mut w = W::default(); + // UnsignedInteger 300 = groups 0x2C, 0x01. + w.put(1, 1).put(0x2C, 8).put(1, 1).put(0x01, 8).put(0, 1); + // Integer -2 = group 0xFE, stop. + w.put(1, 1).put(0xFE, 8).put(0, 1); + // Integer 200 = 0xC8 then 0x00 (top bit clear), stop. + w.put(1, 1).put(0xC8, 8).put(1, 1).put(0x00, 8).put(0, 1); + // IntegerWithVariableBitNumber(4) = -5. + w.put(1, 1).put(5, 3); + // String "hé". + w.put(1, 1).put(1, 1).put(3, 8).put(0, 1).put(0x68, 8).put(0xC3, 8).put(0xA9, 8); + // Null string. + w.put(0, 1); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 300); + assert_eq!(r.integer().unwrap(), -2); + assert_eq!(r.integer().unwrap(), 200); + assert_eq!(r.integer_vbn(4).unwrap(), -5); + assert_eq!(r.string().unwrap().as_deref(), Some("hé")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn the_writer_round_trips_through_the_reader() { + let mut w = W::default(); + w.uint(0).uint(u32::MAX).int(-129).int(i32::MIN).int(i32::MAX).string(Some("glass")).string(None); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + assert_eq!(r.unsigned_integer().unwrap(), 0); + assert_eq!(r.unsigned_integer().unwrap(), u32::MAX); + assert_eq!(r.integer().unwrap(), -129); + assert_eq!(r.integer().unwrap(), i32::MIN); + assert_eq!(r.integer().unwrap(), i32::MAX); + assert_eq!(r.string().unwrap().as_deref(), Some("glass")); + assert_eq!(r.string().unwrap(), None); + } + + #[test] + fn oversized_groups_and_widths_are_malformed() { + // Five UnsignedInteger groups. + let mut w = W::default(); + (0..5).for_each(|_| { + w.put(1, 1).put(0xFF, 8); + }); + w.put(0, 1); + assert!(matches!(BitReader::new(&w.bytes()).unsigned_integer(), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).bits(33), Err(PrcError::Malformed(_)))); + assert!(matches!(BitReader::new(&[0; 8]).integer_vbn(34), Err(PrcError::Malformed(_)))); + assert_eq!(BitReader::new(&[]).integer_vbn(0), Ok(0)); + } + + #[test] + fn truncation_is_an_error_not_a_panic() { + let mut r = BitReader::new(&[0xFF]); + assert!(r.unsigned_integer().is_err()); + let mut r = BitReader::new(&[0xC0, 0x40]); + assert!(matches!(r.string(), Err(PrcError::Truncated(_)))); + let mut r = BitReader::new(&[0xAB]); + assert!(matches!(r.skip_bits(9), Err(PrcError::Truncated(_)))); + assert_eq!(r.position(), 0, "a refused skip does not move the cursor"); + r.skip_bits(8).unwrap(); + assert_eq!(r.remaining(), 0); + } +} diff --git a/crates/prc/src/container.rs b/crates/prc/src/container.rs new file mode 100644 index 0000000000..8a44921334 --- /dev/null +++ b/crates/prc/src/container.rs @@ -0,0 +1,325 @@ +//! The PRC container: file header, file-structure descriptions, section extents and +//! inflation [WD 6.1-6.2, 10.2]. +//! +//! Section extents are not stored: each section runs to the next one's start offset, and the +//! last one to the model file's start. Inflation stops at the zlib stream's end, so padding +//! between sections is tolerated. + +use std::io::Read; + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; + +/// The PRC version this reader implements. +pub const PRC_READER_VERSION: u32 = 8137; + +/// The most file structures [`PrcFile::parse`] accepts. +pub const MAX_FILE_STRUCTURES: usize = 65_536; + +/// Ceiling on the total inflated size of every section in one PRC stream. +pub const MAX_INFLATED_BYTES: usize = 512 * 1024 * 1024; + +/// Sections per file structure [WD 6.1.2]: its header, then the five in [`SectionKind`] +/// order. +const SECTIONS: usize = 6; + +/// A 128-bit PRC identifier [WD 10.2.4]: four little-endian `u32`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct UniqueId(pub [u32; 4]); + +/// The compressed sections of one file structure, in file order [WD 6.1.2, 6.2.1]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SectionKind { + /// Starts with the file structure's schema, then its globals entity. + Globals, + /// The product-structure tree. + Tree, + /// Tessellated (mesh) data. + Tessellation, + /// Exact (B-rep) geometry. + Geometry, + /// Supplementary geometry. + ExtraGeometry, +} + +impl SectionKind { + /// Every kind, in file order. + pub const ALL: [SectionKind; 5] = + [SectionKind::Globals, SectionKind::Tree, SectionKind::Tessellation, SectionKind::Geometry, SectionKind::ExtraGeometry]; + + /// The section's name, as used in errors. + pub fn name(self) -> &'static str { + match self { + SectionKind::Globals => "globals", + SectionKind::Tree => "tree", + SectionKind::Tessellation => "tessellation", + SectionKind::Geometry => "geometry", + SectionKind::ExtraGeometry => "extra geometry", + } + } + + fn index(self) -> usize { + match self { + SectionKind::Globals => 0, + SectionKind::Tree => 1, + SectionKind::Tessellation => 2, + SectionKind::Geometry => 3, + SectionKind::ExtraGeometry => 4, + } + } +} + +/// The uncompressed file header [WD 6.1.1]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcHeader { + /// The oldest reader version the producer says can read the file. + pub min_version_for_read: u32, + /// The producer's PRC version. + pub authoring_version: u32, + /// The file's identifier. + pub file_id: UniqueId, + /// The producing application's identifier (all zero = unregistered). + pub application_id: UniqueId, +} + +/// One file structure: its uncompressed header fields and its five inflated sections +/// [WD 6.2]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct FileStructure { + /// The identifier; equals the file header's description of it. + pub id: UniqueId, + /// Per-structure reader version floor; may differ from the file's. + pub min_version_for_read: u32, + /// Per-structure producer version. + pub authoring_version: u32, + /// Picture payloads (JPEG, PNG, ...) carried uncompressed in the header, referenced by + /// index from picture entities [WD 6.2.2]. + pub pictures: Vec>, + sections: [Vec; 5], +} + +impl FileStructure { + /// The inflated bytes of one section. + pub fn section(&self, kind: SectionKind) -> &[u8] { + self.sections.get(kind.index()).map_or(&[], Vec::as_slice) + } + + /// The globals section's leading schema: the fields producers newer than this reader + /// added to each entity type [WD 6.3]. + /// + /// # Errors + /// As [`Schema::read`]. + pub fn schema(&self) -> Result { + Schema::read(&mut BitReader::new(self.section(SectionKind::Globals))) + } +} + +/// A parsed PRC stream: header, file structures and model-file section. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct PrcFile { + /// The file header. + pub header: PrcHeader, + /// File structures in physical order (referenced before referencing). + pub file_structures: Vec, + /// The inflated model-file section: its schema, then the model file entity. + pub model_file: Vec, + /// The `UncompressedFiles` blocks after the file header [WD 6.1.1], which picture + /// entities may also reference. + pub uncompressed_files: Vec>, +} + +/// A little-endian byte cursor over the uncompressed parts of the stream. +struct Bytes<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Bytes<'a> { + fn take(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], PrcError> { + let end = self.pos.checked_add(n).ok_or(PrcError::Truncated(what))?; + let s = self.data.get(self.pos..end).ok_or(PrcError::Truncated(what))?; + self.pos = end; + Ok(s) + } + + fn u32(&mut self, what: &'static str) -> Result { + let s = self.take(4, what)?; + let b: [u8; 4] = s.try_into().map_err(|_| PrcError::Truncated(what))?; + Ok(u32::from_le_bytes(b)) + } + + fn id(&mut self, what: &'static str) -> Result { + Ok(UniqueId([self.u32(what)?, self.u32(what)?, self.u32(what)?, self.u32(what)?])) + } + + fn magic(&mut self) -> Result<(), PrcError> { + match self.take(3, "magic") { + Ok(b"PRC") => Ok(()), + _ => Err(PrcError::NotPrc), + } + } + + /// `UncompressedBlock`s: a count, then size-prefixed blocks [WD 10.2.2-3]. + fn blocks(&mut self, what: &'static str) -> Result>, PrcError> { + let n = offset(self.u32(what)?); + // Each block needs at least its 4-byte size, so `n` is bounded by the data left. + if n > self.data.len().saturating_sub(self.pos) / 4 { + return Err(PrcError::Truncated(what)); + } + (0..n) + .map(|_| { + let len = offset(self.u32(what)?); + Ok(self.take(len, what)?.to_vec()) + }) + .collect() + } +} + +/// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data +/// that large could exist). +fn offset(v: u32) -> usize { + usize::try_from(v).unwrap_or(usize::MAX) +} + +/// One file structure description of the file header [WD 6.2]: its id and the offsets of +/// its sections. +fn file_structure_description(r: &mut Bytes) -> Result<(UniqueId, Vec), PrcError> { + let id = r.id("file structure description")?; + if r.u32("file structure description")? != 0 { + return Err(PrcError::Malformed("file structure reserved field is not 0".into())); + } + let count = offset(r.u32("file structure description")?); + if count < SECTIONS { + return Err(PrcError::Malformed(format!("{count} sections in a file structure"))); + } + let mut offs = Vec::with_capacity(SECTIONS); + // Offsets past the six this reader uses are read and dropped; each read is bounded by + // the data, so a huge `count` ends in `Truncated`. + for i in 0..count { + let o = r.u32("section offset")?; + if i < SECTIONS { + offs.push(offset(o)); + } + } + Ok((id, offs)) +} + +/// Where one file structure's parts lie: its section offsets and the end of its last section. +struct Extent<'a> { + offs: &'a [usize], + last: usize, +} + +impl Extent<'_> { + fn bound(&self, i: usize) -> usize { + self.offs.get(i).copied().unwrap_or(self.last) + } +} + +/// The file structure `id` lying at `extent`: its header and its inflated sections, charged +/// to `budget`. +fn file_structure(data: &[u8], id: UniqueId, extent: &Extent, budget: &mut Budget) -> Result { + if extent.offs.windows(2).any(|w| w.first() > w.get(1)) || extent.bound(SECTIONS - 1) > extent.last { + return Err(PrcError::Malformed("file structure section offsets are not ascending".into())); + } + let header = data.get(..extent.bound(1)).ok_or(PrcError::Truncated("file structure header"))?; + let mut h = Bytes { data: header, pos: extent.bound(0) }; + h.magic()?; + let min_version_for_read = h.u32("file structure header")?; + let authoring_version = h.u32("file structure header")?; + if h.id("file structure header")? != id { + return Err(PrcError::Malformed("file structure id differs from its description".into())); + } + let _application = h.id("file structure header")?; + let pictures = h.blocks("file structure pictures")?; + let mut sections: [Vec; 5] = Default::default(); + for (i, (slot, kind)) in sections.iter_mut().zip(SectionKind::ALL).enumerate() { + let end = if i + 2 < SECTIONS { extent.bound(i + 2) } else { extent.last }; + *slot = budget.inflate(data, (extent.bound(i + 1), end), kind.name())?; + } + Ok(FileStructure { id, min_version_for_read, authoring_version, pictures, sections }) +} + +/// What is left of the inflation ceiling, and the ceiling itself for the error. +struct Budget { + left: usize, + limit: usize, +} + +impl Budget { + /// The zlib stream in `data[range]`, its size charged to the budget. + fn inflate(&mut self, data: &[u8], range: (usize, usize), section: &'static str) -> Result, PrcError> { + let (start, end) = range; + if start == end { + return Ok(Vec::new()); + } + let src = data.get(start..end).ok_or_else(|| PrcError::Malformed(format!("{section} section {start}..{end} is outside the data")))?; + let mut out = Vec::new(); + // One byte past the budget is enough to know it was exceeded. + let cap = u64::try_from(self.left).unwrap_or(u64::MAX).saturating_add(1); + flate2::read::ZlibDecoder::new(src).take(cap).read_to_end(&mut out).map_err(|e| PrcError::Inflate { section, reason: e.to_string() })?; + self.left = self.left.checked_sub(out.len()).ok_or(PrcError::TooLarge { limit: self.limit })?; + Ok(out) + } +} + +impl PrcFile { + /// Parse a PRC stream (the decoded data of a `/3D` stream with `/Subtype /PRC`). + /// + /// Newer `min_version_for_read` values are not refused: the fields read here are + /// unchanged across published versions, and the caller sees the versions in + /// [`PrcHeader`]. + /// + /// # Errors + /// [`PrcError::NotPrc`] without the magic; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged header or offset table; [`PrcError::Inflate`] + /// for a section that is not zlib; [`PrcError::TooLarge`] past [`MAX_INFLATED_BYTES`] + /// in total. + /// + /// # Examples + /// ``` + /// use pdfcraft_prc::{PrcError, PrcFile}; + /// assert_eq!(PrcFile::parse(b"%PDF"), Err(PrcError::NotPrc)); + /// ``` + pub fn parse(data: &[u8]) -> Result { + Self::parse_with_limit(data, MAX_INFLATED_BYTES) + } + + /// [`Self::parse`] with a caller-chosen ceiling on the total inflated size, for callers + /// with less memory to spend than [`MAX_INFLATED_BYTES`]. + /// + /// # Errors + /// As [`Self::parse`]; [`PrcError::TooLarge`] carries `limit`. + pub fn parse_with_limit(data: &[u8], limit: usize) -> Result { + let mut r = Bytes { data, pos: 0 }; + r.magic()?; + let header = PrcHeader { + min_version_for_read: r.u32("file header")?, + authoring_version: r.u32("file header")?, + file_id: r.id("file header")?, + application_id: r.id("file header")?, + }; + let n_fs = offset(r.u32("file structure count")?); + if n_fs == 0 || n_fs > MAX_FILE_STRUCTURES { + return Err(PrcError::Malformed(format!("{n_fs} file structures"))); + } + let descriptions = (0..n_fs).map(|_| file_structure_description(&mut r)).collect::, _>>()?; + let mf_start = offset(r.u32("model file offsets")?); + let mf_end = offset(r.u32("model file offsets")?); + let uncompressed_files = r.blocks("uncompressed files")?; + if mf_end < mf_start { + return Err(PrcError::Malformed("model file ends before it starts".into())); + } + + let mut budget = Budget { left: limit, limit }; + let last = mf_start.min(data.len()); + let file_structures = + descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::, _>>()?; + let model_file = budget.inflate(data, (mf_start, mf_end.min(data.len())), "model file")?; + Ok(PrcFile { header, file_structures, model_file, uncompressed_files }) + } +} diff --git a/crates/prc/src/error.rs b/crates/prc/src/error.rs new file mode 100644 index 0000000000..eaad2bb6e1 --- /dev/null +++ b/crates/prc/src/error.rs @@ -0,0 +1,38 @@ +//! The crate's error type. + +/// Why a PRC stream could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PrcError { + /// The data does not start with the `PRC` magic [WD 6.1.1]. + #[error("not a PRC stream: missing the \"PRC\" magic")] + NotPrc, + /// A value ran past the end of its byte or bit stream. + #[error("PRC data truncated while reading {0}")] + Truncated(&'static str), + /// A structural field holds a value the format forbids. + #[error("malformed PRC: {0}")] + Malformed(String), + /// No `Double` code of up to 22 bits matched [WD 11.17]. + #[error("PRC Double: no code matches the next 22 bits")] + UnknownDoubleCode, + /// A section is not a valid zlib stream. + #[error("PRC {section} section does not inflate: {reason}")] + Inflate { + /// The section's name. + section: &'static str, + /// The decoder's message. + reason: String, + }, + /// The inflated sections together exceed the ceiling ([`crate::MAX_INFLATED_BYTES`] or + /// the caller's limit). + #[error("PRC sections inflate past the {limit}-byte ceiling")] + TooLarge { + /// The ceiling that was hit. + limit: usize, + }, + /// A construct this reader does not decode; the part of the file holding it cannot be + /// read past. + #[error("PRC {0} is not supported")] + Unsupported(&'static str), +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs new file mode 100644 index 0000000000..1a62f9f272 --- /dev/null +++ b/crates/prc/src/lib.rs @@ -0,0 +1,26 @@ +//! A reader for PRC, the 3D model format a PDF `/3D` stream of `/Subtype /PRC` carries +//! (ISO 32000-2 §13.6.3, ISO 14739-1). +//! +//! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the +//! uncompressed container, inflates each section, and decodes the bit-level primitives and +//! the schema a newer producer uses to describe fields this reader does not know. +//! +//! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570. +//! +//! Every read is bounded: inflation by [`MAX_INFLATED_BYTES`] (or a caller's limit), schema +//! programs by a step and depth ceiling. Damaged data is an error, never a panic. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod acof; +mod bits; +mod container; +mod error; +mod schema; +#[cfg(test)] +mod testw; + +pub use bits::BitReader; +pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; +pub use error::PrcError; +pub use schema::Schema; diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs new file mode 100644 index 0000000000..a93554334b --- /dev/null +++ b/crates/prc/src/schema.rs @@ -0,0 +1,525 @@ +//! `FileStructureSchema` and its token interpreter [WD 6.3, 9]. +//! +//! A producer newer than the reader describes, per entity type, the fields it appended; the +//! reader runs that program after the fields it knows, to step over the rest [WD 9.1]. Token +//! values follow the WD 9.2 table as corrected by PDF Association pdf-issues #407 +//! (`Block_Start` = 19; 39 and 40 swallow one token). +//! +//! Choices the sources leave open, made here: +//! - `SimpleFor` reads its count as an `UnsignedInteger` [WD 9.3.17 pseudocode; pdf-issues +//! #575]: that read also decodes an `Integer`-written non-negative count, while a signed +//! read does not decode an unsigned one whose top group is 0x80 or higher. +//! - `Block_Version V` runs its block only when `V` is newer than +//! [`crate::PRC_READER_VERSION`]: an older block holds fields this reader already reads +//! natively [WD 9.3.20]. +//! - `Extent_1D` and `Extent_2D` read 2 and 4 `Double`s: `Interval` and `Domain` +//! [WD 8.2.2, 8.2.4]. +//! - Expression values are `i64`; a `Double` read as an operand truncates (NaN reads as 0). +//! - Variables are not block-scoped: a program that reuses an index after its block ends +//! reads the stale value instead of an error. +//! - Pointer tokens (12-14) and `Value_CurveIs3D` (28) need the exact-geometry reader and are +//! refused with [`PrcError::Unsupported`]. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Steps (tokens executed, loop iterations included) one +/// [`Schema::skip_added_fields`] call may take. +const MAX_STEPS: u64 = 1 << 22; + +/// Nesting depth of blocks, loops, expressions and `Father_Type` calls. +const MAX_DEPTH: u32 = 64; + +/// The per-entity-type programs at the start of a globals or model-file +/// bitstream [WD 6.3.1]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Schema { + programs: HashMap>, +} + +impl Schema { + /// Read a schema: `UnsignedInteger` count, then per entry the entity + /// type, a token count and the tokens [WD 6.3.2]. + /// + /// # Errors + /// [`PrcError::Truncated`] when a count runs past the data; + /// [`PrcError::Malformed`] for an entity type listed twice. + pub fn read(r: &mut BitReader<'_>) -> Result { + let n = r.unsigned_integer()? as usize; + // Every entry takes at least two bits. + if n > r.remaining() / 2 { + return Err(PrcError::Truncated("the schema")); + } + let mut programs = HashMap::new(); + for _ in 0..n { + let entity_type = r.unsigned_integer()?; + let len = r.unsigned_integer()? as usize; + if len > r.remaining() { + return Err(PrcError::Truncated("a schema program")); + } + let tokens = (0..len).map(|_| r.unsigned_integer()).collect::, _>>()?; + if programs.insert(entity_type, tokens).is_some() { + return Err(PrcError::Malformed(format!("schema lists entity type {entity_type} twice"))); + } + } + Ok(Self { programs }) + } + + /// True when no entity type has added fields (the common case: the file + /// was written at the version this reader implements). + pub fn is_empty(&self) -> bool { + self.programs.is_empty() + } + + /// The entity types the schema describes, unordered. + pub fn entity_types(&self) -> impl Iterator + '_ { + self.programs.keys().copied() + } + + /// Consume the fields a newer producer appended to `entity_type`, if the + /// schema describes it; a no-op otherwise. + /// + /// # Errors + /// [`PrcError::Unsupported`] for a token that needs exact geometry; + /// [`PrcError::Malformed`] for an ill-formed program or one past the step + /// or depth ceiling; any read error from `r`. + pub fn skip_added_fields(&self, entity_type: u32, r: &mut BitReader<'_>) -> Result<(), PrcError> { + let Some(program) = self.programs.get(&entity_type) else { + return Ok(()); + }; + let mut exec = Exec { schema: self, r, vars: HashMap::new(), loop_counter: 0, steps: 0 }; + exec.program(program, 0) + } +} + +struct Exec<'s, 'r, 'd> { + schema: &'s Schema, + r: &'r mut BitReader<'d>, + vars: HashMap, + loop_counter: i64, + steps: u64, +} + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(format!("schema program: {what}")) +} + +/// The token at `*pos`, advancing past it. +fn next(prog: &[u32], pos: &mut usize) -> Result { + let t = prog.get(*pos).copied().ok_or_else(|| malformed("ends mid-statement"))?; + *pos += 1; + Ok(t) +} + +impl Exec<'_, '_, '_> { + fn tick(&mut self, depth: u32) -> Result<(), PrcError> { + self.steps += 1; + if self.steps > MAX_STEPS { + return Err(malformed("step ceiling reached")); + } + if depth > MAX_DEPTH { + return Err(malformed("nesting ceiling reached")); + } + Ok(()) + } + + fn program(&mut self, prog: &[u32], depth: u32) -> Result<(), PrcError> { + let mut pos = 0; + while pos < prog.len() { + self.stmt(prog, &mut pos, true, depth)?; + } + Ok(()) + } + + /// One data read for tokens 0-5 and 7-11, returning its value as an + /// operand. + fn read(&mut self, token: u32) -> Result { + let doubles = match token { + 0 => return Ok(i64::from(self.r.bit()?)), + 1 => return Ok(self.r.double()? as i64), + 2 => return Ok(i64::from(self.r.character()?)), + 3 => return Ok(i64::from(self.r.unsigned_integer()?)), + 4 => return Ok(i64::from(self.r.integer()?)), + 5 => { + self.r.string()?; + return Ok(0); + } + 7 => 2, + 8 => 3, + 9 => 2, + 10 => 4, + 11 => 6, + _ => return Err(malformed("not a data token")), + }; + for _ in 0..doubles { + self.r.double()?; + } + Ok(0) + } + + /// Statements until `Block_End`, which is consumed. + fn block(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + loop { + if prog.get(*pos) == Some(&21) { + *pos += 1; + return Ok(()); + } + self.stmt(prog, pos, run, depth + 1)?; + } + } + + /// Run `body` (one statement at `*pos`) `count` times; `*pos` ends past it. + fn repeat(&mut self, prog: &[u32], pos: &mut usize, count: i64, run: bool, depth: u32) -> Result<(), PrcError> { + let start = *pos; + self.stmt(prog, pos, false, depth + 1)?; + if run { + let saved = self.loop_counter; + for i in 0..count.max(0) { + self.tick(depth)?; + self.loop_counter = i; + let mut p = start; + self.stmt(prog, &mut p, true, depth + 1)?; + } + self.loop_counter = saved; + } + Ok(()) + } + + /// One statement; with `run` false, only step over its tokens. + fn stmt(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + self.tick(depth)?; + match next(prog, pos)? { + t @ (0..=5 | 7..=11) => { + if run { + self.read(t)?; + } + } + 6 => self.parent_program(prog, pos, run, depth)?, + 12 => { + next(prog, pos)?; + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 13 | 14 => { + if run { + return Err(PrcError::Unsupported("a schema pointer field")); + } + } + 15 => { + let count = self.expr(prog, pos, run, depth + 1)?; + self.repeat(prog, pos, count, run, depth)?; + } + 16 => { + let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 }; + self.repeat(prog, pos, count, run, depth)?; + } + 17 => self.conditional(prog, pos, run, depth)?, + 19 => self.block(prog, pos, run, depth)?, + 20 => { + let version = next(prog, pos)?; + let newer = version > crate::PRC_READER_VERSION; + self.block(prog, pos, run && newer, depth)?; + } + 22 => { + let v = next(prog, pos)?; + if run { + self.vars.insert(v, 0); + } + } + 23 | 24 => { + let v = next(prog, pos)?; + let value = self.expr(prog, pos, run, depth + 1)?; + if run { + self.vars.insert(v, value); + } + } + 39 | 40 => { + next(prog, pos)?; + } + t => return Err(malformed(&format!("token {t} where a statement belongs"))), + } + Ok(()) + } + + /// Token 6: run the named type's own program, the fields it inherits. + fn parent_program(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let parent = next(prog, pos)?; + if run && let Some(p) = self.schema.programs.get(&parent) { + self.tick(depth + 1)?; + let mut q = 0; + while q < p.len() { + self.stmt(p, &mut q, true, depth + 1)?; + } + } + Ok(()) + } + + /// Token 17: `if` with an optional token-18 `else`. + fn conditional(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result<(), PrcError> { + let cond = self.expr(prog, pos, run, depth + 1)? != 0; + self.stmt(prog, pos, run && cond, depth + 1)?; + if prog.get(*pos) == Some(&18) { + *pos += 1; + self.stmt(prog, pos, run && !cond, depth + 1)?; + } + Ok(()) + } + + /// One expression; with `run` false its value is 0 and nothing is read. + fn expr(&mut self, prog: &[u32], pos: &mut usize, run: bool, depth: u32) -> Result { + self.tick(depth)?; + let t = next(prog, pos)?; + match t { + 0..=4 => { + if run { + self.read(t) + } else { + Ok(0) + } + } + 25 => { + let v = next(prog, pos)?; + if !run { + return Ok(0); + } + self.vars.get(&v).copied().ok_or_else(|| malformed(&format!("variable {v} read before it is set"))) + } + 26 => Ok(i64::from(next(prog, pos)?)), + 27 => Ok(self.loop_counter), + 28 => { + if run { + Err(PrcError::Unsupported("Value_CurveIs3D")) + } else { + Ok(0) + } + } + 29..=38 => { + let a = self.expr(prog, pos, run, depth + 1)?; + let b = self.expr(prog, pos, run, depth + 1)?; + if !run { + return Ok(0); + } + Ok(match t { + 29 => a.wrapping_mul(b), + 30 => a.checked_div(b).ok_or_else(|| malformed("division by zero"))?, + 31 => a.wrapping_add(b), + 32 => a.wrapping_sub(b), + 33 => i64::from(a < b), + 34 => i64::from(a <= b), + 35 => i64::from(a > b), + 36 => i64::from(a >= b), + 37 => i64::from(a == b), + _ => i64::from(a != b), + }) + } + t => Err(malformed(&format!("token {t} where a value belongs"))), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::testw::W; + + fn schema(entries: &[(u32, &[u32])]) -> W { + let mut w = W::default(); + w.uint(entries.len() as u32); + for (t, toks) in entries { + w.uint(*t).uint(toks.len() as u32); + for k in *toks { + w.uint(*k); + } + } + w + } + + fn run(entries: &[(u32, &[u32])], data: &W) -> (Result<(), PrcError>, usize) { + let mut w = schema(entries); + w.append(data); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let start = r.position(); + let res = s.skip_added_fields(172, &mut r); + (res, r.position() - start) + } + + #[test] + fn integer_string_and_boolean_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).uint(300).int(-7).string(Some("ab")).put(0x41, 8); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 3, 4, 5, 2])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn data_tokens_consume_their_fields() { + let mut d = W::default(); + d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0); + let want = d.len(); + d.uint(99); // not part of the added fields + let (res, used) = run(&[(172, &[0, 1, 3, 4, 5, 7])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + #[ignore = "the Double table is not in the crate yet"] + fn vector_extent_and_box_tokens_read_their_doubles() { + // Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles. + let mut d = W::default(); + for i in 0..15 { + d.double(f64::from(i) + 0.5); + } + let want = d.len(); + d.uint(99); + let (res, used) = run(&[(172, &[8, 9, 10, 11])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn arithmetic_and_comparison_operators() { + // For (5 - 3) { UInt }, then If (2 < 2) { Bool } Else { Char }. + let mut d = W::default(); + d.uint(7).uint(8).put(0x41, 8); + let want = d.len(); + d.uint(99); + let prog = [15, 32, 26, 5, 26, 3, 3, 17, 33, 26, 2, 26, 2, 0, 18, 2]; + let (res, used) = run(&[(172, &prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn an_absent_type_is_a_no_op() { + let mut w = schema(&[(300, &[3])]); + w.uint(5); + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let s = Schema::read(&mut r).unwrap(); + let at = r.position(); + s.skip_added_fields(172, &mut r).unwrap(); + assert_eq!(r.position(), at); + assert!(!s.is_empty()); + } + + #[test] + fn simple_for_reads_its_count_then_repeats_the_body() { + let mut d = W::default(); + d.int(3).uint(1).uint(2).uint(3); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A block body. + let mut d = W::default(); + d.int(2).bit(true).uint(9).bit(false).uint(8); + let want = d.len(); + let (res, used) = run(&[(172, &[16, 19, 0, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + + // A count whose top byte is 0x80 or higher, written either way. + for signed in [false, true] { + let mut d = W::default(); + if signed { + d.int(200); + } else { + d.uint(200); + } + (0..200).for_each(|_| { + d.bit(true); + }); + let want = d.len(); + d.uint(77); + let (res, used) = run(&[(172, &[16, 0])], &d); + res.unwrap(); + assert_eq!(used, want, "signed={signed}"); + } + } + + #[test] + fn if_else_and_variables_choose_the_branch_by_file_data() { + // DeclareAndSet v0 = ; if v0 > 1 { UInt } else { Character }. + let prog: &[u32] = &[24, 0, 3, 17, 35, 25, 0, 26, 1, 3, 18, 2]; + let mut d = W::default(); + d.uint(5).uint(4250); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + + let mut d = W::default(); + d.uint(0).put(0xAB, 8); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn for_counts_by_expression_and_exposes_the_counter() { + // For (2 * 2) { if counter == 2 { UInt } } => one UInt read. + let prog: &[u32] = &[15, 29, 26, 2, 26, 2, 17, 37, 27, 26, 2, 3]; + let mut d = W::default(); + d.uint(1234); + let want = d.len(); + let (res, used) = run(&[(172, prog)], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn version_blocks_run_only_when_newer_than_the_reader() { + let mut d = W::default(); + d.uint(42); + let want = d.len(); + let (res, used) = run(&[(172, &[20, 8137, 3, 21, 20, 9000, 3, 21])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn father_type_runs_the_parent_program_and_obsolete_tokens_swallow_one() { + let mut d = W::default(); + d.uint(1).uint(2); + let want = d.len(); + let (res, used) = run(&[(172, &[39, 3, 6, 50, 40, 8]), (50, &[3, 3])], &d); + res.unwrap(); + assert_eq!(used, want); + } + + #[test] + fn hostile_programs_are_refused_not_run_forever() { + let d = W::default(); + // For (1000000 * 1000000) { empty block }. + let prog: &[u32] = &[15, 29, 26, 1_000_000, 26, 1_000_000, 19, 21]; + assert!(matches!(run(&[(172, prog)], &d).0, Err(PrcError::Malformed(_)))); + // Father_Type recursing into itself. + assert!(matches!(run(&[(172, &[6, 172])], &d).0, Err(PrcError::Malformed(_)))); + // A pointer field. + assert!(matches!(run(&[(172, &[13])], &d).0, Err(PrcError::Unsupported(_)))); + // A stray Block_End, an unterminated block, a division by zero. + assert!(matches!(run(&[(172, &[21])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[19, 0])], &d).0, Err(PrcError::Malformed(_)))); + assert!(matches!(run(&[(172, &[23, 0, 30, 26, 1, 26, 0])], &d).0, Err(PrcError::Malformed(_)))); + } + + #[test] + fn a_duplicated_entity_type_is_malformed() { + let bytes = schema(&[(172, &[3]), (172, &[3])]).bytes(); + assert!(matches!(Schema::read(&mut BitReader::new(&bytes)), Err(PrcError::Malformed(_)))); + } +} diff --git a/crates/prc/src/testw.rs b/crates/prc/src/testw.rs new file mode 100644 index 0000000000..4706a20ef8 --- /dev/null +++ b/crates/prc/src/testw.rs @@ -0,0 +1,101 @@ +//! Test-only PRC bit writer: the inverse of [`crate::BitReader`]. + +#![allow(clippy::expect_used)] + +use crate::acof; + +/// An MSB-first bit sink for building test streams. +#[derive(Debug, Default, Clone)] +pub(crate) struct W { + bits: Vec, +} + +impl W { + /// The low `n` bits of `v` (`n` ≤ 64), most significant first. + pub(crate) fn put(&mut self, v: u64, n: u32) -> &mut Self { + for i in (0..n).rev() { + self.bits.push((v >> i) & 1 == 1); + } + self + } + + /// One bit; a PRC `Boolean`. + pub(crate) fn bit(&mut self, b: bool) -> &mut Self { + self.bits.push(b); + self + } + + /// Bits written so far. + pub(crate) fn len(&self) -> usize { + self.bits.len() + } + + /// Every bit of `other`, unaligned. + pub(crate) fn append(&mut self, other: &Self) -> &mut Self { + self.bits.extend_from_slice(&other.bits); + self + } + + /// An `UnsignedInteger` [WD 11.10]. + pub(crate) fn uint(&mut self, mut v: u32) -> &mut Self { + while v != 0 { + self.bit(true).put(u64::from(v & 0xff), 8); + v >>= 8; + } + self.bit(false) + } + + /// An `Integer` [WD 11.11]: two's-complement groups, low first, each followed by a + /// continue bit. + pub(crate) fn int(&mut self, v: i32) -> &mut Self { + if v == 0 { + return self.bit(false); + } + self.bit(true); + let mut v = i64::from(v); + loop { + let group = v & 0xff; + self.put(group.cast_unsigned(), 8); + v >>= 8; + let done = (v == 0 && group & 0x80 == 0) || (v == -1 && group & 0x80 != 0); + self.bit(!done); + if done { + return self; + } + } + } + + /// A `String` [WD 11.4]: presence bit, byte count, raw UTF-8 bytes. + pub(crate) fn string(&mut self, s: Option<&str>) -> &mut Self { + let Some(s) = s else { + return self.bit(false); + }; + self.bit(true).uint(u32::try_from(s.len()).expect("a short test string")); + for b in s.bytes() { + self.put(u64::from(b), 8); + } + self + } + + /// A finite `Double` [WD 11.17]: zero as the two-bit zero code; anything else as its + /// exponent row, the sign, then the mantissa as literal bytes. + pub(crate) fn double(&mut self, v: f64) -> &mut Self { + if v == 0.0 { + return self.put(0b01, 2); + } + let bits = v.to_bits(); + let exponent = u32::try_from((bits >> 52) & 0x7ff).expect("11 bits"); + let (n, c) = acof::exponent_code(exponent).expect("the Double table has a row per exponent"); + let le = bits.to_le_bytes(); + self.put(u64::from(c), n).bit(v < 0.0).bit(true).put(u64::from(le[6] & 0x0f), 4); + for b in le[..6].iter().rev() { + self.bit(true).put(u64::from(*b), 8); + } + self + } + + /// The bits packed MSB-first, the last byte zero-padded. + pub(crate) fn bytes(&self) -> Vec { + self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() + } +} diff --git a/crates/prc/tests/container.rs b/crates/prc/tests/container.rs new file mode 100644 index 0000000000..dc9970704b --- /dev/null +++ b/crates/prc/tests/container.rs @@ -0,0 +1,114 @@ +//! The PRC container walk [WD 6.1-6.2] against synthetic streams. + +#![allow(clippy::unwrap_used)] + +use std::io::Write; + +use pdfcraft_prc::{PrcError, PrcFile, SectionKind, UniqueId}; + +fn zlib(data: &[u8]) -> Vec { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() +} + +fn u32s(out: &mut Vec, vs: &[u32]) { + for v in vs { + out.extend_from_slice(&v.to_le_bytes()); + } +} + +/// One file structure with the given section payloads and model file. +fn build(sections: [&[u8]; 5], model: &[u8], padding: usize) -> Vec { + const HEADER_LEN: usize = 107; + let mut fs_header = b"PRC".to_vec(); + u32s(&mut fs_header, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 1, 3]); + fs_header.extend_from_slice(b"\xff\xd8\xff"); + + let mut body = fs_header.clone(); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + body.extend(std::iter::repeat_n(0u8, padding)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(model)); + let mf_end = HEADER_LEN + body.len(); + + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| o as u32).collect::>()); + u32s(&mut out, &[mf_start as u32, mf_end as u32, 0]); + assert_eq!(out.len(), HEADER_LEN); + out.extend(body); + out +} + +const SECTIONS: [&[u8]; 5] = [b"globals", b"tree", b"tess", b"", b"extra"]; + +#[test] +fn the_container_walk_inflates_every_section() { + for padding in [0, 3] { + let f = PrcFile::parse(&build(SECTIONS, b"model file", padding)).unwrap(); + assert_eq!(f.header.min_version_for_read, 8137); + assert_eq!(f.header.file_id, UniqueId([1, 2, 3, 4])); + assert_eq!(f.file_structures.len(), 1); + let fs = &f.file_structures[0]; + assert_eq!(fs.id, UniqueId([5, 6, 7, 8])); + assert_eq!(fs.pictures, vec![b"\xff\xd8\xff".to_vec()]); + for (kind, want) in SectionKind::ALL.into_iter().zip(SECTIONS) { + assert_eq!(fs.section(kind), want, "{}", kind.name()); + } + assert_eq!(f.model_file, b"model file"); + } +} + +#[test] +fn damaged_containers_are_refused_by_name() { + let good = build(SECTIONS, b"m", 0); + assert_eq!(PrcFile::parse(b"U3D\0").unwrap_err(), PrcError::NotPrc); + assert!(matches!(PrcFile::parse(&good[..40]), Err(PrcError::Truncated(_)))); + + // Reserved field (offset 63) not zero. + let mut bad = good.clone(); + bad[63] = 1; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); + + // File-structure id disagrees with its description (FS header id at 107+11). + let mut bad = good.clone(); + bad[118] ^= 0xff; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); + + // A section that is not zlib: corrupt the globals stream's header byte. + let globals = u32::from_le_bytes(good[75..79].try_into().unwrap()) as usize; + let mut bad = good.clone(); + bad[globals] = 0; + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Inflate { section: "globals", .. }))); + + // Offsets out of order. + let mut bad = good; + bad[75..79].copy_from_slice(&1u32.to_le_bytes()); + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Malformed(_)))); +} + +#[test] +fn the_inflation_ceiling_is_reported_not_exceeded() { + let f = build([&[0u8; 600], &[0u8; 600], b"", b"", b""], b"", 0); + assert!(PrcFile::parse_with_limit(&f, 1200).is_ok()); + assert_eq!(PrcFile::parse_with_limit(&f, 1199).unwrap_err(), PrcError::TooLarge { limit: 1199 }); +} + +#[test] +fn an_offset_or_count_past_the_data_is_an_error_not_a_panic() { + let mut bad = build(SECTIONS, b"m", 0); + // The model file's start (header offset 95) far past the end, its end further still. + bad[95..99].copy_from_slice(&u32::MAX.to_le_bytes()); + bad[99..103].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(PrcFile::parse(&bad).is_err()); + // A section count that claims more offsets than the data holds. + let mut bad = build(SECTIONS, b"m", 0); + bad[67..71].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(PrcFile::parse(&bad), Err(PrcError::Truncated(_)))); +} diff --git a/crates/prc/tests/proptests.rs b/crates/prc/tests/proptests.rs new file mode 100644 index 0000000000..e33e095bb2 --- /dev/null +++ b/crates/prc/tests/proptests.rs @@ -0,0 +1,41 @@ +//! Arbitrary bytes through every reader: an error or a value, never a panic. + +use pdfcraft_prc::{BitReader, PrcFile, Schema}; +use proptest::prelude::*; + +proptest! { + #[test] + fn any_bytes_parse_or_fail_cleanly(data in proptest::collection::vec(any::(), 0..512), magic in any::()) { + let mut data = data; + if magic && data.len() >= 3 { + data[..3].copy_from_slice(b"PRC"); + } + let _ = PrcFile::parse_with_limit(&data, 1 << 20); + } + + #[test] + fn any_bits_read_or_fail_cleanly(data in proptest::collection::vec(any::(), 0..64), n in 0u32..40) { + let mut r = BitReader::new(&data); + let _ = r.unsigned_integer(); + let _ = r.integer(); + let _ = r.integer_vbn(n); + let _ = r.double_vbn(0.5, n); + let _ = r.nbits_then_unsigned(); + let _ = r.string(); + let _ = r.float_as_bytes(); + let _ = r.double(); + let _ = r.skip_bits(usize::try_from(n).unwrap_or(0)); + prop_assert!(r.position() <= data.len() * 8); + } + + #[test] + fn any_schema_runs_or_fails_cleanly(data in proptest::collection::vec(any::(), 0..256), entity in 0u32..4) { + let mut r = BitReader::new(&data); + if let Ok(schema) = Schema::read(&mut r) { + let types: Vec = schema.entity_types().chain([entity]).collect(); + for t in types { + let _ = schema.skip_added_fields(t, &mut r.clone()); + } + } + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index b961ebef66..4f59c1dc94 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -34,6 +34,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("filters", Class::Standalone(0)), ("crypt", Class::Standalone(0)), ("arlington", Class::Standalone(0)), + ("prc", Class::Standalone(0)), // L1 object layer ("cos", Class::Standalone(1)), // L2 core model From ab552430a7abce749b1ba6d0a959f5ff907fb2f1 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:22:49 -0400 Subject: [PATCH 149/562] M12: PRC reader: the Double code table from prc-rs (MIT) (#1297) Every PRC Double decodes through a fixed 2077-row prefix-code table (working draft 11.17.2). The rows come from prc-rs src/double.rs at 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d under its MIT licence, whose notice stays in the file header; ATTRIBUTION.toml records it. The table is a complete prefix code, so the empty-table stub test is replaced by a truncation test. The exponent lookup masks the 11 exponent bits, because the last exponent row's upper word is 0x7ff80000. Revert proof: with the table emptied, eight Double tests fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 + crates/prc/README.md | 3 +- crates/prc/src/acof.rs | 15 +- crates/prc/src/acof/table.rs | 2113 ++++++++++++++++++++++++++++++++++ crates/prc/src/bits.rs | 13 +- crates/prc/src/schema.rs | 2 - 7 files changed, 2137 insertions(+), 23 deletions(-) create mode 100644 crates/prc/src/acof/table.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fc88e8b664..dea7c69d36 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (289) +## In this repository (290) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -231,6 +231,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | | `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | +| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. | | `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 76f4cf40a8..16c2d266dc 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2437,6 +2437,17 @@ adobe_data = true usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree." sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" +[[asset]] +path = "crates/prc/src/acof/table.rs" +title = "PRC Double prefix-code table (as a Rust table)" +author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)" +source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header." +licence = "MIT" +licence_file = "crates/prc/src/acof/table.rs" +kind = "data" +usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data." +sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c" + [[bundled]] crate = "epaint_default_fonts" version = "0.37.0" diff --git a/crates/prc/README.md b/crates/prc/README.md index 0f8a01872f..edb67bdad8 100644 --- a/crates/prc/README.md +++ b/crates/prc/README.md @@ -20,4 +20,5 @@ BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_ - `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. - Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). - `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). -- Not yet in the crate: the `Double` code table, so `BitReader::double` returns `UnknownDoubleCode`; the product tree and tessellation readers. +- Not yet in the crate: the product tree and tessellation readers. +- The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. diff --git a/crates/prc/src/acof.rs b/crates/prc/src/acof.rs index dff5d4a6f2..fc11a663a6 100644 --- a/crates/prc/src/acof.rs +++ b/crates/prc/src/acof.rs @@ -2,13 +2,13 @@ //! //! The table has 2077 rows: 29 frequent doubles and one row per IEEE binary64 exponent //! (0..=2047). Each row is a code of 1 to 22 bits and the value it decodes to. -//! -//! The rows are not in this crate yet: until they are, [`lookup`] finds no code, so every -//! `Double` read fails with `UnknownDoubleCode` and nothing else changes. +//! The rows come from prc-rs under its MIT licence (see [`table`]). use std::collections::HashMap; use std::sync::OnceLock; +mod table; + /// Rows a complete table has. const ROWS: usize = 2077; @@ -17,7 +17,6 @@ pub(crate) const MAX_CODE_BITS: u32 = 22; /// What a code decodes to. #[derive(Debug, Clone, Copy, PartialEq)] -#[cfg_attr(not(test), expect(dead_code, reason = "built only by the table's rows, which are not in the crate yet"))] pub(crate) enum Entry { /// A frequent value, returned with the sign bit applied. Double(f64), @@ -29,9 +28,6 @@ pub(crate) enum Entry { /// One row: code length, code, and what it decodes to. type Row = (u32, u32, Entry); -/// The table's rows. -const TABLE: &[Row] = &[]; - fn key(nbits: u32, code: u32) -> u32 { (nbits << 24) | code } @@ -54,7 +50,7 @@ fn build(rows: &[Row]) -> Option> { fn table() -> Option<&'static HashMap> { static MAP: OnceLock>> = OnceLock::new(); - MAP.get_or_init(|| build(TABLE)).as_ref() + MAP.get_or_init(|| build(table::TABLE)).as_ref() } /// The entry for an `nbits`-long code, if one exists. @@ -65,7 +61,7 @@ pub(crate) fn lookup(nbits: u32, code: u32) -> Option { /// The `(nbits, code)` of IEEE exponent `e`'s row, the test writer's half of [`lookup`]. #[cfg(test)] pub(crate) fn exponent_code(e: u32) -> Option<(u32, u32)> { - table()?.iter().find(|(_, v)| **v == Entry::Exponent(e << 20)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) + table()?.iter().find(|(_, v)| matches!(v, Entry::Exponent(hi) if (hi >> 20) & 0x7ff == e)).map(|(&k, _)| (k >> 24, k & 0xff_ffff)) } #[cfg(test)] @@ -87,7 +83,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn the_table_is_complete() { let t = table().unwrap(); assert_eq!(t.len(), ROWS); diff --git a/crates/prc/src/acof/table.rs b/crates/prc/src/acof/table.rs new file mode 100644 index 0000000000..28cff50a2e --- /dev/null +++ b/crates/prc/src/acof/table.rs @@ -0,0 +1,2113 @@ +//! The 2077 rows of the PRC `Double` prefix-code table [WD 11.17.2], as `(code length, code, entry)`. +//! +//! The rows are taken from prc-rs (, `src/double.rs` at commit +//! 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d), used under its MIT licence: +//! +//! Copyright (c) 2025,2026 Kristof Ralovich +//! +//! Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +//! documentation files (the "Software"), to deal in the Software without restriction, including without limitation +//! the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +//! to permit persons to whom the Software is furnished to do so, subject to the following conditions: +//! +//! The above copyright notice and this permission notice shall be included in all copies or substantial portions of +//! the Software. +//! +//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +//! THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +//! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +//! TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +//! SOFTWARE. + +use super::{Entry, Row}; + +/// A frequent value: the binary64 with upper and lower words `hi`, `lo`. +const fn d(hi: u32, lo: u32) -> Entry { + Entry::Double(f64::from_bits(((hi as u64) << 32) | lo as u64)) +} + +/// An exponent row: the upper word of a binary64 whose mantissa follows in the stream. +const fn e(hi: u32) -> Entry { + Entry::Exponent(hi) +} + +#[rustfmt::skip] +pub(super) const TABLE: &[Row] = &[ + (2, 0x1, d(0x00000000, 0x00000000)), + (22, 0xd1d32, e(0x00000000)), + (22, 0xd1d33, e(0x00100000)), + (22, 0xf78d8, e(0x00200000)), + (22, 0xf78d9, e(0x00300000)), + (22, 0xf78da, e(0x00400000)), + (22, 0xf78db, e(0x00500000)), + (22, 0xf78dc, e(0x00600000)), + (22, 0xf78dd, e(0x00700000)), + (22, 0xf78de, e(0x00800000)), + (22, 0xf78df, e(0x00900000)), + (22, 0xf78e0, e(0x00a00000)), + (22, 0xf78e1, e(0x00b00000)), + (22, 0xf78e2, e(0x00c00000)), + (22, 0xf78e3, e(0x00d00000)), + (22, 0xf78e4, e(0x00e00000)), + (22, 0xf78e5, e(0x00f00000)), + (22, 0xf78e6, e(0x01000000)), + (22, 0xf78e7, e(0x01100000)), + (22, 0xf78e8, e(0x01200000)), + (22, 0xf78e9, e(0x01300000)), + (22, 0xf78ea, e(0x01400000)), + (22, 0xf78eb, e(0x01500000)), + (22, 0xf78ec, e(0x01600000)), + (22, 0xf78ed, e(0x01700000)), + (22, 0xf78ee, e(0x01800000)), + (22, 0xf78ef, e(0x01900000)), + (22, 0xf78f0, e(0x01a00000)), + (22, 0xf78f1, e(0x01b00000)), + (22, 0xf78f2, e(0x01c00000)), + (22, 0xf78f3, e(0x01d00000)), + (22, 0xf78f4, e(0x01e00000)), + (22, 0xf78f5, e(0x01f00000)), + (22, 0xf78f6, e(0x02000000)), + (22, 0xf78f7, e(0x02100000)), + (22, 0xf78f8, e(0x02200000)), + (22, 0xf78f9, e(0x02300000)), + (22, 0xf78fa, e(0x02400000)), + (22, 0xf78fb, e(0x02500000)), + (22, 0xf78fc, e(0x02600000)), + (22, 0xf78fd, e(0x02700000)), + (22, 0xf78fe, e(0x02800000)), + (22, 0xf78ff, e(0x02900000)), + (22, 0x3a8300, e(0x02a00000)), + (22, 0x3a8301, e(0x02b00000)), + (22, 0x3a8302, e(0x02c00000)), + (22, 0x3a8303, e(0x02d00000)), + (22, 0x3a8304, e(0x02e00000)), + (22, 0x3a8305, e(0x02f00000)), + (22, 0x3a8306, e(0x03000000)), + (22, 0x3a8307, e(0x03100000)), + (22, 0x3a8308, e(0x03200000)), + (22, 0x3a8309, e(0x03300000)), + (22, 0x3a830a, e(0x03400000)), + (22, 0x3a830b, e(0x03500000)), + (22, 0x3a830c, e(0x03600000)), + (22, 0x3a830d, e(0x03700000)), + (22, 0x3a830e, e(0x03800000)), + (22, 0x3a830f, e(0x03900000)), + (22, 0x3a8310, e(0x03a00000)), + (22, 0x3a8311, e(0x03b00000)), + (22, 0x3a8312, e(0x03c00000)), + (22, 0x3a8313, e(0x03d00000)), + (22, 0x3a8314, e(0x03e00000)), + (22, 0x3a8315, e(0x03f00000)), + (22, 0x3a8316, e(0x04000000)), + (22, 0x3a8317, e(0x04100000)), + (22, 0x3a8318, e(0x04200000)), + (22, 0x3a8319, e(0x04300000)), + (22, 0x3a831a, e(0x04400000)), + (22, 0x3a831b, e(0x04500000)), + (22, 0x3a831c, e(0x04600000)), + (22, 0x3a831d, e(0x04700000)), + (22, 0x3a831e, e(0x04800000)), + (22, 0x3a831f, e(0x04900000)), + (22, 0x3a8320, e(0x04a00000)), + (22, 0x3a8321, e(0x04b00000)), + (22, 0x3a8322, e(0x04c00000)), + (22, 0x3a8323, e(0x04d00000)), + (22, 0x3a8324, e(0x04e00000)), + (22, 0x3a8325, e(0x04f00000)), + (22, 0x3a8326, e(0x05000000)), + (22, 0x3a8327, e(0x05100000)), + (22, 0x3a8328, e(0x05200000)), + (22, 0x3a8329, e(0x05300000)), + (22, 0x3a832a, e(0x05400000)), + (22, 0x3a832b, e(0x05500000)), + (22, 0x3a832c, e(0x05600000)), + (22, 0x3a832d, e(0x05700000)), + (22, 0x3a832e, e(0x05800000)), + (22, 0x3a832f, e(0x05900000)), + (22, 0x3a8330, e(0x05a00000)), + (22, 0x3a8331, e(0x05b00000)), + (22, 0x3a8332, e(0x05c00000)), + (22, 0x3a8333, e(0x05d00000)), + (22, 0x3a8334, e(0x05e00000)), + (22, 0x3a8335, e(0x05f00000)), + (22, 0x3a8336, e(0x06000000)), + (22, 0x3a8337, e(0x06100000)), + (22, 0x3a8338, e(0x06200000)), + (22, 0x3a8339, e(0x06300000)), + (22, 0x3a833a, e(0x06400000)), + (22, 0x3a833b, e(0x06500000)), + (22, 0x3a833c, e(0x06600000)), + (22, 0x3a833d, e(0x06700000)), + (22, 0x3a833e, e(0x06800000)), + (22, 0x3a833f, e(0x06900000)), + (22, 0x3a8340, e(0x06a00000)), + (22, 0x3a8341, e(0x06b00000)), + (22, 0x3a8342, e(0x06c00000)), + (22, 0x3a8343, e(0x06d00000)), + (22, 0x3a8344, e(0x06e00000)), + (22, 0x3a8345, e(0x06f00000)), + (22, 0x3a8346, e(0x07000000)), + (22, 0x3a8347, e(0x07100000)), + (22, 0x3a8348, e(0x07200000)), + (22, 0x3a8349, e(0x07300000)), + (22, 0x3a834a, e(0x07400000)), + (22, 0x3a834b, e(0x07500000)), + (22, 0x3a834c, e(0x07600000)), + (22, 0x3a834d, e(0x07700000)), + (22, 0x3a834e, e(0x07800000)), + (22, 0x3a834f, e(0x07900000)), + (22, 0x3a8350, e(0x07a00000)), + (22, 0x3a8351, e(0x07b00000)), + (22, 0x3a8352, e(0x07c00000)), + (22, 0x3a8353, e(0x07d00000)), + (22, 0x3a8354, e(0x07e00000)), + (22, 0x3a8355, e(0x07f00000)), + (22, 0x3a8356, e(0x08000000)), + (22, 0x3a8357, e(0x08100000)), + (22, 0x3a8358, e(0x08200000)), + (22, 0x3a8359, e(0x08300000)), + (22, 0x3a835a, e(0x08400000)), + (22, 0x3a835b, e(0x08500000)), + (22, 0x3a835c, e(0x08600000)), + (22, 0x3a835d, e(0x08700000)), + (22, 0x3a835e, e(0x08800000)), + (22, 0x3a835f, e(0x08900000)), + (22, 0x3a8360, e(0x08a00000)), + (22, 0x3a8361, e(0x08b00000)), + (22, 0x3a8362, e(0x08c00000)), + (22, 0x3a8363, e(0x08d00000)), + (22, 0x3a8364, e(0x08e00000)), + (22, 0x3a8365, e(0x08f00000)), + (22, 0x3a8366, e(0x09000000)), + (22, 0x3a8367, e(0x09100000)), + (22, 0x3a8368, e(0x09200000)), + (22, 0x3a8369, e(0x09300000)), + (22, 0x3a836a, e(0x09400000)), + (22, 0x3a836b, e(0x09500000)), + (22, 0x3a836c, e(0x09600000)), + (22, 0x3a836d, e(0x09700000)), + (22, 0x3a836e, e(0x09800000)), + (22, 0x3a836f, e(0x09900000)), + (22, 0x3a8370, e(0x09a00000)), + (22, 0x3a8371, e(0x09b00000)), + (22, 0x3a8372, e(0x09c00000)), + (22, 0x3a8373, e(0x09d00000)), + (22, 0x3a8374, e(0x09e00000)), + (22, 0x3a8375, e(0x09f00000)), + (22, 0x3a8376, e(0x0a000000)), + (22, 0x3a8377, e(0x0a100000)), + (22, 0x3a8378, e(0x0a200000)), + (22, 0x3a8379, e(0x0a300000)), + (22, 0x3a837a, e(0x0a400000)), + (22, 0x3a837b, e(0x0a500000)), + (22, 0x3a837c, e(0x0a600000)), + (22, 0x3a837d, e(0x0a700000)), + (22, 0x3a837e, e(0x0a800000)), + (22, 0x3a837f, e(0x0a900000)), + (22, 0x3a8380, e(0x0aa00000)), + (22, 0x3a8381, e(0x0ab00000)), + (22, 0x3a8382, e(0x0ac00000)), + (22, 0x3a8383, e(0x0ad00000)), + (22, 0x3a8384, e(0x0ae00000)), + (22, 0x3a8385, e(0x0af00000)), + (22, 0x3a8386, e(0x0b000000)), + (22, 0x3a8387, e(0x0b100000)), + (22, 0x3a8388, e(0x0b200000)), + (22, 0x3a8389, e(0x0b300000)), + (22, 0x3a838a, e(0x0b400000)), + (22, 0x3a838b, e(0x0b500000)), + (22, 0x3a838c, e(0x0b600000)), + (22, 0x3a838d, e(0x0b700000)), + (22, 0x3a838e, e(0x0b800000)), + (22, 0x3a838f, e(0x0b900000)), + (22, 0x3a8390, e(0x0ba00000)), + (22, 0x3a8391, e(0x0bb00000)), + (22, 0x3a8392, e(0x0bc00000)), + (22, 0x3a8393, e(0x0bd00000)), + (22, 0x3a8394, e(0x0be00000)), + (22, 0x3a8395, e(0x0bf00000)), + (22, 0x3a8396, e(0x0c000000)), + (22, 0x3a8397, e(0x0c100000)), + (22, 0x3a8398, e(0x0c200000)), + (22, 0x3a8399, e(0x0c300000)), + (22, 0x3a839a, e(0x0c400000)), + (22, 0x3a839b, e(0x0c500000)), + (22, 0x3a839c, e(0x0c600000)), + (22, 0x3a839d, e(0x0c700000)), + (22, 0x3a839e, e(0x0c800000)), + (22, 0x3a839f, e(0x0c900000)), + (22, 0x3a83a0, e(0x0ca00000)), + (22, 0x3a83a1, e(0x0cb00000)), + (22, 0x3a83a2, e(0x0cc00000)), + (22, 0x3a83a3, e(0x0cd00000)), + (22, 0x3a83a4, e(0x0ce00000)), + (22, 0x3a83a5, e(0x0cf00000)), + (22, 0x3a83a6, e(0x0d000000)), + (22, 0x3a83a7, e(0x0d100000)), + (22, 0x3a83a8, e(0x0d200000)), + (22, 0x3a83a9, e(0x0d300000)), + (22, 0x3a83aa, e(0x0d400000)), + (22, 0x3a83ab, e(0x0d500000)), + (22, 0x3a83ac, e(0x0d600000)), + (22, 0x3a83ad, e(0x0d700000)), + (22, 0x3a83ae, e(0x0d800000)), + (22, 0x3a83af, e(0x0d900000)), + (22, 0x3a83b0, e(0x0da00000)), + (22, 0x3a83b1, e(0x0db00000)), + (22, 0x3a83b2, e(0x0dc00000)), + (22, 0x3a83b3, e(0x0dd00000)), + (22, 0x3a83b4, e(0x0de00000)), + (22, 0x3a83b5, e(0x0df00000)), + (22, 0x3a83b6, e(0x0e000000)), + (22, 0x3a83b7, e(0x0e100000)), + (22, 0x3a83b8, e(0x0e200000)), + (22, 0x3a83b9, e(0x0e300000)), + (22, 0x3a83ba, e(0x0e400000)), + (22, 0x3a83bb, e(0x0e500000)), + (22, 0x3a83bc, e(0x0e600000)), + (22, 0x3a83bd, e(0x0e700000)), + (22, 0x3a83be, e(0x0e800000)), + (22, 0x3a83bf, e(0x0e900000)), + (22, 0x3a83c0, e(0x0ea00000)), + (22, 0x3a83c1, e(0x0eb00000)), + (22, 0x3a83c2, e(0x0ec00000)), + (22, 0x3a83c3, e(0x0ed00000)), + (22, 0x3a83c4, e(0x0ee00000)), + (22, 0x3a83c5, e(0x0ef00000)), + (22, 0x3a83c6, e(0x0f000000)), + (22, 0x3a83c7, e(0x0f100000)), + (22, 0x3a83c8, e(0x0f200000)), + (22, 0x3a83c9, e(0x0f300000)), + (22, 0x3a83ca, e(0x0f400000)), + (22, 0x3a83cb, e(0x0f500000)), + (22, 0x3a83cc, e(0x0f600000)), + (22, 0x3a83cd, e(0x0f700000)), + (22, 0x3a83ce, e(0x0f800000)), + (22, 0x3a83cf, e(0x0f900000)), + (22, 0x3a83d0, e(0x0fa00000)), + (22, 0x3a83d1, e(0x0fb00000)), + (22, 0x3a83d2, e(0x0fc00000)), + (22, 0x3a83d3, e(0x0fd00000)), + (22, 0x3a83d4, e(0x0fe00000)), + (22, 0x3a83d5, e(0x0ff00000)), + (22, 0x3a83d6, e(0x10000000)), + (22, 0x3a83d7, e(0x10100000)), + (22, 0x3a83d8, e(0x10200000)), + (22, 0x3a83d9, e(0x10300000)), + (22, 0x3a83da, e(0x10400000)), + (22, 0x3a83db, e(0x10500000)), + (22, 0x3a83dc, e(0x10600000)), + (22, 0x3a83dd, e(0x10700000)), + (22, 0x3a83de, e(0x10800000)), + (22, 0x3a83df, e(0x10900000)), + (22, 0x3a83e0, e(0x10a00000)), + (22, 0x3a83e1, e(0x10b00000)), + (22, 0x3a83e2, e(0x10c00000)), + (22, 0x3a83e3, e(0x10d00000)), + (22, 0x3a83e4, e(0x10e00000)), + (22, 0x3a83e5, e(0x10f00000)), + (22, 0x3a83e6, e(0x11000000)), + (22, 0x3a83e7, e(0x11100000)), + (22, 0x3a83e8, e(0x11200000)), + (22, 0x3a83e9, e(0x11300000)), + (22, 0x3a83ea, e(0x11400000)), + (22, 0x3a83eb, e(0x11500000)), + (22, 0x3a83ec, e(0x11600000)), + (22, 0x3a83ed, e(0x11700000)), + (22, 0x3a83ee, e(0x11800000)), + (22, 0x3a83ef, e(0x11900000)), + (22, 0x3a83f0, e(0x11a00000)), + (22, 0x3a83f1, e(0x11b00000)), + (22, 0x3a83f2, e(0x11c00000)), + (22, 0x3a83f3, e(0x11d00000)), + (22, 0x3a83f4, e(0x11e00000)), + (22, 0x3a83f5, e(0x11f00000)), + (22, 0x3a83f6, e(0x12000000)), + (22, 0x3a83f7, e(0x12100000)), + (22, 0x3a83f8, e(0x12200000)), + (22, 0x3a83f9, e(0x12300000)), + (22, 0x3a83fa, e(0x12400000)), + (22, 0x3a83fb, e(0x12500000)), + (22, 0x3a83fc, e(0x12600000)), + (22, 0x3a83fd, e(0x12700000)), + (22, 0x3a83fe, e(0x12800000)), + (22, 0x3a83ff, e(0x12900000)), + (22, 0x3a8400, e(0x12a00000)), + (22, 0x3a8401, e(0x12b00000)), + (22, 0x3a8402, e(0x12c00000)), + (22, 0x3a8403, e(0x12d00000)), + (22, 0x3a8404, e(0x12e00000)), + (22, 0x3a8405, e(0x12f00000)), + (22, 0x3a8406, e(0x13000000)), + (22, 0x3a8407, e(0x13100000)), + (22, 0x3a8408, e(0x13200000)), + (22, 0x3a8409, e(0x13300000)), + (22, 0x3a840a, e(0x13400000)), + (22, 0x3a840b, e(0x13500000)), + (22, 0x3a840c, e(0x13600000)), + (22, 0x3a840d, e(0x13700000)), + (22, 0x3a840e, e(0x13800000)), + (22, 0x3a840f, e(0x13900000)), + (22, 0x3a8410, e(0x13a00000)), + (22, 0x3a8411, e(0x13b00000)), + (22, 0x3a8412, e(0x13c00000)), + (22, 0x3a8413, e(0x13d00000)), + (22, 0x3a8414, e(0x13e00000)), + (22, 0x3a8415, e(0x13f00000)), + (22, 0x3a8416, e(0x14000000)), + (22, 0x3a8417, e(0x14100000)), + (22, 0x3a8418, e(0x14200000)), + (22, 0x3a8419, e(0x14300000)), + (22, 0x3a841a, e(0x14400000)), + (22, 0x3a841b, e(0x14500000)), + (22, 0x3a841c, e(0x14600000)), + (22, 0x3a841d, e(0x14700000)), + (22, 0x3a841e, e(0x14800000)), + (22, 0x3a841f, e(0x14900000)), + (22, 0x3a8420, e(0x14a00000)), + (22, 0x3a8421, e(0x14b00000)), + (22, 0x3a8422, e(0x14c00000)), + (22, 0x3a8423, e(0x14d00000)), + (22, 0x3a8424, e(0x14e00000)), + (22, 0x3a8425, e(0x14f00000)), + (22, 0x3a8426, e(0x15000000)), + (22, 0x3a8427, e(0x15100000)), + (22, 0x3a8428, e(0x15200000)), + (22, 0x3a8429, e(0x15300000)), + (22, 0x3a842a, e(0x15400000)), + (22, 0x3a842b, e(0x15500000)), + (22, 0x3a842c, e(0x15600000)), + (22, 0x3a842d, e(0x15700000)), + (22, 0x3a842e, e(0x15800000)), + (22, 0x3a842f, e(0x15900000)), + (22, 0x3a8430, e(0x15a00000)), + (22, 0x3a8431, e(0x15b00000)), + (22, 0x3a8432, e(0x15c00000)), + (22, 0x3a8433, e(0x15d00000)), + (22, 0x3a8434, e(0x15e00000)), + (22, 0x3a8435, e(0x15f00000)), + (22, 0x3a8436, e(0x16000000)), + (22, 0x3a8437, e(0x16100000)), + (22, 0x3a8438, e(0x16200000)), + (22, 0x3a8439, e(0x16300000)), + (22, 0x3a843a, e(0x16400000)), + (22, 0x3a843b, e(0x16500000)), + (22, 0x3a843c, e(0x16600000)), + (22, 0x3a843d, e(0x16700000)), + (22, 0x3a843e, e(0x16800000)), + (22, 0x3a843f, e(0x16900000)), + (22, 0x3a8440, e(0x16a00000)), + (22, 0x3a8441, e(0x16b00000)), + (22, 0x3a8442, e(0x16c00000)), + (22, 0x3a8443, e(0x16d00000)), + (22, 0x3a8444, e(0x16e00000)), + (22, 0x3a8445, e(0x16f00000)), + (22, 0x3a8446, e(0x17000000)), + (22, 0x3a8447, e(0x17100000)), + (22, 0x3a8448, e(0x17200000)), + (22, 0x3a8449, e(0x17300000)), + (22, 0x3a844a, e(0x17400000)), + (22, 0x3a844b, e(0x17500000)), + (22, 0x3a844c, e(0x17600000)), + (22, 0x3a844d, e(0x17700000)), + (22, 0x3a844e, e(0x17800000)), + (22, 0x3a844f, e(0x17900000)), + (22, 0x3a8450, e(0x17a00000)), + (22, 0x3a8451, e(0x17b00000)), + (22, 0x3a8452, e(0x17c00000)), + (22, 0x3a8453, e(0x17d00000)), + (22, 0x3a8454, e(0x17e00000)), + (22, 0x3a8455, e(0x17f00000)), + (22, 0x3a8456, e(0x18000000)), + (22, 0x3a8457, e(0x18100000)), + (22, 0x3a8458, e(0x18200000)), + (22, 0x3a8459, e(0x18300000)), + (22, 0x3a845a, e(0x18400000)), + (22, 0x3a845b, e(0x18500000)), + (22, 0x3a845c, e(0x18600000)), + (22, 0x3a845d, e(0x18700000)), + (22, 0x3a845e, e(0x18800000)), + (22, 0x3a845f, e(0x18900000)), + (22, 0x3a8460, e(0x18a00000)), + (22, 0x3a8461, e(0x18b00000)), + (22, 0x3a8462, e(0x18c00000)), + (22, 0x3a8463, e(0x18d00000)), + (22, 0x3a8464, e(0x18e00000)), + (22, 0x3a8465, e(0x18f00000)), + (22, 0x3a8466, e(0x19000000)), + (22, 0x3a8467, e(0x19100000)), + (22, 0x3a8468, e(0x19200000)), + (22, 0x3a8469, e(0x19300000)), + (22, 0x3a846a, e(0x19400000)), + (22, 0x3a846b, e(0x19500000)), + (22, 0x3a846c, e(0x19600000)), + (22, 0x3a846d, e(0x19700000)), + (22, 0x3a846e, e(0x19800000)), + (22, 0x3a846f, e(0x19900000)), + (22, 0x3a8470, e(0x19a00000)), + (22, 0x3a8471, e(0x19b00000)), + (22, 0x3a8472, e(0x19c00000)), + (22, 0x3a8473, e(0x19d00000)), + (22, 0x3a8474, e(0x19e00000)), + (22, 0x3a8475, e(0x19f00000)), + (22, 0x3a8476, e(0x1a000000)), + (22, 0x3a8477, e(0x1a100000)), + (22, 0x3a8478, e(0x1a200000)), + (22, 0x3a8479, e(0x1a300000)), + (22, 0x3a847a, e(0x1a400000)), + (22, 0x3a847b, e(0x1a500000)), + (22, 0x3a847c, e(0x1a600000)), + (22, 0x3a847d, e(0x1a700000)), + (22, 0x3a847e, e(0x1a800000)), + (22, 0x3a847f, e(0x1a900000)), + (22, 0x3a8480, e(0x1aa00000)), + (22, 0x3a8481, e(0x1ab00000)), + (22, 0x3a8482, e(0x1ac00000)), + (22, 0x3a8483, e(0x1ad00000)), + (22, 0x3a8484, e(0x1ae00000)), + (22, 0x3a8485, e(0x1af00000)), + (22, 0x3a8486, e(0x1b000000)), + (22, 0x3a8487, e(0x1b100000)), + (22, 0x3a8488, e(0x1b200000)), + (22, 0x3a8489, e(0x1b300000)), + (22, 0x3a848a, e(0x1b400000)), + (22, 0x3a848b, e(0x1b500000)), + (22, 0x3a848c, e(0x1b600000)), + (22, 0x3a848d, e(0x1b700000)), + (22, 0x3a848e, e(0x1b800000)), + (22, 0x3a848f, e(0x1b900000)), + (22, 0x3a8490, e(0x1ba00000)), + (22, 0x3a8491, e(0x1bb00000)), + (22, 0x3a8492, e(0x1bc00000)), + (22, 0x3a8493, e(0x1bd00000)), + (22, 0x3a8494, e(0x1be00000)), + (22, 0x3a8495, e(0x1bf00000)), + (22, 0x3a8496, e(0x1c000000)), + (22, 0x3a8497, e(0x1c100000)), + (22, 0x3a8498, e(0x1c200000)), + (22, 0x3a8499, e(0x1c300000)), + (22, 0x3a849a, e(0x1c400000)), + (22, 0x3a849b, e(0x1c500000)), + (22, 0x3a849c, e(0x1c600000)), + (22, 0x3a849d, e(0x1c700000)), + (22, 0x3a849e, e(0x1c800000)), + (22, 0x3a849f, e(0x1c900000)), + (22, 0x3a84a0, e(0x1ca00000)), + (22, 0x3a84a1, e(0x1cb00000)), + (22, 0x3a84a2, e(0x1cc00000)), + (22, 0x3a84a3, e(0x1cd00000)), + (22, 0x3a84a4, e(0x1ce00000)), + (22, 0x3a84a5, e(0x1cf00000)), + (22, 0x3a84a6, e(0x1d000000)), + (22, 0x3a84a7, e(0x1d100000)), + (22, 0x3a84a8, e(0x1d200000)), + (22, 0x3a84a9, e(0x1d300000)), + (22, 0x3a84aa, e(0x1d400000)), + (22, 0x3a84ab, e(0x1d500000)), + (22, 0x3a84ac, e(0x1d600000)), + (22, 0x3a84ad, e(0x1d700000)), + (22, 0x3a84ae, e(0x1d800000)), + (22, 0x3a84af, e(0x1d900000)), + (22, 0x3a84b0, e(0x1da00000)), + (22, 0x3a84b1, e(0x1db00000)), + (22, 0x3a84b2, e(0x1dc00000)), + (22, 0x3a84b3, e(0x1dd00000)), + (22, 0x3a84b4, e(0x1de00000)), + (22, 0x3a84b5, e(0x1df00000)), + (22, 0x3a84b6, e(0x1e000000)), + (22, 0x3a84b7, e(0x1e100000)), + (22, 0x3a84b8, e(0x1e200000)), + (22, 0x3a84b9, e(0x1e300000)), + (22, 0x3a84ba, e(0x1e400000)), + (22, 0x3a84bb, e(0x1e500000)), + (22, 0x3a84bc, e(0x1e600000)), + (22, 0x3a84bd, e(0x1e700000)), + (22, 0x3a84be, e(0x1e800000)), + (22, 0x3a84bf, e(0x1e900000)), + (22, 0x3a84c0, e(0x1ea00000)), + (22, 0x3a84c1, e(0x1eb00000)), + (22, 0x3a84c2, e(0x1ec00000)), + (22, 0x3a84c3, e(0x1ed00000)), + (22, 0x3a84c4, e(0x1ee00000)), + (22, 0x3a84c5, e(0x1ef00000)), + (22, 0x3a84c6, e(0x1f000000)), + (22, 0x3a84c7, e(0x1f100000)), + (22, 0x3a84c8, e(0x1f200000)), + (22, 0x3a84c9, e(0x1f300000)), + (22, 0x3a84ca, e(0x1f400000)), + (22, 0x3a84cb, e(0x1f500000)), + (22, 0x3a84cc, e(0x1f600000)), + (22, 0x3a84cd, e(0x1f700000)), + (22, 0x3a84ce, e(0x1f800000)), + (22, 0x3a84cf, e(0x1f900000)), + (22, 0x3a84d0, e(0x1fa00000)), + (22, 0x3a84d1, e(0x1fb00000)), + (22, 0x3a84d2, e(0x1fc00000)), + (22, 0x3a84d3, e(0x1fd00000)), + (22, 0x3a84d4, e(0x1fe00000)), + (22, 0x3a84d5, e(0x1ff00000)), + (22, 0x3a84d6, e(0x20000000)), + (22, 0x3a84d7, e(0x20100000)), + (22, 0x3a84d8, e(0x20200000)), + (22, 0x3a84d9, e(0x20300000)), + (22, 0x3a84da, e(0x20400000)), + (22, 0x3a84db, e(0x20500000)), + (22, 0x3a84dc, e(0x20600000)), + (22, 0x3a84dd, e(0x20700000)), + (22, 0x3a84de, e(0x20800000)), + (22, 0x3a84df, e(0x20900000)), + (22, 0x3a84e0, e(0x20a00000)), + (22, 0x3a84e1, e(0x20b00000)), + (22, 0x3a84e2, e(0x20c00000)), + (22, 0x3a84e3, e(0x20d00000)), + (22, 0x3a84e4, e(0x20e00000)), + (22, 0x3a84e5, e(0x20f00000)), + (22, 0x3a84e6, e(0x21000000)), + (22, 0x3a84e7, e(0x21100000)), + (22, 0x3a84e8, e(0x21200000)), + (22, 0x3a84e9, e(0x21300000)), + (22, 0x3a84ea, e(0x21400000)), + (22, 0x3a84eb, e(0x21500000)), + (22, 0x3a84ec, e(0x21600000)), + (22, 0x3a84ed, e(0x21700000)), + (22, 0x3a84ee, e(0x21800000)), + (22, 0x3a84ef, e(0x21900000)), + (22, 0x3a84f0, e(0x21a00000)), + (22, 0x3a84f1, e(0x21b00000)), + (22, 0x3a84f2, e(0x21c00000)), + (22, 0x3a84f3, e(0x21d00000)), + (22, 0x3a84f4, e(0x21e00000)), + (22, 0x3a84f5, e(0x21f00000)), + (22, 0x3a84f6, e(0x22000000)), + (22, 0x3a84f7, e(0x22100000)), + (22, 0x3a84f8, e(0x22200000)), + (22, 0x3a84f9, e(0x22300000)), + (22, 0x3a84fa, e(0x22400000)), + (22, 0x3a84fb, e(0x22500000)), + (22, 0x3a84fc, e(0x22600000)), + (22, 0x3a84fd, e(0x22700000)), + (22, 0x3a84fe, e(0x22800000)), + (22, 0x3a84ff, e(0x22900000)), + (22, 0x3a8500, e(0x22a00000)), + (22, 0x3a8501, e(0x22b00000)), + (22, 0x3a8502, e(0x22c00000)), + (22, 0x3a8503, e(0x22d00000)), + (22, 0x3a8504, e(0x22e00000)), + (22, 0x3a8505, e(0x22f00000)), + (22, 0x3a8506, e(0x23000000)), + (22, 0x3a8507, e(0x23100000)), + (22, 0x3a8508, e(0x23200000)), + (22, 0x3a8509, e(0x23300000)), + (22, 0x3a850a, e(0x23400000)), + (22, 0x3a850b, e(0x23500000)), + (22, 0x3a850c, e(0x23600000)), + (22, 0x3a850d, e(0x23700000)), + (22, 0x3a850e, e(0x23800000)), + (22, 0x3a850f, e(0x23900000)), + (22, 0x3a8510, e(0x23a00000)), + (22, 0x3a8511, e(0x23b00000)), + (22, 0x3a8512, e(0x23c00000)), + (22, 0x3a8513, e(0x23d00000)), + (22, 0x3a8514, e(0x23e00000)), + (22, 0x3a8515, e(0x23f00000)), + (22, 0x3a8516, e(0x24000000)), + (22, 0x3a8517, e(0x24100000)), + (22, 0x3a8518, e(0x24200000)), + (22, 0x3a8519, e(0x24300000)), + (22, 0x3a851a, e(0x24400000)), + (22, 0x3a851b, e(0x24500000)), + (22, 0x3a851c, e(0x24600000)), + (22, 0x3a851d, e(0x24700000)), + (22, 0x3a851e, e(0x24800000)), + (22, 0x3a851f, e(0x24900000)), + (22, 0x3a8520, e(0x24a00000)), + (22, 0x3a8521, e(0x24b00000)), + (22, 0x3a8522, e(0x24c00000)), + (22, 0x3a8523, e(0x24d00000)), + (22, 0x3a8524, e(0x24e00000)), + (22, 0x3a8525, e(0x24f00000)), + (22, 0x3a8526, e(0x25000000)), + (22, 0x3a8527, e(0x25100000)), + (22, 0x3a8528, e(0x25200000)), + (22, 0x3a8529, e(0x25300000)), + (22, 0x3a852a, e(0x25400000)), + (22, 0x3a852b, e(0x25500000)), + (22, 0x3a852c, e(0x25600000)), + (22, 0x3a852d, e(0x25700000)), + (22, 0x3a852e, e(0x25800000)), + (22, 0x3a852f, e(0x25900000)), + (22, 0x3a8530, e(0x25a00000)), + (22, 0x3a8531, e(0x25b00000)), + (22, 0x3a8532, e(0x25c00000)), + (22, 0x3a8533, e(0x25d00000)), + (22, 0x3a8534, e(0x25e00000)), + (22, 0x3a8535, e(0x25f00000)), + (22, 0x3a8536, e(0x26000000)), + (22, 0x3a8537, e(0x26100000)), + (22, 0x3a8538, e(0x26200000)), + (22, 0x3a8539, e(0x26300000)), + (22, 0x3a853a, e(0x26400000)), + (22, 0x3a853b, e(0x26500000)), + (22, 0x3a853c, e(0x26600000)), + (22, 0x3a853d, e(0x26700000)), + (22, 0x3a853e, e(0x26800000)), + (22, 0x3a853f, e(0x26900000)), + (22, 0x3a8540, e(0x26a00000)), + (22, 0x3a8541, e(0x26b00000)), + (22, 0x3a8542, e(0x26c00000)), + (22, 0x3a8543, e(0x26d00000)), + (22, 0x3a8544, e(0x26e00000)), + (22, 0x3a8545, e(0x26f00000)), + (22, 0x3a8546, e(0x27000000)), + (22, 0x3a8547, e(0x27100000)), + (22, 0x3a8548, e(0x27200000)), + (22, 0x3a8549, e(0x27300000)), + (22, 0x3a854a, e(0x27400000)), + (22, 0x3a854b, e(0x27500000)), + (22, 0x3a854c, e(0x27600000)), + (22, 0x3a854d, e(0x27700000)), + (22, 0x3a854e, e(0x27800000)), + (22, 0x3a854f, e(0x27900000)), + (22, 0x3a8550, e(0x27a00000)), + (22, 0x3a8551, e(0x27b00000)), + (22, 0x3a8552, e(0x27c00000)), + (22, 0x3a8553, e(0x27d00000)), + (22, 0x3a8554, e(0x27e00000)), + (22, 0x3a8555, e(0x27f00000)), + (22, 0x3a8556, e(0x28000000)), + (22, 0x3a8557, e(0x28100000)), + (22, 0x3a8558, e(0x28200000)), + (22, 0x3a8559, e(0x28300000)), + (22, 0x3a855a, e(0x28400000)), + (22, 0x3a855b, e(0x28500000)), + (22, 0x3a855c, e(0x28600000)), + (22, 0x3a855d, e(0x28700000)), + (22, 0x3a855e, e(0x28800000)), + (22, 0x3a855f, e(0x28900000)), + (22, 0x3a8560, e(0x28a00000)), + (22, 0x3a8561, e(0x28b00000)), + (22, 0x3a8562, e(0x28c00000)), + (22, 0x3a8563, e(0x28d00000)), + (22, 0x3a8564, e(0x28e00000)), + (22, 0x3a8565, e(0x28f00000)), + (22, 0x3a8566, e(0x29000000)), + (22, 0x3a8567, e(0x29100000)), + (22, 0x3a8568, e(0x29200000)), + (22, 0x3a8569, e(0x29300000)), + (22, 0x3a856a, e(0x29400000)), + (22, 0x3a856b, e(0x29500000)), + (22, 0x3a856c, e(0x29600000)), + (22, 0x3a856d, e(0x29700000)), + (22, 0x3a856e, e(0x29800000)), + (22, 0x3a856f, e(0x29900000)), + (22, 0x3a8570, e(0x29a00000)), + (22, 0x3a8571, e(0x29b00000)), + (22, 0x3a8572, e(0x29c00000)), + (22, 0x3a8573, e(0x29d00000)), + (22, 0x3a8574, e(0x29e00000)), + (22, 0x3a8575, e(0x29f00000)), + (22, 0x3a8576, e(0x2a000000)), + (22, 0x3a8577, e(0x2a100000)), + (22, 0x3a8578, e(0x2a200000)), + (22, 0x3a8579, e(0x2a300000)), + (22, 0x3a857a, e(0x2a400000)), + (22, 0x3a857b, e(0x2a500000)), + (22, 0x3a857c, e(0x2a600000)), + (22, 0x3a857d, e(0x2a700000)), + (22, 0x3a857e, e(0x2a800000)), + (22, 0x3a857f, e(0x2a900000)), + (22, 0x3a8580, e(0x2aa00000)), + (22, 0x3a8581, e(0x2ab00000)), + (22, 0x3a8582, e(0x2ac00000)), + (22, 0x3a8583, e(0x2ad00000)), + (22, 0x3a8584, e(0x2ae00000)), + (22, 0x3a8585, e(0x2af00000)), + (22, 0x3a8586, e(0x2b000000)), + (22, 0x3a8587, e(0x2b100000)), + (22, 0x3a8588, e(0x2b200000)), + (22, 0x3a8589, e(0x2b300000)), + (22, 0x3a858a, e(0x2b400000)), + (22, 0x3a858b, e(0x2b500000)), + (22, 0x3a858c, e(0x2b600000)), + (22, 0x3a858d, e(0x2b700000)), + (22, 0x3a858e, e(0x2b800000)), + (22, 0x3a858f, e(0x2b900000)), + (22, 0x3a8590, e(0x2ba00000)), + (22, 0x3a8591, e(0x2bb00000)), + (22, 0x3a8592, e(0x2bc00000)), + (22, 0x3a8593, e(0x2bd00000)), + (22, 0x3a8594, e(0x2be00000)), + (22, 0x3a8595, e(0x2bf00000)), + (22, 0x3a8596, e(0x2c000000)), + (22, 0x3a8597, e(0x2c100000)), + (22, 0x3a8598, e(0x2c200000)), + (22, 0x3a8599, e(0x2c300000)), + (22, 0x3a859a, e(0x2c400000)), + (22, 0x3a859b, e(0x2c500000)), + (22, 0x3a859c, e(0x2c600000)), + (22, 0x3a859d, e(0x2c700000)), + (22, 0x3a859e, e(0x2c800000)), + (22, 0x3a859f, e(0x2c900000)), + (22, 0x3a85a0, e(0x2ca00000)), + (22, 0x3a85a1, e(0x2cb00000)), + (22, 0x3a85a2, e(0x2cc00000)), + (22, 0x3a85a3, e(0x2cd00000)), + (22, 0x3a85a4, e(0x2ce00000)), + (22, 0x3a85a5, e(0x2cf00000)), + (22, 0x3a85a6, e(0x2d000000)), + (22, 0x3a85a7, e(0x2d100000)), + (22, 0x3a85a8, e(0x2d200000)), + (22, 0x3a85a9, e(0x2d300000)), + (22, 0x3a85aa, e(0x2d400000)), + (22, 0x3a85ab, e(0x2d500000)), + (22, 0x3a85ac, e(0x2d600000)), + (22, 0x3a85ad, e(0x2d700000)), + (22, 0x3a85ae, e(0x2d800000)), + (22, 0x3a85af, e(0x2d900000)), + (22, 0x3a85b0, e(0x2da00000)), + (22, 0x3a85b1, e(0x2db00000)), + (22, 0x3a85b2, e(0x2dc00000)), + (22, 0x3a85b3, e(0x2dd00000)), + (22, 0x3a85b4, e(0x2de00000)), + (22, 0x3a85b5, e(0x2df00000)), + (22, 0x3a85b6, e(0x2e000000)), + (22, 0x3a85b7, e(0x2e100000)), + (22, 0x3a85b8, e(0x2e200000)), + (22, 0x3a85b9, e(0x2e300000)), + (22, 0x3a85ba, e(0x2e400000)), + (22, 0x3a85bb, e(0x2e500000)), + (22, 0x3a85bc, e(0x2e600000)), + (22, 0x3a85bd, e(0x2e700000)), + (22, 0x3a85be, e(0x2e800000)), + (22, 0x3a85bf, e(0x2e900000)), + (22, 0x3a85c0, e(0x2ea00000)), + (22, 0x3a85c1, e(0x2eb00000)), + (22, 0x3a85c2, e(0x2ec00000)), + (22, 0x3a85c3, e(0x2ed00000)), + (22, 0x3a85c4, e(0x2ee00000)), + (22, 0x3a85c5, e(0x2ef00000)), + (22, 0x3a85c6, e(0x2f000000)), + (22, 0x3a85c7, e(0x2f100000)), + (22, 0x3a85c8, e(0x2f200000)), + (22, 0x3a85c9, e(0x2f300000)), + (22, 0x3a85ca, e(0x2f400000)), + (22, 0x3a85cb, e(0x2f500000)), + (22, 0x3a85cc, e(0x2f600000)), + (22, 0x3a85cd, e(0x2f700000)), + (22, 0x3a85ce, e(0x2f800000)), + (22, 0x3a85cf, e(0x2f900000)), + (22, 0x3a85d0, e(0x2fa00000)), + (22, 0x3a85d1, e(0x2fb00000)), + (22, 0x3a85d2, e(0x2fc00000)), + (22, 0x3a85d3, e(0x2fd00000)), + (22, 0x3a85d4, e(0x2fe00000)), + (22, 0x3a85d5, e(0x2ff00000)), + (22, 0x3a85d6, e(0x30000000)), + (22, 0x3a85d7, e(0x30100000)), + (22, 0x3a85d8, e(0x30200000)), + (22, 0x3a85d9, e(0x30300000)), + (22, 0x3a85da, e(0x30400000)), + (22, 0x3a85db, e(0x30500000)), + (22, 0x3a85dc, e(0x30600000)), + (22, 0x3a85dd, e(0x30700000)), + (22, 0x3a85de, e(0x30800000)), + (22, 0x3a85df, e(0x30900000)), + (22, 0x3a85e0, e(0x30a00000)), + (22, 0x3a85e1, e(0x30b00000)), + (22, 0x3a85e2, e(0x30c00000)), + (22, 0x3a85e3, e(0x30d00000)), + (22, 0x3a85e4, e(0x30e00000)), + (22, 0x3a85e5, e(0x30f00000)), + (22, 0x3a85e6, e(0x31000000)), + (22, 0x3a85e7, e(0x31100000)), + (22, 0x3a85e8, e(0x31200000)), + (22, 0x3a85e9, e(0x31300000)), + (22, 0x3a85ea, e(0x31400000)), + (22, 0x3a85eb, e(0x31500000)), + (22, 0x3a85ec, e(0x31600000)), + (22, 0x3a85ed, e(0x31700000)), + (22, 0x3a85ee, e(0x31800000)), + (22, 0x3a85ef, e(0x31900000)), + (22, 0x3a85f0, e(0x31a00000)), + (22, 0x3a85f1, e(0x31b00000)), + (22, 0x3a85f2, e(0x31c00000)), + (22, 0x3a85f3, e(0x31d00000)), + (22, 0x3a85f4, e(0x31e00000)), + (22, 0x3a85f5, e(0x31f00000)), + (22, 0x3a85f6, e(0x32000000)), + (22, 0x3a85f7, e(0x32100000)), + (22, 0x3a85f8, e(0x32200000)), + (22, 0x3a85f9, e(0x32300000)), + (22, 0x3a85fa, e(0x32400000)), + (22, 0x3a85fb, e(0x32500000)), + (22, 0x3a85fc, e(0x32600000)), + (22, 0x3a85fd, e(0x32700000)), + (22, 0x3a85fe, e(0x32800000)), + (22, 0x3a85ff, e(0x32900000)), + (22, 0x3a8600, e(0x32a00000)), + (22, 0x3a8601, e(0x32b00000)), + (22, 0x3a8602, e(0x32c00000)), + (22, 0x3a8603, e(0x32d00000)), + (22, 0x3a8604, e(0x32e00000)), + (22, 0x3a8605, e(0x32f00000)), + (22, 0x3a8606, e(0x33000000)), + (22, 0x3a8607, e(0x33100000)), + (22, 0x3a8608, e(0x33200000)), + (22, 0x3a8609, e(0x33300000)), + (22, 0x3a860a, e(0x33400000)), + (22, 0x3a860b, e(0x33500000)), + (22, 0x3a860c, e(0x33600000)), + (22, 0x3a860d, e(0x33700000)), + (22, 0x3a860e, e(0x33800000)), + (22, 0x3a860f, e(0x33900000)), + (22, 0x3a8610, e(0x33a00000)), + (22, 0x3a8611, e(0x33b00000)), + (22, 0x3a8612, e(0x33c00000)), + (22, 0x3a8613, e(0x33d00000)), + (22, 0x3a8614, e(0x33e00000)), + (22, 0x3a8615, e(0x33f00000)), + (22, 0x3a8616, e(0x34000000)), + (22, 0x3a8617, e(0x34100000)), + (22, 0x3a8618, e(0x34200000)), + (22, 0x3a8619, e(0x34300000)), + (22, 0x3a861a, e(0x34400000)), + (22, 0x3a861b, e(0x34500000)), + (22, 0x3a861c, e(0x34600000)), + (22, 0x3a861d, e(0x34700000)), + (22, 0x3a861e, e(0x34800000)), + (22, 0x3a861f, e(0x34900000)), + (22, 0x3a8620, e(0x34a00000)), + (22, 0x3a8621, e(0x34b00000)), + (22, 0x3a8622, e(0x34c00000)), + (22, 0x3a8623, e(0x34d00000)), + (22, 0x3a8624, e(0x34e00000)), + (22, 0x3a8625, e(0x34f00000)), + (22, 0x3a8626, e(0x35000000)), + (22, 0x3a8627, e(0x35100000)), + (22, 0x3a8628, e(0x35200000)), + (22, 0x3a8629, e(0x35300000)), + (18, 0xf787, e(0x35400000)), + (18, 0xd1d4, e(0x35500000)), + (19, 0x77f9f, e(0x35600000)), + (18, 0x3b8fb, e(0x35700000)), + (19, 0x1ef1a, e(0x35800000)), + (21, 0x1d4315, e(0x35900000)), + (16, 0x3de2, e(0x35a00000)), + (22, 0x3a862c, e(0x35b00000)), + (22, 0x3a862d, e(0x35c00000)), + (22, 0x3a862e, e(0x35d00000)), + (22, 0x3a862f, e(0x35e00000)), + (22, 0x3a8630, e(0x35f00000)), + (22, 0x3a8631, e(0x36000000)), + (22, 0x3a8632, e(0x36100000)), + (22, 0x3a8633, e(0x36200000)), + (22, 0x3a8634, e(0x36300000)), + (22, 0x3a8635, e(0x36400000)), + (22, 0x3a8636, e(0x36500000)), + (22, 0x3a8637, e(0x36600000)), + (22, 0x3a8638, e(0x36700000)), + (21, 0x1d431d, e(0x36800000)), + (22, 0x3a8639, e(0x36900000)), + (22, 0x3a863c, e(0x36a00000)), + (16, 0x3de0, e(0x36b00000)), + (18, 0x3a95e, e(0x36c00000)), + (21, 0x1d431f, e(0x36d00000)), + (22, 0x3a863d, e(0x36e00000)), + (22, 0x3a8640, e(0x36f00000)), + (20, 0x34749, e(0x37000000)), + (20, 0x3474d, e(0x37100000)), + (22, 0x3a8641, e(0x37200000)), + (22, 0x3a8642, e(0x37300000)), + (22, 0x3a8643, e(0x37400000)), + (22, 0x3a8644, e(0x37500000)), + (22, 0x3a8645, e(0x37600000)), + (22, 0x3a8646, e(0x37700000)), + (22, 0x3a8647, e(0x37800000)), + (22, 0x3a8648, e(0x37900000)), + (22, 0x3a8649, e(0x37a00000)), + (22, 0x3a864a, e(0x37b00000)), + (22, 0x3a864b, e(0x37c00000)), + (22, 0x3a864c, e(0x37d00000)), + (22, 0x3a864d, e(0x37e00000)), + (22, 0x3a864e, e(0x37f00000)), + (22, 0x3a864f, e(0x38000000)), + (22, 0x3a8650, e(0x38100000)), + (22, 0x3a8651, e(0x38200000)), + (22, 0x3a8652, e(0x38300000)), + (22, 0x3a8653, e(0x38400000)), + (22, 0x3a8654, e(0x38500000)), + (22, 0x3a8655, e(0x38600000)), + (22, 0x3a8656, e(0x38700000)), + (22, 0x3a8657, e(0x38800000)), + (22, 0x3a8658, e(0x38900000)), + (22, 0x3a8659, e(0x38a00000)), + (22, 0x3a865a, e(0x38b00000)), + (22, 0x3a865b, e(0x38c00000)), + (22, 0x3a865c, e(0x38d00000)), + (22, 0x3a865d, e(0x38e00000)), + (21, 0x1d432f, e(0x38f00000)), + (22, 0x3a8660, e(0x39000000)), + (22, 0x3a8661, e(0x39100000)), + (22, 0x3a8662, e(0x39200000)), + (21, 0x1d4332, e(0x39300000)), + (18, 0xd1d5, e(0x39400000)), + (18, 0x3bfda, e(0x39500000)), + (15, 0x7528, e(0x39600000)), + (15, 0x7529, e(0x39700000)), + (18, 0x3a95f, e(0x39800000)), + (17, 0x1d434, e(0x39900000)), + (19, 0x750cd, e(0x39a00000)), + (18, 0x3a867, e(0x39b00000)), + (19, 0x771fd, e(0x39c00000)), + (15, 0x7764, e(0x39d00000)), + (20, 0xee3f9, e(0x39e00000)), + (18, 0x3bfdb, e(0x39f00000)), + (16, 0xeff7, e(0x3a000000)), + (18, 0xd1d6, e(0x3a100000)), + (22, 0x3a8663, e(0x3a200000)), + (22, 0x3a8666, e(0x3a300000)), + (18, 0x3b8fa, e(0x3a400000)), + (17, 0x1d435, e(0x3a500000)), + (17, 0x1dfe4, e(0x3a600000)), + (19, 0x750d8, e(0x3a700000)), + (18, 0x3a95b, e(0x3a800000)), + (19, 0x77f9e, e(0x3a900000)), + (19, 0x750d9, e(0x3aa00000)), + (18, 0xd1d7, e(0x3ab00000)), + (18, 0x3b8ff, e(0x3ac00000)), + (17, 0x1dc7c, e(0x3ad00000)), + (19, 0x750da, e(0x3ae00000)), + (17, 0x7bc2, e(0x3af00000)), + (18, 0x3bfca, e(0x3b000000)), + (19, 0x1a3a5, e(0x3b100000)), + (17, 0x1d4ac, e(0x3b200000)), + (18, 0x3a86e, e(0x3b300000)), + (17, 0x1d438, e(0x3b400000)), + (18, 0x3bfcb, e(0x3b500000)), + (19, 0x1a3a7, e(0x3b600000)), + (17, 0x1d4ae, e(0x3b700000)), + (18, 0x3bfce, e(0x3b800000)), + (18, 0xf78c, e(0x3b900000)), + (17, 0x1dfec, e(0x3ba00000)), + (17, 0x1d439, e(0x3bb00000)), + (17, 0x68e8, e(0x3bc00000)), + (18, 0xf786, e(0x3bd00000)), + (15, 0x771e, e(0x3be00000)), + (17, 0x1dfe6, e(0x3bf00000)), + (15, 0x77f8, e(0x3c000000)), + (14, 0x3bb3, e(0x3c100000)), + (14, 0x3b8e, e(0x3c200000)), + (14, 0x3a82, e(0x3c300000)), + (14, 0x3b96, e(0x3c400000)), + (13, 0x68f, e(0x3c500000)), + (12, 0x3d4, e(0x3c600000)), + (13, 0x1dca, e(0x3c700000)), + (12, 0x346, e(0x3c800000)), + (12, 0xee7, e(0x3c900000)), + (12, 0xeea, e(0x3ca00000)), + (11, 0x1ed, e(0x3cb00000)), + (12, 0x3df, e(0x3cc00000)), + (11, 0x1a2, e(0x3cd00000)), + (11, 0x56f, e(0x3ce00000)), + (11, 0xb9, e(0x3cf00000)), + (13, 0x7b2, e(0x3d000000)), + (13, 0x1dd8, e(0x3d100000)), + (13, 0x15ba, e(0x3d200000)), + (12, 0xee6, e(0x3d300000)), + (13, 0x15b8, e(0x3d400000)), + (14, 0xf79, e(0x3d500000)), + (14, 0x3a81, e(0x3d600000)), + (14, 0xd1c, e(0x3d700000)), + (15, 0x7765, e(0x3d800000)), + (14, 0xf54, e(0x3d900000)), + (13, 0x15b9, e(0x3da00000)), + (13, 0x7ab, e(0x3db00000)), + (15, 0x7500, e(0x3dc00000)), + (15, 0x1eaa, e(0x3dd00000)), + (15, 0x7501, e(0x3de00000)), + (15, 0x1eab, e(0x3df00000)), + (14, 0x3b97, e(0x3e000000)), + (15, 0x752a, e(0x3e100000)), + (15, 0x77fa, e(0x3e200000)), + (10, 0xf4, d(0x3e35798e, 0xe2308c3a)), + (14, 0x3a93, e(0x3e300000)), + (11, 0x77c, d(0x3e45798e, 0xe2308c3a)), + (13, 0x1dc6, e(0x3e400000)), + (13, 0x7bd, e(0x3e500000)), + (13, 0x1dff, e(0x3e600000)), + (12, 0xefe, e(0x3e700000)), + (8, 0xaf, d(0x3e8ad7f2, 0x9abcaf4a)), + (12, 0xeed, e(0x3e800000)), + (11, 0xb8, e(0x3e900000)), + (12, 0x3d8, e(0x3ea00000)), + (11, 0x1eb, e(0x3eb00000)), + (9, 0x1d2, d(0x3ec0c6f7, 0xa0b5ed8e)), + (13, 0x1d4b, e(0x3ec00000)), + (13, 0x7b3, e(0x3ed00000)), + (10, 0x5d, e(0x3ee00000)), + (12, 0xeeb, e(0x3ef00000)), + (11, 0x1ee, e(0x3f000000)), + (10, 0x5f, e(0x3f100000)), + (10, 0x2b6, e(0x3f200000)), + (9, 0x1de, e(0x3f300000)), + (10, 0xd0, d(0x3f454c98, 0x5f06f694)), + (6, 0x9, d(0x3f4a36e2, 0xeb1c432d)), + (8, 0xe8, e(0x3f400000)), + (4, 0xf, d(0x3f50624d, 0xd2f1a9fc)), + (8, 0xae, e(0x3f500000)), + (5, 0x16, d(0x3f60624d, 0xd2f1a9fc)), + (7, 0x1b, e(0x3f600000)), + (7, 0x76, e(0x3f700000)), + (7, 0xa, e(0x3f800000)), + (6, 0x8, e(0x3f900000)), + (6, 0xe, e(0x3fa00000)), + (11, 0x751, d(0x3fbe69ad, 0x42c3c9ee)), + (6, 0x4, e(0x3fb00000)), + (6, 0xc, e(0x3fc00000)), + (5, 0x3, e(0x3fd00000)), + (11, 0x777, d(0x3fe00000, 0x00000000)), + (9, 0x1d6, d(0x3fefffff, 0xf8000002)), + (4, 0x8, e(0x3fe00000)), + (4, 0x0, d(0x3ff00000, 0x00000000)), + (5, 0x13, e(0x3ff00000)), + (5, 0x1b, e(0x40000000)), + (9, 0x15a, d(0x401921fb, 0x54442d18)), + (5, 0x17, e(0x40100000)), + (5, 0x12, e(0x40200000)), + (11, 0x774, d(0x4035ee14, 0x80000000)), + (5, 0x19, e(0x40300000)), + (9, 0x1d3, d(0x404ca5dc, 0x1a63c1f8)), + (5, 0x1a, e(0x40400000)), + (11, 0x77e, d(0x405bb32f, 0xe0000000)), + (10, 0x5e, d(0x405c332f, 0xe0000000)), + (5, 0x18, e(0x40500000)), + (9, 0x1d7, d(0x40668000, 0x00000000)), + (5, 0x1c, e(0x40600000)), + (9, 0x1d5, d(0x40768000, 0x00000000)), + (5, 0x14, e(0x40700000)), + (11, 0x77d, d(0x408f4000, 0x00000000)), + (5, 0x5, e(0x40800000)), + (10, 0xd2, d(0x409233ff, 0xffffffff)), + (8, 0x3c, d(0x40923400, 0x00000000)), + (11, 0x753, d(0x40923400, 0x00000001)), + (10, 0xd3, d(0x4092abff, 0xffffffff)), + (8, 0x35, d(0x4092ac00, 0x00000000)), + (11, 0x770, d(0x4092ac00, 0x00000001)), + (8, 0x16, e(0x40900000)), + (12, 0xee2, e(0x40a00000)), + (12, 0xee4, e(0x40b00000)), + (7, 0x1f, d(0x40c81c80, 0x00000000)), + (8, 0xac, e(0x40c00000)), + (13, 0x15bb, e(0x40d00000)), + (22, 0x3a8667, e(0x40e00000)), + (22, 0x3a86d8, e(0x40f00000)), + (22, 0x3a86d9, e(0x41000000)), + (22, 0x3a86da, e(0x41100000)), + (17, 0x1dc7e, e(0x41200000)), + (22, 0x3a86db, e(0x41300000)), + (22, 0x3a86dc, e(0x41400000)), + (22, 0x3a86dd, e(0x41500000)), + (22, 0x3a86de, e(0x41600000)), + (22, 0x3a86df, e(0x41700000)), + (22, 0x3a86f0, e(0x41800000)), + (22, 0x3a86f1, e(0x41900000)), + (22, 0x3a86f2, e(0x41a00000)), + (22, 0x3a86f3, e(0x41b00000)), + (6, 0x2a, d(0x41cdcd64, 0xff800000)), + (22, 0x3a86f4, e(0x41c00000)), + (22, 0x3a86f5, e(0x41d00000)), + (22, 0x3a86f6, e(0x41e00000)), + (22, 0x3a86f7, e(0x41f00000)), + (22, 0x3a86f8, e(0x42000000)), + (22, 0x3a86f9, e(0x42100000)), + (22, 0x3a86fa, e(0x42200000)), + (22, 0x3a86fb, e(0x42300000)), + (22, 0x3a86fc, e(0x42400000)), + (22, 0x3a86fd, e(0x42500000)), + (22, 0x3a86fe, e(0x42600000)), + (22, 0x3a86ff, e(0x42700000)), + (22, 0x3a8740, e(0x42800000)), + (22, 0x3a8741, e(0x42900000)), + (22, 0x3a8742, e(0x42a00000)), + (22, 0x3a8743, e(0x42b00000)), + (22, 0x3a8744, e(0x42c00000)), + (22, 0x3a8745, e(0x42d00000)), + (22, 0x3a8746, e(0x42e00000)), + (22, 0x3a8747, e(0x42f00000)), + (22, 0x3a8748, e(0x43000000)), + (22, 0x3a8749, e(0x43100000)), + (22, 0x3a874a, e(0x43200000)), + (22, 0x3a874b, e(0x43300000)), + (22, 0x3a874c, e(0x43400000)), + (22, 0x3a874d, e(0x43500000)), + (22, 0x3a874e, e(0x43600000)), + (22, 0x3a874f, e(0x43700000)), + (22, 0x3a8750, e(0x43800000)), + (22, 0x3a8751, e(0x43900000)), + (22, 0x3a8752, e(0x43a00000)), + (22, 0x3a8753, e(0x43b00000)), + (22, 0x3a8754, e(0x43c00000)), + (22, 0x3a8755, e(0x43d00000)), + (22, 0x3a8756, e(0x43e00000)), + (22, 0x3a8757, e(0x43f00000)), + (22, 0x3a8758, e(0x44000000)), + (15, 0x1a3b, e(0x44100000)), + (22, 0x3a8759, e(0x44200000)), + (22, 0x3a875a, e(0x44300000)), + (22, 0x3a875b, e(0x44400000)), + (22, 0x3a875c, e(0x44500000)), + (22, 0x3a875d, e(0x44600000)), + (22, 0x3a875e, e(0x44700000)), + (22, 0x3a875f, e(0x44800000)), + (22, 0x3a8760, e(0x44900000)), + (22, 0x3a8761, e(0x44a00000)), + (22, 0x3a8762, e(0x44b00000)), + (22, 0x3a8763, e(0x44c00000)), + (22, 0x3a8764, e(0x44d00000)), + (22, 0x3a8765, e(0x44e00000)), + (22, 0x3a8766, e(0x44f00000)), + (22, 0x3a8767, e(0x45000000)), + (22, 0x3a8768, e(0x45100000)), + (22, 0x3a8769, e(0x45200000)), + (22, 0x3a876a, e(0x45300000)), + (22, 0x3a876b, e(0x45400000)), + (22, 0x3a876c, e(0x45500000)), + (22, 0x3a876d, e(0x45600000)), + (22, 0x3a876e, e(0x45700000)), + (22, 0x3a876f, e(0x45800000)), + (22, 0x3a8770, e(0x45900000)), + (22, 0x3a8771, e(0x45a00000)), + (22, 0x3a8772, e(0x45b00000)), + (22, 0x3a8773, e(0x45c00000)), + (22, 0x3a8774, e(0x45d00000)), + (22, 0x3a8775, e(0x45e00000)), + (22, 0x3a8776, e(0x45f00000)), + (22, 0x3a8777, e(0x46000000)), + (22, 0x3a8778, e(0x46100000)), + (22, 0x3a8779, e(0x46200000)), + (22, 0x3a877a, e(0x46300000)), + (22, 0x3a877b, e(0x46400000)), + (22, 0x3a877c, e(0x46500000)), + (22, 0x3a877d, e(0x46600000)), + (22, 0x3a877e, e(0x46700000)), + (22, 0x3a877f, e(0x46800000)), + (22, 0x3a8780, e(0x46900000)), + (22, 0x3a8781, e(0x46a00000)), + (22, 0x3a8782, e(0x46b00000)), + (22, 0x3a8783, e(0x46c00000)), + (22, 0x3a8784, e(0x46d00000)), + (22, 0x3a8785, e(0x46e00000)), + (22, 0x3a8786, e(0x46f00000)), + (22, 0x3a8787, e(0x47000000)), + (22, 0x3a8788, e(0x47100000)), + (22, 0x3a8789, e(0x47200000)), + (22, 0x3a878a, e(0x47300000)), + (22, 0x3a878b, e(0x47400000)), + (22, 0x3a878c, e(0x47500000)), + (22, 0x3a878d, e(0x47600000)), + (22, 0x3a878e, e(0x47700000)), + (22, 0x3a878f, e(0x47800000)), + (22, 0x3a8790, e(0x47900000)), + (22, 0x3a8791, e(0x47a00000)), + (22, 0x3a8792, e(0x47b00000)), + (22, 0x3a8793, e(0x47c00000)), + (22, 0x3a8794, e(0x47d00000)), + (22, 0x3a8795, e(0x47e00000)), + (22, 0x3a8796, e(0x47f00000)), + (22, 0x3a8797, e(0x48000000)), + (22, 0x3a8798, e(0x48100000)), + (22, 0x3a8799, e(0x48200000)), + (22, 0x3a879a, e(0x48300000)), + (22, 0x3a879b, e(0x48400000)), + (22, 0x3a879c, e(0x48500000)), + (22, 0x3a879d, e(0x48600000)), + (22, 0x3a879e, e(0x48700000)), + (22, 0x3a879f, e(0x48800000)), + (22, 0x3a87a0, e(0x48900000)), + (22, 0x3a87a1, e(0x48a00000)), + (22, 0x3a87a2, e(0x48b00000)), + (22, 0x3a87a3, e(0x48c00000)), + (22, 0x3a87a4, e(0x48d00000)), + (22, 0x3a87a5, e(0x48e00000)), + (22, 0x3a87a6, e(0x48f00000)), + (22, 0x3a87a7, e(0x49000000)), + (22, 0x3a87a8, e(0x49100000)), + (22, 0x3a87a9, e(0x49200000)), + (22, 0x3a87aa, e(0x49300000)), + (22, 0x3a87ab, e(0x49400000)), + (22, 0x3a87ac, e(0x49500000)), + (22, 0x3a87ad, e(0x49600000)), + (22, 0x3a87ae, e(0x49700000)), + (22, 0x3a87af, e(0x49800000)), + (22, 0x3a87b0, e(0x49900000)), + (22, 0x3a87b1, e(0x49a00000)), + (22, 0x3a87b2, e(0x49b00000)), + (22, 0x3a87b3, e(0x49c00000)), + (22, 0x3a87b4, e(0x49d00000)), + (22, 0x3a87b5, e(0x49e00000)), + (22, 0x3a87b6, e(0x49f00000)), + (22, 0x3a87b7, e(0x4a000000)), + (22, 0x3a87b8, e(0x4a100000)), + (22, 0x3a87b9, e(0x4a200000)), + (22, 0x3a87ba, e(0x4a300000)), + (22, 0x3a87bb, e(0x4a400000)), + (22, 0x3a87bc, e(0x4a500000)), + (22, 0x3a87bd, e(0x4a600000)), + (22, 0x3a87be, e(0x4a700000)), + (22, 0x3a87bf, e(0x4a800000)), + (22, 0x3a87c0, e(0x4a900000)), + (22, 0x3a87c1, e(0x4aa00000)), + (22, 0x3a87c2, e(0x4ab00000)), + (22, 0x3a87c3, e(0x4ac00000)), + (22, 0x3a87c4, e(0x4ad00000)), + (22, 0x3a87c5, e(0x4ae00000)), + (22, 0x3a87c6, e(0x4af00000)), + (22, 0x3a87c7, e(0x4b000000)), + (22, 0x3a87c8, e(0x4b100000)), + (22, 0x3a87c9, e(0x4b200000)), + (22, 0x3a87ca, e(0x4b300000)), + (22, 0x3a87cb, e(0x4b400000)), + (22, 0x3a87cc, e(0x4b500000)), + (22, 0x3a87cd, e(0x4b600000)), + (22, 0x3a87ce, e(0x4b700000)), + (22, 0x3a87cf, e(0x4b800000)), + (22, 0x3a87d0, e(0x4b900000)), + (22, 0x3a87d1, e(0x4ba00000)), + (22, 0x3a87d2, e(0x4bb00000)), + (22, 0x3a87d3, e(0x4bc00000)), + (22, 0x3a87d4, e(0x4bd00000)), + (22, 0x3a87d5, e(0x4be00000)), + (22, 0x3a87d6, e(0x4bf00000)), + (22, 0x3a87d7, e(0x4c000000)), + (22, 0x3a87d8, e(0x4c100000)), + (22, 0x3a87d9, e(0x4c200000)), + (22, 0x3a87da, e(0x4c300000)), + (22, 0x3a87db, e(0x4c400000)), + (22, 0x3a87dc, e(0x4c500000)), + (22, 0x3a87dd, e(0x4c600000)), + (22, 0x3a87de, e(0x4c700000)), + (22, 0x3a87df, e(0x4c800000)), + (22, 0x3a87e0, e(0x4c900000)), + (22, 0x3a87e1, e(0x4ca00000)), + (22, 0x3a87e2, e(0x4cb00000)), + (22, 0x3a87e3, e(0x4cc00000)), + (22, 0x3a87e4, e(0x4cd00000)), + (22, 0x3a87e5, e(0x4ce00000)), + (22, 0x3a87e6, e(0x4cf00000)), + (22, 0x3a87e7, e(0x4d000000)), + (22, 0x3a87e8, e(0x4d100000)), + (22, 0x3a87e9, e(0x4d200000)), + (22, 0x3a87ea, e(0x4d300000)), + (22, 0x3a87eb, e(0x4d400000)), + (22, 0x3a87ec, e(0x4d500000)), + (22, 0x3a87ed, e(0x4d600000)), + (22, 0x3a87ee, e(0x4d700000)), + (22, 0x3a87ef, e(0x4d800000)), + (22, 0x3a87f0, e(0x4d900000)), + (22, 0x3a87f1, e(0x4da00000)), + (22, 0x3a87f2, e(0x4db00000)), + (22, 0x3a87f3, e(0x4dc00000)), + (22, 0x3a87f4, e(0x4dd00000)), + (22, 0x3a87f5, e(0x4de00000)), + (22, 0x3a87f6, e(0x4df00000)), + (22, 0x3a87f7, e(0x4e000000)), + (22, 0x3a87f8, e(0x4e100000)), + (22, 0x3a87f9, e(0x4e200000)), + (22, 0x3a87fa, e(0x4e300000)), + (22, 0x3a87fb, e(0x4e400000)), + (22, 0x3a87fc, e(0x4e500000)), + (22, 0x3a87fd, e(0x4e600000)), + (22, 0x3a87fe, e(0x4e700000)), + (22, 0x3a87ff, e(0x4e800000)), + (22, 0x3a9000, e(0x4e900000)), + (22, 0x3a9001, e(0x4ea00000)), + (22, 0x3a9002, e(0x4eb00000)), + (22, 0x3a9003, e(0x4ec00000)), + (22, 0x3a9004, e(0x4ed00000)), + (22, 0x3a9005, e(0x4ee00000)), + (22, 0x3a9006, e(0x4ef00000)), + (22, 0x3a9007, e(0x4f000000)), + (22, 0x3a9008, e(0x4f100000)), + (22, 0x3a9009, e(0x4f200000)), + (22, 0x3a900a, e(0x4f300000)), + (22, 0x3a900b, e(0x4f400000)), + (22, 0x3a900c, e(0x4f500000)), + (22, 0x3a900d, e(0x4f600000)), + (22, 0x3a900e, e(0x4f700000)), + (22, 0x3a900f, e(0x4f800000)), + (22, 0x3a9010, e(0x4f900000)), + (22, 0x3a9011, e(0x4fa00000)), + (22, 0x3a9012, e(0x4fb00000)), + (22, 0x3a9013, e(0x4fc00000)), + (22, 0x3a9014, e(0x4fd00000)), + (22, 0x3a9015, e(0x4fe00000)), + (22, 0x3a9016, e(0x4ff00000)), + (22, 0x3a9017, e(0x50000000)), + (22, 0x3a9018, e(0x50100000)), + (22, 0x3a9019, e(0x50200000)), + (22, 0x3a901a, e(0x50300000)), + (22, 0x3a901b, e(0x50400000)), + (22, 0x3a901c, e(0x50500000)), + (22, 0x3a901d, e(0x50600000)), + (22, 0x3a901e, e(0x50700000)), + (22, 0x3a901f, e(0x50800000)), + (22, 0x3a9020, e(0x50900000)), + (22, 0x3a9021, e(0x50a00000)), + (22, 0x3a9022, e(0x50b00000)), + (22, 0x3a9023, e(0x50c00000)), + (22, 0x3a9024, e(0x50d00000)), + (22, 0x3a9025, e(0x50e00000)), + (22, 0x3a9026, e(0x50f00000)), + (22, 0x3a9027, e(0x51000000)), + (22, 0x3a9028, e(0x51100000)), + (22, 0x3a9029, e(0x51200000)), + (22, 0x3a902a, e(0x51300000)), + (22, 0x3a902b, e(0x51400000)), + (22, 0x3a902c, e(0x51500000)), + (22, 0x3a902d, e(0x51600000)), + (22, 0x3a902e, e(0x51700000)), + (22, 0x3a902f, e(0x51800000)), + (22, 0x3a9030, e(0x51900000)), + (22, 0x3a9031, e(0x51a00000)), + (22, 0x3a9032, e(0x51b00000)), + (22, 0x3a9033, e(0x51c00000)), + (22, 0x3a9034, e(0x51d00000)), + (22, 0x3a9035, e(0x51e00000)), + (22, 0x3a9036, e(0x51f00000)), + (22, 0x3a9037, e(0x52000000)), + (22, 0x3a9038, e(0x52100000)), + (22, 0x3a9039, e(0x52200000)), + (22, 0x3a903a, e(0x52300000)), + (22, 0x3a903b, e(0x52400000)), + (22, 0x3a903c, e(0x52500000)), + (22, 0x3a903d, e(0x52600000)), + (22, 0x3a903e, e(0x52700000)), + (22, 0x3a903f, e(0x52800000)), + (22, 0x3a9040, e(0x52900000)), + (22, 0x3a9041, e(0x52a00000)), + (22, 0x3a9042, e(0x52b00000)), + (22, 0x3a9043, e(0x52c00000)), + (22, 0x3a9044, e(0x52d00000)), + (22, 0x3a9045, e(0x52e00000)), + (22, 0x3a9046, e(0x52f00000)), + (22, 0x3a9047, e(0x53000000)), + (22, 0x3a9048, e(0x53100000)), + (22, 0x3a9049, e(0x53200000)), + (22, 0x3a904a, e(0x53300000)), + (22, 0x3a904b, e(0x53400000)), + (22, 0x3a904c, e(0x53500000)), + (22, 0x3a904d, e(0x53600000)), + (22, 0x3a904e, e(0x53700000)), + (22, 0x3a904f, e(0x53800000)), + (22, 0x3a9050, e(0x53900000)), + (22, 0x3a9051, e(0x53a00000)), + (22, 0x3a9052, e(0x53b00000)), + (22, 0x3a9053, e(0x53c00000)), + (22, 0x3a9054, e(0x53d00000)), + (22, 0x3a9055, e(0x53e00000)), + (22, 0x3a9056, e(0x53f00000)), + (22, 0x3a9057, e(0x54000000)), + (22, 0x3a9058, e(0x54100000)), + (22, 0x3a9059, e(0x54200000)), + (22, 0x3a905a, e(0x54300000)), + (22, 0x3a905b, e(0x54400000)), + (22, 0x3a905c, e(0x54500000)), + (22, 0x3a905d, e(0x54600000)), + (22, 0x3a905e, e(0x54700000)), + (22, 0x3a905f, e(0x54800000)), + (22, 0x3a9060, e(0x54900000)), + (22, 0x3a9061, e(0x54a00000)), + (22, 0x3a9062, e(0x54b00000)), + (22, 0x3a9063, e(0x54c00000)), + (22, 0x3a9064, e(0x54d00000)), + (22, 0x3a9065, e(0x54e00000)), + (22, 0x3a9066, e(0x54f00000)), + (22, 0x3a9067, e(0x55000000)), + (22, 0x3a9068, e(0x55100000)), + (22, 0x3a9069, e(0x55200000)), + (22, 0x3a906a, e(0x55300000)), + (22, 0x3a906b, e(0x55400000)), + (22, 0x3a906c, e(0x55500000)), + (22, 0x3a906d, e(0x55600000)), + (22, 0x3a906e, e(0x55700000)), + (22, 0x3a906f, e(0x55800000)), + (22, 0x3a9070, e(0x55900000)), + (22, 0x3a9071, e(0x55a00000)), + (22, 0x3a9072, e(0x55b00000)), + (22, 0x3a9073, e(0x55c00000)), + (22, 0x3a9074, e(0x55d00000)), + (22, 0x3a9075, e(0x55e00000)), + (22, 0x3a9076, e(0x55f00000)), + (22, 0x3a9077, e(0x56000000)), + (22, 0x3a9078, e(0x56100000)), + (22, 0x3a9079, e(0x56200000)), + (22, 0x3a907a, e(0x56300000)), + (22, 0x3a907b, e(0x56400000)), + (22, 0x3a907c, e(0x56500000)), + (22, 0x3a907d, e(0x56600000)), + (22, 0x3a907e, e(0x56700000)), + (22, 0x3a907f, e(0x56800000)), + (22, 0x3a9080, e(0x56900000)), + (22, 0x3a9081, e(0x56a00000)), + (22, 0x3a9082, e(0x56b00000)), + (22, 0x3a9083, e(0x56c00000)), + (22, 0x3a9084, e(0x56d00000)), + (22, 0x3a9085, e(0x56e00000)), + (22, 0x3a9086, e(0x56f00000)), + (22, 0x3a9087, e(0x57000000)), + (22, 0x3a9088, e(0x57100000)), + (22, 0x3a9089, e(0x57200000)), + (22, 0x3a908a, e(0x57300000)), + (22, 0x3a908b, e(0x57400000)), + (22, 0x3a908c, e(0x57500000)), + (22, 0x3a908d, e(0x57600000)), + (22, 0x3a908e, e(0x57700000)), + (22, 0x3a908f, e(0x57800000)), + (22, 0x3a9090, e(0x57900000)), + (22, 0x3a9091, e(0x57a00000)), + (22, 0x3a9092, e(0x57b00000)), + (22, 0x3a9093, e(0x57c00000)), + (22, 0x3a9094, e(0x57d00000)), + (22, 0x3a9095, e(0x57e00000)), + (22, 0x3a9096, e(0x57f00000)), + (22, 0x3a9097, e(0x58000000)), + (22, 0x3a9098, e(0x58100000)), + (22, 0x3a9099, e(0x58200000)), + (22, 0x3a909a, e(0x58300000)), + (22, 0x3a909b, e(0x58400000)), + (22, 0x3a909c, e(0x58500000)), + (22, 0x3a909d, e(0x58600000)), + (22, 0x3a909e, e(0x58700000)), + (22, 0x3a909f, e(0x58800000)), + (22, 0x3a90a0, e(0x58900000)), + (22, 0x3a90a1, e(0x58a00000)), + (22, 0x3a90a2, e(0x58b00000)), + (22, 0x3a90a3, e(0x58c00000)), + (22, 0x3a90a4, e(0x58d00000)), + (22, 0x3a90a5, e(0x58e00000)), + (22, 0x3a90a6, e(0x58f00000)), + (22, 0x3a90a7, e(0x59000000)), + (22, 0x3a90a8, e(0x59100000)), + (22, 0x3a90a9, e(0x59200000)), + (22, 0x3a90aa, e(0x59300000)), + (22, 0x3a90ab, e(0x59400000)), + (22, 0x3a90ac, e(0x59500000)), + (22, 0x3a90ad, e(0x59600000)), + (22, 0x3a90ae, e(0x59700000)), + (22, 0x3a90af, e(0x59800000)), + (22, 0x3a90b0, e(0x59900000)), + (22, 0x3a90b1, e(0x59a00000)), + (22, 0x3a90b2, e(0x59b00000)), + (22, 0x3a90b3, e(0x59c00000)), + (22, 0x3a90b4, e(0x59d00000)), + (22, 0x3a90b5, e(0x59e00000)), + (22, 0x3a90b6, e(0x59f00000)), + (22, 0x3a90b7, e(0x5a000000)), + (22, 0x3a90b8, e(0x5a100000)), + (22, 0x3a90b9, e(0x5a200000)), + (22, 0x3a90ba, e(0x5a300000)), + (22, 0x3a90bb, e(0x5a400000)), + (22, 0x3a90bc, e(0x5a500000)), + (22, 0x3a90bd, e(0x5a600000)), + (22, 0x3a90be, e(0x5a700000)), + (22, 0x3a90bf, e(0x5a800000)), + (22, 0x3a90c0, e(0x5a900000)), + (22, 0x3a90c1, e(0x5aa00000)), + (22, 0x3a90c2, e(0x5ab00000)), + (22, 0x3a90c3, e(0x5ac00000)), + (22, 0x3a90c4, e(0x5ad00000)), + (22, 0x3a90c5, e(0x5ae00000)), + (22, 0x3a90c6, e(0x5af00000)), + (22, 0x3a90c7, e(0x5b000000)), + (22, 0x3a90c8, e(0x5b100000)), + (22, 0x3a90c9, e(0x5b200000)), + (22, 0x3a90ca, e(0x5b300000)), + (22, 0x3a90cb, e(0x5b400000)), + (22, 0x3a90cc, e(0x5b500000)), + (22, 0x3a90cd, e(0x5b600000)), + (22, 0x3a90ce, e(0x5b700000)), + (22, 0x3a90cf, e(0x5b800000)), + (22, 0x3a90d0, e(0x5b900000)), + (22, 0x3a90d1, e(0x5ba00000)), + (22, 0x3a90d2, e(0x5bb00000)), + (22, 0x3a90d3, e(0x5bc00000)), + (22, 0x3a90d4, e(0x5bd00000)), + (22, 0x3a90d5, e(0x5be00000)), + (22, 0x3a90d6, e(0x5bf00000)), + (22, 0x3a90d7, e(0x5c000000)), + (22, 0x3a90d8, e(0x5c100000)), + (22, 0x3a90d9, e(0x5c200000)), + (22, 0x3a90da, e(0x5c300000)), + (22, 0x3a90db, e(0x5c400000)), + (22, 0x3a90dc, e(0x5c500000)), + (22, 0x3a90dd, e(0x5c600000)), + (22, 0x3a90de, e(0x5c700000)), + (22, 0x3a90df, e(0x5c800000)), + (22, 0x3a90e0, e(0x5c900000)), + (22, 0x3a90e1, e(0x5ca00000)), + (22, 0x3a90e2, e(0x5cb00000)), + (22, 0x3a90e3, e(0x5cc00000)), + (22, 0x3a90e4, e(0x5cd00000)), + (22, 0x3a90e5, e(0x5ce00000)), + (22, 0x3a90e6, e(0x5cf00000)), + (22, 0x3a90e7, e(0x5d000000)), + (22, 0x3a90e8, e(0x5d100000)), + (22, 0x3a90e9, e(0x5d200000)), + (22, 0x3a90ea, e(0x5d300000)), + (22, 0x3a90eb, e(0x5d400000)), + (22, 0x3a90ec, e(0x5d500000)), + (22, 0x3a90ed, e(0x5d600000)), + (22, 0x3a90ee, e(0x5d700000)), + (22, 0x3a90ef, e(0x5d800000)), + (22, 0x3a90f0, e(0x5d900000)), + (22, 0x3a90f1, e(0x5da00000)), + (22, 0x3a90f2, e(0x5db00000)), + (22, 0x3a90f3, e(0x5dc00000)), + (22, 0x3a90f4, e(0x5dd00000)), + (22, 0x3a90f5, e(0x5de00000)), + (22, 0x3a90f6, e(0x5df00000)), + (22, 0x3a90f7, e(0x5e000000)), + (22, 0x3a90f8, e(0x5e100000)), + (22, 0x3a90f9, e(0x5e200000)), + (22, 0x3a90fa, e(0x5e300000)), + (22, 0x3a90fb, e(0x5e400000)), + (22, 0x3a90fc, e(0x5e500000)), + (22, 0x3a90fd, e(0x5e600000)), + (22, 0x3a90fe, e(0x5e700000)), + (22, 0x3a90ff, e(0x5e800000)), + (22, 0x3a9100, e(0x5e900000)), + (22, 0x3a9101, e(0x5ea00000)), + (22, 0x3a9102, e(0x5eb00000)), + (22, 0x3a9103, e(0x5ec00000)), + (22, 0x3a9104, e(0x5ed00000)), + (22, 0x3a9105, e(0x5ee00000)), + (22, 0x3a9106, e(0x5ef00000)), + (22, 0x3a9107, e(0x5f000000)), + (22, 0x3a9108, e(0x5f100000)), + (22, 0x3a9109, e(0x5f200000)), + (22, 0x3a910a, e(0x5f300000)), + (22, 0x3a910b, e(0x5f400000)), + (22, 0x3a910c, e(0x5f500000)), + (22, 0x3a910d, e(0x5f600000)), + (22, 0x3a910e, e(0x5f700000)), + (22, 0x3a910f, e(0x5f800000)), + (22, 0x3a9110, e(0x5f900000)), + (22, 0x3a9111, e(0x5fa00000)), + (22, 0x3a9112, e(0x5fb00000)), + (22, 0x3a9113, e(0x5fc00000)), + (22, 0x3a9114, e(0x5fd00000)), + (22, 0x3a9115, e(0x5fe00000)), + (22, 0x3a9116, e(0x5ff00000)), + (22, 0x3a9117, e(0x60000000)), + (22, 0x3a9118, e(0x60100000)), + (22, 0x3a9119, e(0x60200000)), + (22, 0x3a911a, e(0x60300000)), + (22, 0x3a911b, e(0x60400000)), + (22, 0x3a911c, e(0x60500000)), + (22, 0x3a911d, e(0x60600000)), + (22, 0x3a911e, e(0x60700000)), + (22, 0x3a911f, e(0x60800000)), + (22, 0x3a9120, e(0x60900000)), + (22, 0x3a9121, e(0x60a00000)), + (22, 0x3a9122, e(0x60b00000)), + (22, 0x3a9123, e(0x60c00000)), + (22, 0x3a9124, e(0x60d00000)), + (22, 0x3a9125, e(0x60e00000)), + (22, 0x3a9126, e(0x60f00000)), + (22, 0x3a9127, e(0x61000000)), + (22, 0x3a9128, e(0x61100000)), + (22, 0x3a9129, e(0x61200000)), + (22, 0x3a912a, e(0x61300000)), + (22, 0x3a912b, e(0x61400000)), + (22, 0x3a912c, e(0x61500000)), + (22, 0x3a912d, e(0x61600000)), + (22, 0x3a912e, e(0x61700000)), + (22, 0x3a912f, e(0x61800000)), + (22, 0x3a9130, e(0x61900000)), + (22, 0x3a9131, e(0x61a00000)), + (22, 0x3a9132, e(0x61b00000)), + (22, 0x3a9133, e(0x61c00000)), + (22, 0x3a9134, e(0x61d00000)), + (22, 0x3a9135, e(0x61e00000)), + (22, 0x3a9136, e(0x61f00000)), + (22, 0x3a9137, e(0x62000000)), + (22, 0x3a9138, e(0x62100000)), + (22, 0x3a9139, e(0x62200000)), + (22, 0x3a913a, e(0x62300000)), + (22, 0x3a913b, e(0x62400000)), + (22, 0x3a913c, e(0x62500000)), + (22, 0x3a913d, e(0x62600000)), + (22, 0x3a913e, e(0x62700000)), + (22, 0x3a913f, e(0x62800000)), + (22, 0x3a9140, e(0x62900000)), + (22, 0x3a9141, e(0x62a00000)), + (22, 0x3a9142, e(0x62b00000)), + (22, 0x3a9143, e(0x62c00000)), + (22, 0x3a9144, e(0x62d00000)), + (22, 0x3a9145, e(0x62e00000)), + (22, 0x3a9146, e(0x62f00000)), + (22, 0x3a9147, e(0x63000000)), + (22, 0x3a9148, e(0x63100000)), + (22, 0x3a9149, e(0x63200000)), + (22, 0x3a914a, e(0x63300000)), + (22, 0x3a914b, e(0x63400000)), + (22, 0x3a914c, e(0x63500000)), + (22, 0x3a914d, e(0x63600000)), + (22, 0x3a914e, e(0x63700000)), + (22, 0x3a914f, e(0x63800000)), + (22, 0x3a9150, e(0x63900000)), + (22, 0x3a9151, e(0x63a00000)), + (22, 0x3a9152, e(0x63b00000)), + (22, 0x3a9153, e(0x63c00000)), + (22, 0x3a9154, e(0x63d00000)), + (22, 0x3a9155, e(0x63e00000)), + (22, 0x3a9156, e(0x63f00000)), + (22, 0x3a9157, e(0x64000000)), + (22, 0x3a9158, e(0x64100000)), + (22, 0x3a9159, e(0x64200000)), + (22, 0x3a915a, e(0x64300000)), + (22, 0x3a915b, e(0x64400000)), + (22, 0x3a915c, e(0x64500000)), + (22, 0x3a915d, e(0x64600000)), + (22, 0x3a915e, e(0x64700000)), + (22, 0x3a915f, e(0x64800000)), + (22, 0x3a9160, e(0x64900000)), + (22, 0x3a9161, e(0x64a00000)), + (22, 0x3a9162, e(0x64b00000)), + (22, 0x3a9163, e(0x64c00000)), + (22, 0x3a9164, e(0x64d00000)), + (22, 0x3a9165, e(0x64e00000)), + (22, 0x3a9166, e(0x64f00000)), + (22, 0x3a9167, e(0x65000000)), + (22, 0x3a9168, e(0x65100000)), + (22, 0x3a9169, e(0x65200000)), + (22, 0x3a916a, e(0x65300000)), + (22, 0x3a916b, e(0x65400000)), + (22, 0x3a916c, e(0x65500000)), + (22, 0x3a916d, e(0x65600000)), + (22, 0x3a916e, e(0x65700000)), + (22, 0x3a916f, e(0x65800000)), + (22, 0x3a9170, e(0x65900000)), + (22, 0x3a9171, e(0x65a00000)), + (22, 0x3a9172, e(0x65b00000)), + (22, 0x3a9173, e(0x65c00000)), + (22, 0x3a9174, e(0x65d00000)), + (22, 0x3a9175, e(0x65e00000)), + (22, 0x3a9176, e(0x65f00000)), + (22, 0x3a9177, e(0x66000000)), + (22, 0x3a9178, e(0x66100000)), + (22, 0x3a9179, e(0x66200000)), + (22, 0x3a917a, e(0x66300000)), + (22, 0x3a917b, e(0x66400000)), + (22, 0x3a917c, e(0x66500000)), + (22, 0x3a917d, e(0x66600000)), + (22, 0x3a917e, e(0x66700000)), + (22, 0x3a917f, e(0x66800000)), + (22, 0x3a9180, e(0x66900000)), + (22, 0x3a9181, e(0x66a00000)), + (22, 0x3a9182, e(0x66b00000)), + (22, 0x3a9183, e(0x66c00000)), + (22, 0x3a9184, e(0x66d00000)), + (22, 0x3a9185, e(0x66e00000)), + (22, 0x3a9186, e(0x66f00000)), + (22, 0x3a9187, e(0x67000000)), + (22, 0x3a9188, e(0x67100000)), + (22, 0x3a9189, e(0x67200000)), + (22, 0x3a918a, e(0x67300000)), + (22, 0x3a918b, e(0x67400000)), + (22, 0x3a918c, e(0x67500000)), + (22, 0x3a918d, e(0x67600000)), + (22, 0x3a918e, e(0x67700000)), + (22, 0x3a918f, e(0x67800000)), + (22, 0x3a9190, e(0x67900000)), + (22, 0x3a9191, e(0x67a00000)), + (22, 0x3a9192, e(0x67b00000)), + (22, 0x3a9193, e(0x67c00000)), + (22, 0x3a9194, e(0x67d00000)), + (22, 0x3a9195, e(0x67e00000)), + (22, 0x3a9196, e(0x67f00000)), + (22, 0x3a9197, e(0x68000000)), + (22, 0x3a9198, e(0x68100000)), + (22, 0x3a9199, e(0x68200000)), + (22, 0x3a919a, e(0x68300000)), + (22, 0x3a919b, e(0x68400000)), + (22, 0x3a919c, e(0x68500000)), + (22, 0x3a919d, e(0x68600000)), + (22, 0x3a919e, e(0x68700000)), + (22, 0x3a919f, e(0x68800000)), + (22, 0x3a91a0, e(0x68900000)), + (22, 0x3a91a1, e(0x68a00000)), + (22, 0x3a91a2, e(0x68b00000)), + (22, 0x3a91a3, e(0x68c00000)), + (22, 0x3a91a4, e(0x68d00000)), + (22, 0x3a91a5, e(0x68e00000)), + (22, 0x3a91a6, e(0x68f00000)), + (22, 0x3a91a7, e(0x69000000)), + (22, 0x3a91a8, e(0x69100000)), + (22, 0x3a91a9, e(0x69200000)), + (22, 0x3a91aa, e(0x69300000)), + (22, 0x3a91ab, e(0x69400000)), + (22, 0x3a91ac, e(0x69500000)), + (22, 0x3a91ad, e(0x69600000)), + (22, 0x3a91ae, e(0x69700000)), + (22, 0x3a91af, e(0x69800000)), + (22, 0x3a91b0, e(0x69900000)), + (22, 0x3a91b1, e(0x69a00000)), + (22, 0x3a91b2, e(0x69b00000)), + (22, 0x3a91b3, e(0x69c00000)), + (22, 0x3a91b4, e(0x69d00000)), + (22, 0x3a91b5, e(0x69e00000)), + (22, 0x3a91b6, e(0x69f00000)), + (22, 0x3a91b7, e(0x6a000000)), + (22, 0x3a91b8, e(0x6a100000)), + (22, 0x3a91b9, e(0x6a200000)), + (22, 0x3a91ba, e(0x6a300000)), + (22, 0x3a91bb, e(0x6a400000)), + (22, 0x3a91bc, e(0x6a500000)), + (22, 0x3a91bd, e(0x6a600000)), + (22, 0x3a91be, e(0x6a700000)), + (22, 0x3a91bf, e(0x6a800000)), + (22, 0x3a91c0, e(0x6a900000)), + (22, 0x3a91c1, e(0x6aa00000)), + (22, 0x3a91c2, e(0x6ab00000)), + (22, 0x3a91c3, e(0x6ac00000)), + (22, 0x3a91c4, e(0x6ad00000)), + (22, 0x3a91c5, e(0x6ae00000)), + (22, 0x3a91c6, e(0x6af00000)), + (22, 0x3a91c7, e(0x6b000000)), + (22, 0x3a91c8, e(0x6b100000)), + (22, 0x3a91c9, e(0x6b200000)), + (22, 0x3a91ca, e(0x6b300000)), + (22, 0x3a91cb, e(0x6b400000)), + (22, 0x3a91cc, e(0x6b500000)), + (22, 0x3a91cd, e(0x6b600000)), + (22, 0x3a91ce, e(0x6b700000)), + (22, 0x3a91cf, e(0x6b800000)), + (22, 0x3a91d0, e(0x6b900000)), + (22, 0x3a91d1, e(0x6ba00000)), + (22, 0x3a91d2, e(0x6bb00000)), + (22, 0x3a91d3, e(0x6bc00000)), + (22, 0x3a91d4, e(0x6bd00000)), + (22, 0x3a91d5, e(0x6be00000)), + (22, 0x3a91d6, e(0x6bf00000)), + (22, 0x3a91d7, e(0x6c000000)), + (22, 0x3a91d8, e(0x6c100000)), + (22, 0x3a91d9, e(0x6c200000)), + (22, 0x3a91da, e(0x6c300000)), + (22, 0x3a91db, e(0x6c400000)), + (22, 0x3a91dc, e(0x6c500000)), + (22, 0x3a91dd, e(0x6c600000)), + (22, 0x3a91de, e(0x6c700000)), + (22, 0x3a91df, e(0x6c800000)), + (22, 0x3a91e0, e(0x6c900000)), + (22, 0x3a91e1, e(0x6ca00000)), + (22, 0x3a91e2, e(0x6cb00000)), + (22, 0x3a91e3, e(0x6cc00000)), + (22, 0x3a91e4, e(0x6cd00000)), + (22, 0x3a91e5, e(0x6ce00000)), + (22, 0x3a91e6, e(0x6cf00000)), + (22, 0x3a91e7, e(0x6d000000)), + (22, 0x3a91e8, e(0x6d100000)), + (22, 0x3a91e9, e(0x6d200000)), + (22, 0x3a91ea, e(0x6d300000)), + (22, 0x3a91eb, e(0x6d400000)), + (22, 0x3a91ec, e(0x6d500000)), + (22, 0x3a91ed, e(0x6d600000)), + (22, 0x3a91ee, e(0x6d700000)), + (22, 0x3a91ef, e(0x6d800000)), + (22, 0x3a91f0, e(0x6d900000)), + (22, 0x3a91f1, e(0x6da00000)), + (22, 0x3a91f2, e(0x6db00000)), + (22, 0x3a91f3, e(0x6dc00000)), + (22, 0x3a91f4, e(0x6dd00000)), + (22, 0x3a91f5, e(0x6de00000)), + (22, 0x3a91f6, e(0x6df00000)), + (22, 0x3a91f7, e(0x6e000000)), + (22, 0x3a91f8, e(0x6e100000)), + (22, 0x3a91f9, e(0x6e200000)), + (22, 0x3a91fa, e(0x6e300000)), + (22, 0x3a91fb, e(0x6e400000)), + (22, 0x3a91fc, e(0x6e500000)), + (22, 0x3a91fd, e(0x6e600000)), + (22, 0x3a91fe, e(0x6e700000)), + (22, 0x3a91ff, e(0x6e800000)), + (22, 0x3a9200, e(0x6e900000)), + (22, 0x3a9201, e(0x6ea00000)), + (22, 0x3a9202, e(0x6eb00000)), + (22, 0x3a9203, e(0x6ec00000)), + (22, 0x3a9204, e(0x6ed00000)), + (22, 0x3a9205, e(0x6ee00000)), + (22, 0x3a9206, e(0x6ef00000)), + (22, 0x3a9207, e(0x6f000000)), + (22, 0x3a9208, e(0x6f100000)), + (22, 0x3a9209, e(0x6f200000)), + (22, 0x3a920a, e(0x6f300000)), + (22, 0x3a920b, e(0x6f400000)), + (22, 0x3a920c, e(0x6f500000)), + (22, 0x3a920d, e(0x6f600000)), + (22, 0x3a920e, e(0x6f700000)), + (22, 0x3a920f, e(0x6f800000)), + (22, 0x3a9210, e(0x6f900000)), + (22, 0x3a9211, e(0x6fa00000)), + (22, 0x3a9212, e(0x6fb00000)), + (22, 0x3a9213, e(0x6fc00000)), + (22, 0x3a9214, e(0x6fd00000)), + (22, 0x3a9215, e(0x6fe00000)), + (22, 0x3a9216, e(0x6ff00000)), + (22, 0x3a9217, e(0x70000000)), + (22, 0x3a9218, e(0x70100000)), + (22, 0x3a9219, e(0x70200000)), + (22, 0x3a921a, e(0x70300000)), + (22, 0x3a921b, e(0x70400000)), + (22, 0x3a921c, e(0x70500000)), + (22, 0x3a921d, e(0x70600000)), + (22, 0x3a921e, e(0x70700000)), + (22, 0x3a921f, e(0x70800000)), + (22, 0x3a9220, e(0x70900000)), + (22, 0x3a9221, e(0x70a00000)), + (22, 0x3a9222, e(0x70b00000)), + (22, 0x3a9223, e(0x70c00000)), + (22, 0x3a9224, e(0x70d00000)), + (22, 0x3a9225, e(0x70e00000)), + (22, 0x3a9226, e(0x70f00000)), + (22, 0x3a9227, e(0x71000000)), + (22, 0x3a9228, e(0x71100000)), + (22, 0x3a9229, e(0x71200000)), + (22, 0x3a922a, e(0x71300000)), + (22, 0x3a922b, e(0x71400000)), + (22, 0x3a922c, e(0x71500000)), + (22, 0x3a922d, e(0x71600000)), + (22, 0x3a922e, e(0x71700000)), + (22, 0x3a922f, e(0x71800000)), + (22, 0x3a9230, e(0x71900000)), + (22, 0x3a9231, e(0x71a00000)), + (22, 0x3a9232, e(0x71b00000)), + (22, 0x3a9233, e(0x71c00000)), + (22, 0x3a9234, e(0x71d00000)), + (22, 0x3a9235, e(0x71e00000)), + (22, 0x3a9236, e(0x71f00000)), + (22, 0x3a9237, e(0x72000000)), + (22, 0x3a9238, e(0x72100000)), + (22, 0x3a9239, e(0x72200000)), + (22, 0x3a923a, e(0x72300000)), + (22, 0x3a923b, e(0x72400000)), + (22, 0x3a923c, e(0x72500000)), + (22, 0x3a923d, e(0x72600000)), + (22, 0x3a923e, e(0x72700000)), + (22, 0x3a923f, e(0x72800000)), + (22, 0x3a9240, e(0x72900000)), + (22, 0x3a9241, e(0x72a00000)), + (22, 0x3a9242, e(0x72b00000)), + (22, 0x3a9243, e(0x72c00000)), + (22, 0x3a9244, e(0x72d00000)), + (22, 0x3a9245, e(0x72e00000)), + (22, 0x3a9246, e(0x72f00000)), + (22, 0x3a9247, e(0x73000000)), + (22, 0x3a9248, e(0x73100000)), + (22, 0x3a9249, e(0x73200000)), + (22, 0x3a924a, e(0x73300000)), + (22, 0x3a924b, e(0x73400000)), + (22, 0x3a924c, e(0x73500000)), + (22, 0x3a924d, e(0x73600000)), + (22, 0x3a924e, e(0x73700000)), + (22, 0x3a924f, e(0x73800000)), + (22, 0x3a9250, e(0x73900000)), + (22, 0x3a9251, e(0x73a00000)), + (22, 0x3a9252, e(0x73b00000)), + (22, 0x3a9253, e(0x73c00000)), + (22, 0x3a9254, e(0x73d00000)), + (22, 0x3a9255, e(0x73e00000)), + (22, 0x3a9256, e(0x73f00000)), + (22, 0x3a9257, e(0x74000000)), + (22, 0x3a9258, e(0x74100000)), + (22, 0x3a9259, e(0x74200000)), + (22, 0x3a925a, e(0x74300000)), + (22, 0x3a925b, e(0x74400000)), + (22, 0x3a925c, e(0x74500000)), + (22, 0x3a925d, e(0x74600000)), + (22, 0x3a925e, e(0x74700000)), + (22, 0x3a925f, e(0x74800000)), + (22, 0x3a9260, e(0x74900000)), + (22, 0x3a9261, e(0x74a00000)), + (22, 0x3a9262, e(0x74b00000)), + (22, 0x3a9263, e(0x74c00000)), + (22, 0x3a9264, e(0x74d00000)), + (22, 0x3a9265, e(0x74e00000)), + (22, 0x3a9266, e(0x74f00000)), + (22, 0x3a9267, e(0x75000000)), + (22, 0x3a9268, e(0x75100000)), + (22, 0x3a9269, e(0x75200000)), + (22, 0x3a926a, e(0x75300000)), + (22, 0x3a926b, e(0x75400000)), + (22, 0x3a926c, e(0x75500000)), + (22, 0x3a926d, e(0x75600000)), + (22, 0x3a926e, e(0x75700000)), + (22, 0x3a926f, e(0x75800000)), + (22, 0x3a9270, e(0x75900000)), + (22, 0x3a9271, e(0x75a00000)), + (22, 0x3a9272, e(0x75b00000)), + (22, 0x3a9273, e(0x75c00000)), + (22, 0x3a9274, e(0x75d00000)), + (22, 0x3a9275, e(0x75e00000)), + (22, 0x3a9276, e(0x75f00000)), + (22, 0x3a9277, e(0x76000000)), + (22, 0x3a9278, e(0x76100000)), + (22, 0x3a9279, e(0x76200000)), + (22, 0x3a927a, e(0x76300000)), + (22, 0x3a927b, e(0x76400000)), + (22, 0x3a927c, e(0x76500000)), + (22, 0x3a927d, e(0x76600000)), + (22, 0x3a927e, e(0x76700000)), + (22, 0x3a927f, e(0x76800000)), + (22, 0x3a9280, e(0x76900000)), + (22, 0x3a9281, e(0x76a00000)), + (22, 0x3a9282, e(0x76b00000)), + (22, 0x3a9283, e(0x76c00000)), + (22, 0x3a9284, e(0x76d00000)), + (22, 0x3a9285, e(0x76e00000)), + (22, 0x3a9286, e(0x76f00000)), + (22, 0x3a9287, e(0x77000000)), + (22, 0x3a9288, e(0x77100000)), + (22, 0x3a9289, e(0x77200000)), + (22, 0x3a928a, e(0x77300000)), + (22, 0x3a928b, e(0x77400000)), + (22, 0x3a928c, e(0x77500000)), + (22, 0x3a928d, e(0x77600000)), + (22, 0x3a928e, e(0x77700000)), + (22, 0x3a928f, e(0x77800000)), + (22, 0x3a9290, e(0x77900000)), + (22, 0x3a9291, e(0x77a00000)), + (22, 0x3a9292, e(0x77b00000)), + (22, 0x3a9293, e(0x77c00000)), + (22, 0x3a9294, e(0x77d00000)), + (22, 0x3a9295, e(0x77e00000)), + (22, 0x3a9296, e(0x77f00000)), + (22, 0x3a9297, e(0x78000000)), + (22, 0x3a9298, e(0x78100000)), + (22, 0x3a9299, e(0x78200000)), + (22, 0x3a929a, e(0x78300000)), + (22, 0x3a929b, e(0x78400000)), + (22, 0x3a929c, e(0x78500000)), + (22, 0x3a929d, e(0x78600000)), + (22, 0x3a929e, e(0x78700000)), + (22, 0x3a929f, e(0x78800000)), + (22, 0x3a92a0, e(0x78900000)), + (22, 0x3a92a1, e(0x78a00000)), + (22, 0x3a92a2, e(0x78b00000)), + (22, 0x3a92a3, e(0x78c00000)), + (22, 0x3a92a4, e(0x78d00000)), + (22, 0x3a92a5, e(0x78e00000)), + (22, 0x3a92a6, e(0x78f00000)), + (22, 0x3a92a7, e(0x79000000)), + (22, 0x3a92a8, e(0x79100000)), + (22, 0x3a92a9, e(0x79200000)), + (22, 0x3a92aa, e(0x79300000)), + (22, 0x3a92ab, e(0x79400000)), + (22, 0x3a92ac, e(0x79500000)), + (22, 0x3a92ad, e(0x79600000)), + (22, 0x3a92ae, e(0x79700000)), + (22, 0x3a92af, e(0x79800000)), + (22, 0x3a92b0, e(0x79900000)), + (22, 0x3a92b1, e(0x79a00000)), + (22, 0x3a92b2, e(0x79b00000)), + (22, 0x3a92b3, e(0x79c00000)), + (22, 0x3a92b4, e(0x79d00000)), + (22, 0x3a92b5, e(0x79e00000)), + (22, 0x3a92b6, e(0x79f00000)), + (22, 0x3a92b7, e(0x7a000000)), + (22, 0x3a92b8, e(0x7a100000)), + (22, 0x3a92b9, e(0x7a200000)), + (22, 0x3a92ba, e(0x7a300000)), + (22, 0x3a92bb, e(0x7a400000)), + (22, 0x3a92bc, e(0x7a500000)), + (22, 0x3a92bd, e(0x7a600000)), + (22, 0x3a92be, e(0x7a700000)), + (22, 0x3a92bf, e(0x7a800000)), + (22, 0x3a92c0, e(0x7a900000)), + (22, 0x3a92c1, e(0x7aa00000)), + (22, 0x3a92c2, e(0x7ab00000)), + (22, 0x3a92c3, e(0x7ac00000)), + (22, 0x3a92c4, e(0x7ad00000)), + (22, 0x3a92c5, e(0x7ae00000)), + (22, 0x3a92c6, e(0x7af00000)), + (22, 0x3a92c7, e(0x7b000000)), + (22, 0x3a92c8, e(0x7b100000)), + (22, 0x3a92c9, e(0x7b200000)), + (22, 0x3a92ca, e(0x7b300000)), + (22, 0x3a92cb, e(0x7b400000)), + (22, 0x3a92cc, e(0x7b500000)), + (22, 0x3a92cd, e(0x7b600000)), + (22, 0x3a92ce, e(0x7b700000)), + (22, 0x3a92cf, e(0x7b800000)), + (22, 0x3a92d0, e(0x7b900000)), + (22, 0x3a92d1, e(0x7ba00000)), + (22, 0x3a92d2, e(0x7bb00000)), + (22, 0x3a92d3, e(0x7bc00000)), + (22, 0x3a92d4, e(0x7bd00000)), + (22, 0x3a92d5, e(0x7be00000)), + (22, 0x3a92d6, e(0x7bf00000)), + (22, 0x3a92d7, e(0x7c000000)), + (22, 0x3a92d8, e(0x7c100000)), + (22, 0x3a92d9, e(0x7c200000)), + (22, 0x3a92da, e(0x7c300000)), + (22, 0x3a92db, e(0x7c400000)), + (22, 0x3a92dc, e(0x7c500000)), + (22, 0x3a92dd, e(0x7c600000)), + (22, 0x3a92de, e(0x7c700000)), + (22, 0x3a92df, e(0x7c800000)), + (22, 0x3a92e0, e(0x7c900000)), + (22, 0x3a92e1, e(0x7ca00000)), + (22, 0x3a92e2, e(0x7cb00000)), + (22, 0x3a92e3, e(0x7cc00000)), + (22, 0x3a92e4, e(0x7cd00000)), + (22, 0x3a92e5, e(0x7ce00000)), + (22, 0x3a92e6, e(0x7cf00000)), + (22, 0x3a92e7, e(0x7d000000)), + (22, 0x3a92e8, e(0x7d100000)), + (22, 0x3a92e9, e(0x7d200000)), + (22, 0x3a92ea, e(0x7d300000)), + (22, 0x3a92eb, e(0x7d400000)), + (22, 0x3a92ec, e(0x7d500000)), + (22, 0x3a92ed, e(0x7d600000)), + (22, 0x3a92ee, e(0x7d700000)), + (22, 0x3a92ef, e(0x7d800000)), + (22, 0x3a92f0, e(0x7d900000)), + (22, 0x3a92f1, e(0x7da00000)), + (22, 0x3a92f2, e(0x7db00000)), + (22, 0x3a92f3, e(0x7dc00000)), + (22, 0x3a92f4, e(0x7dd00000)), + (22, 0x3a92f5, e(0x7de00000)), + (22, 0x3a92f6, e(0x7df00000)), + (22, 0x3a92f7, e(0x7e000000)), + (22, 0x3a92f8, e(0x7e100000)), + (22, 0x3a92f9, e(0x7e200000)), + (22, 0x3a92fa, e(0x7e300000)), + (22, 0x3a92fb, e(0x7e400000)), + (22, 0x3a92fc, e(0x7e500000)), + (22, 0x3a92fd, e(0x7e600000)), + (22, 0x3a92fe, e(0x7e700000)), + (22, 0x3a92ff, e(0x7e800000)), + (22, 0x3a95a0, e(0x7e900000)), + (22, 0x3a95a1, e(0x7ea00000)), + (22, 0x3a95a2, e(0x7eb00000)), + (22, 0x3a95a3, e(0x7ec00000)), + (22, 0x3a95a4, e(0x7ed00000)), + (22, 0x3a95a5, e(0x7ee00000)), + (22, 0x3a95a6, e(0x7ef00000)), + (22, 0x3a95a7, e(0x7f000000)), + (22, 0x3a95a8, e(0x7f100000)), + (22, 0x3a95a9, e(0x7f200000)), + (22, 0x3a95aa, e(0x7f300000)), + (22, 0x3a95ab, e(0x7f400000)), + (22, 0x3a95ac, e(0x7f500000)), + (22, 0x3a95ad, e(0x7f600000)), + (22, 0x3a95ae, e(0x7f700000)), + (22, 0x3a95af, e(0x7f800000)), + (22, 0x3b8fe0, e(0x7f900000)), + (22, 0x3b8fe1, e(0x7fa00000)), + (22, 0x3b8fe2, e(0x7fb00000)), + (22, 0x3b8fe3, e(0x7fc00000)), + (21, 0x68e90, e(0x7fd00000)), + (21, 0x68e91, e(0x7fe00000)), + (21, 0x68e98, e(0x7ff80000)), +]; diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs index a94a16dc9a..b4a2ff352b 100644 --- a/crates/prc/src/bits.rs +++ b/crates/prc/src/bits.rs @@ -281,7 +281,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn zero_is_the_two_bit_code_with_no_sign() { let mut w = W::default(); w.put(0b01, 2).put(0b1111_1111, 8); @@ -292,7 +291,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn exponent_with_literal_mantissa() { let v = -1234.5678f64; let le = v.to_bits().to_le_bytes(); @@ -307,7 +305,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn exponent_without_mantissa_is_a_power_of_two() { let (n, c) = exponent_code(1023 + 3); let mut w = W::default(); @@ -316,7 +313,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn fill_back_reference_and_literal_low_byte() { // Mantissa bytes 6..0 = 0x05, 0xAA, 0xAA (offset 1), 0xAA (offset 2), fill 0xAA to byte 1, byte 0 = 0x11. let (n, c) = exponent_code(1023); @@ -336,7 +332,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn a_back_reference_past_the_value_is_refused() { let (n, c) = exponent_code(1023); let mut w = W::default(); @@ -346,10 +341,10 @@ mod tests { } #[test] - fn without_the_table_a_double_is_an_error_not_a_panic() { - let mut r = BitReader::new(&[0x40, 0, 0]); - assert_eq!(r.double(), Err(PrcError::UnknownDoubleCode)); - assert!(matches!(BitReader::new(&[0x40]).double(), Err(PrcError::Truncated(_)))); + fn a_truncated_double_is_an_error_not_a_panic() { + // No code is 8 bits or shorter and a prefix of 0x17, so one byte ends inside a code. + assert!(matches!(BitReader::new(&[0x17]).double(), Err(PrcError::Truncated(_)))); + assert!(matches!(BitReader::new(&[]).double(), Err(PrcError::Truncated(_)))); } #[test] diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs index a93554334b..686cc8e056 100644 --- a/crates/prc/src/schema.rs +++ b/crates/prc/src/schema.rs @@ -361,7 +361,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn data_tokens_consume_their_fields() { let mut d = W::default(); d.bit(true).double(1.5).uint(300).int(-7).string(Some("ab")).double(2.0).double(3.0); @@ -373,7 +372,6 @@ mod tests { } #[test] - #[ignore = "the Double table is not in the crate yet"] fn vector_extent_and_box_tokens_read_their_doubles() { // Vec3, Extent1D, Extent2D, BBox: 3 + 2 + 4 + 6 Doubles. let mut d = W::default(); From 7f302667900b2f3a33b672dd491cadbe0e606a2d Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:34:43 -0400 Subject: [PATCH 150/562] M12: PRC reader: tessellation and arrays (#1297) Read the tessellation section of a PRC file structure: FileStructure::tessellations() returns each entity as a Tessellation. - TESS_3D becomes a TriangleMesh: positions, triangles from the triangle/fan/strip blocks (one-normal and textured variants included), one triangle range per face, stored normals and texture coordinates with per-corner indices, and per-triangle line-attribute styles. - TESS_3D_Wire becomes polylines (closing and continuous flags); vertex colours are read past, sized by points (closing point included). - TESS_Markup is read past. - TESS_3D_Compressed is read field by field to its end so the entities after it decode; its triangles are not rebuilt (later step). - The compressed Character/Short/Integer/Indice arrays and the Huffman container they use (WD 11.6-11.9, 12.2) live in arrays.rs; the base fields every entity shares (attributes, name, user data) in entity.rs. Never-panic hardening beyond the source this is adapted from: - Huffman symbol width must be 1..=16; the leaf table and element count are bounded by the payload before anything is allocated. - A face may carry at most 8 texture-coordinate sets (an unbounded count allocated per set and padded per triangle). - Every count is checked against the remaining bits; indice sums and deltas use checked arithmetic. A few reading choices (Huffman bit order and code-length width, indice running sums, 64-bit attribute order, three version gates, the is_segment_color bit, the compressed field order) follow prc-rs (MIT); each is marked `// prc-rs:` in the source. Tests are synthetic, written with the crate's test bit writer. Each fix was reverted and its test seen failing (17 reversions). Adapted from pdfcer (MIT, same author), pdfcer-3d at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc/README.md | 9 +- crates/prc/src/arrays.rs | 336 +++++++++++++++++ crates/prc/src/entity.rs | 121 ++++++ crates/prc/src/lib.rs | 4 + crates/prc/src/tess.rs | 419 +++++++++++++++++++++ crates/prc/src/tess/tests.rs | 581 +++++++++++++++++++++++++++++ crates/prc/src/tess/triangulate.rs | 258 +++++++++++++ crates/prc/src/testw.rs | 65 ++++ 8 files changed, 1792 insertions(+), 1 deletion(-) create mode 100644 crates/prc/src/arrays.rs create mode 100644 crates/prc/src/entity.rs create mode 100644 crates/prc/src/tess.rs create mode 100644 crates/prc/src/tess/tests.rs create mode 100644 crates/prc/src/tess/triangulate.rs diff --git a/crates/prc/README.md b/crates/prc/README.md index edb67bdad8..cb3865d3fd 100644 --- a/crates/prc/README.md +++ b/crates/prc/README.md @@ -13,6 +13,7 @@ PrcFile::parse(data) -> Result // header, fil PrcFile::parse_with_limit(data, max_inflated) -> Result FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry FileStructure::schema() -> Result // the globals section's leading schema +FileStructure::tessellations() -> Result, PrcError> // meshes, wires, markups, compressed meshes Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double ``` @@ -20,5 +21,11 @@ BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_ - `MAX_INFLATED_BYTES` (512 MiB) bounds every section together; `TooLarge` reports the limit hit. - Schema programs (how a newer producer describes fields it added) run under a step ceiling (2^22) and a nesting ceiling (64). - `Unsupported` names constructs the reader cannot step over (schema pointer fields, `Value_CurveIs3D`). -- Not yet in the crate: the product tree and tessellation readers. +- `Tessellation::Mesh` is a `TriangleMesh`: positions, triangles, one triangle range per face, the stored normals and + texture coordinates with each triangle's corner indices into them, and per-triangle line-attribute styles. + `Wire` is polylines; `Markup` is read past. +- `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet. +- A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`. +- Not yet in the crate: the product tree and rebuilding compressed meshes. - The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. + A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source. diff --git a/crates/prc/src/arrays.rs b/crates/prc/src/arrays.rs new file mode 100644 index 0000000000..da6c52521b --- /dev/null +++ b/crates/prc/src/arrays.rs @@ -0,0 +1,336 @@ +//! The array primitives of compressed tessellation [WD 11.6-11.9] and the Huffman payload +//! they may carry [WD 12.2]. +//! +//! The WD gives only the Huffman writer. The reader decodes what producers write: each +//! leaf's code length is read in the width the payload's 8-bit field states (the WD writes a +//! fixed 8 bits), stored codes carry a leading root bit, and the payload is read LSB-first +//! although the stream around it is MSB-first. + +use std::collections::HashMap; + +use crate::PrcError; +use crate::bits::BitReader; + +/// Longest Huffman code [WD 12.2]. +const MAX_CODE_BITS: u32 = 32; + +/// Widest Huffman symbol a caller asks for: the arrays hold 2- to 16-bit values. +const MAX_SYMBOL_BITS: u32 = 16; + +fn malformed(what: &str) -> PrcError { + PrcError::Malformed(what.to_owned()) +} + +/// An LSB-first cursor over a Huffman payload. +struct Lsb<'a> { + data: &'a [u8], + pos: usize, + end: usize, +} + +impl Lsb<'_> { + fn bit(&mut self) -> Result { + if self.pos >= self.end { + return Err(PrcError::Truncated("a Huffman payload")); + } + let byte = self.data.get(self.pos / 8).ok_or(PrcError::Truncated("a Huffman payload"))?; + let b = u32::from((byte >> (self.pos % 8)) & 1); + self.pos += 1; + Ok(b) + } + + /// `n` ≤ 32 bits, the first read being the least significant. + fn bits(&mut self, n: u32) -> Result { + let mut v = 0u32; + for i in 0..n.min(32) { + v |= self.bit()? << i; + } + Ok(v) + } + + fn remaining(&self) -> usize { + self.end.saturating_sub(self.pos) + } +} + +/// The low `n` (1..=16) bits of `v` as a symbol, two's complement when `signed`. +fn symbol(v: u32, n: u32, signed: bool) -> i32 { + let v = i64::from(v); + let wide = if signed && n > 0 && v & (1 << (n - 1)) != 0 { v - (1i64 << n) } else { v }; + // n <= 16, so `wide` is within ±2^16. + i32::try_from(wide).unwrap_or(0) +} + +/// The Huffman container of a compressed Character/Short array [WD 11.6, 11.7, 12.2]: +/// symbols are `nbits` wide, sign-extended when `signed`. +fn huffman(r: &mut BitReader<'_>, nbits: u32, signed: bool) -> Result, PrcError> { + if nbits == 0 || nbits > MAX_SYMBOL_BITS { + return Err(malformed("Huffman symbol width")); + } + let words = r.unsigned_integer()? as usize; + if words == 0 { + return Ok(Vec::new()); + } + if words.saturating_mul(32) > r.remaining() { + return Err(PrcError::Truncated("a Huffman payload")); + } + // words * 32 bits remain, so words * 4 bytes cannot overflow. + let blob = (0..words * 4).map(|_| r.character()).collect::, _>>()?; + let last = r.unsigned_integer()?; + if last > 32 { + return Err(malformed("Huffman last-word bit count above 32")); + } + let end = (words - 1) * 32 + last as usize; + let mut b = Lsb { data: &blob, pos: 0, end }; + let codes = huffman_leaves(&mut b, nbits, signed)?; + if codes.is_empty() { + return Ok(Vec::new()); + } + let count = b.bits(32)? as usize; + // Every element is at least one bit. + if count > b.remaining() { + return Err(PrcError::Truncated("a Huffman element count")); + } + let mut out = Vec::with_capacity(count); + for _ in 0..count { + out.push(huffman_symbol(&mut b, &codes)?); + } + Ok(out) +} + +/// The leaf table: `(code length, stored code)` to symbol. +fn huffman_leaves(b: &mut Lsb<'_>, nbits: u32, signed: bool) -> Result, PrcError> { + let leaves = b.bits(nbits + 1)?; + if leaves == 0 { + return Ok(HashMap::new()); + } + // prc-rs: the code-length width is read from the payload, not fixed at 8 bits. + let len_width = b.bits(8)?; + if len_width == 0 || len_width > MAX_CODE_BITS { + return Err(malformed("Huffman code-length width")); + } + // Every leaf is at least nbits + 2 bits, so the payload bounds the table. + if (leaves as usize).saturating_mul((nbits + 2) as usize) > b.remaining() { + return Err(PrcError::Truncated("a Huffman payload")); + } + let mut codes = HashMap::new(); + for _ in 0..leaves { + let s = symbol(b.bits(nbits)?, nbits, signed); + let len = b.bits(len_width)?; + if len == 0 || len > MAX_CODE_BITS { + return Err(malformed("Huffman code length")); + } + let code = b.bits(len)?; + if codes.insert((len, code), s).is_some() { + return Err(malformed("duplicate Huffman code")); + } + } + Ok(codes) +} + +/// One element: code bits, most significant first, until a leaf matches. +fn huffman_symbol(b: &mut Lsb<'_>, codes: &HashMap<(u32, u32), i32>) -> Result { + // prc-rs: the stored code's first bit read is its most significant (the root bit). + let (mut code, mut len) = (0u32, 0u32); + while len < MAX_CODE_BITS { + code = (code << 1) | b.bit()?; + len += 1; + if let Some(&s) = codes.get(&(len, code)) { + return Ok(s); + } + } + Err(malformed("a Huffman code matching no leaf")) +} + +/// A `CharacterArray(nbits)` [WD 11.6]. `compressed` is `None` when the flag is written, +/// else the caller's implicit value. Uncompressed elements are whole bytes, sign-extended +/// from 8 bits when `signed`. +pub(crate) fn character_array(r: &mut BitReader<'_>, nbits: u32, compressed: Option, signed: bool) -> Result, PrcError> { + let compressed = match compressed { + Some(c) => c, + None => r.bit()?, + }; + if compressed { + return huffman(r, nbits, signed); + } + let n = r.unsigned_integer()? as usize; + if n.saturating_mul(8) > r.remaining() { + return Err(PrcError::Truncated("a character array")); + } + (0..n) + .map(|_| { + let c = r.character()?; + Ok(if signed { i32::from(c.cast_signed()) } else { i32::from(c) }) + }) + .collect() +} + +/// A `ShortArray(nbits)` [WD 11.7]: uncompressed elements are a low then a high byte, read +/// unsigned. +pub(crate) fn short_array(r: &mut BitReader<'_>, nbits: u32) -> Result, PrcError> { + if r.bit()? { + return huffman(r, nbits, false); + } + let n = r.unsigned_integer()? as usize; + if n.saturating_mul(16) > r.remaining() { + return Err(PrcError::Truncated("a short array")); + } + (0..n) + .map(|_| { + let lo = r.character()?; + let hi = r.character()?; + Ok(i32::from(u16::from_le_bytes([lo, hi]))) + }) + .collect() +} + +/// A `CompressedIntegerArray` [WD 11.8]: a CharacterArray(6) of widths, then one +/// `IntegerWithVariableBitNumber` per width. +pub(crate) fn compressed_integer_array(r: &mut BitReader<'_>) -> Result, PrcError> { + let widths = character_array(r, 6, None, false)?; + widths + .into_iter() + .map(|w| { + let w = u32::try_from(w).map_err(|_| malformed("integer width"))?; + r.integer_vbn(w) + }) + .collect() +} + +/// A `CompressedIndiceArray` [WD 11.9], read as producers write it: the stored signed 6-bit +/// values are width differences summed into each element's width, and each element is a +/// delta from the previous (the WD's own reading desyncs from the third element). Indices +/// are never negative. +pub(crate) fn compressed_indice_array(r: &mut BitReader<'_>, compressed: Option) -> Result, PrcError> { + let diffs = character_array(r, 6, compressed, true)?; + let mut out = Vec::with_capacity(diffs.len()); + let (mut width, mut prev) = (0i64, 0i64); + for d in diffs { + // prc-rs: widths are running sums of the stored differences, values running deltas. + width = width.checked_add(i64::from(d)).ok_or_else(|| malformed("indice width"))?; + let w = u32::try_from(width).map_err(|_| malformed("indice width"))?; + let v = prev.checked_add(r.integer_vbn(w)?).ok_or_else(|| malformed("indice delta"))?; + out.push(u32::try_from(v).map_err(|_| malformed("an index outside 0..2^32"))?); + prev = v; + } + Ok(out) +} + +/// An `UncompressedBoolArray` of `n` raw bits (no length prefix). +pub(crate) fn bool_array(r: &mut BitReader<'_>, n: usize) -> Result, PrcError> { + if n > r.remaining() { + return Err(PrcError::Truncated("a boolean array")); + } + (0..n).map(|_| r.bit()).collect() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use crate::testw::W; + + fn read(w: &W, f: impl FnOnce(&mut BitReader<'_>) -> Result) -> T { + let bytes = w.bytes(); + let mut r = BitReader::new(&bytes); + let v = f(&mut r).unwrap(); + assert_eq!(r.position(), w.len(), "bits consumed"); + v + } + + #[test] + fn huffman_decodes_the_wd_example_with_root_bits() { + // WD 12.2's input {1,3,5,7,7,9,5,3,3}; codes carry the root bit. + let leaves = [(3, 0b100, 3), (7, 0b101, 3), (5, 0b110, 3), (1, 0b1110, 4), (9, 0b1111, 4)]; + let values = [1, 3, 5, 7, 7, 9, 5, 3, 3]; + let mut w = W::default(); + w.bit(true).huffman(4, 3, &leaves, &values); + assert_eq!(read(&w, |r| character_array(r, 4, None, false)), values); + } + + #[test] + fn huffman_symbols_sign_extend_only_when_signed() { + let leaves = [(0b111110, 0b10, 2), (1, 0b11, 2)]; + let mut w = W::default(); + w.huffman(6, 2, &leaves, &[62, 1, 62]); + assert_eq!(read(&w, |r| huffman(r, 6, true)), [-2, 1, -2]); + assert_eq!(read(&w, |r| huffman(r, 6, false)), [62, 1, 62]); + } + + #[test] + fn huffman_rejects_codes_that_match_no_leaf_and_empty_payloads_are_empty() { + let mut bad = W::default(); + bad.huffman_raw(2, 2, &[(1, 0b10, 2)], &[(0b11, 2)]); + let bytes = bad.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Malformed(_) | PrcError::Truncated(_)))); + let mut empty = W::default(); + empty.uint(0); + assert!(read(&empty, |r| huffman(r, 2, false)).is_empty()); + } + + #[test] + fn a_huffman_element_count_past_the_payload_is_truncated_not_allocated() { + // One leaf, then an element count of 2^32 - 1 with no elements behind it. + let mut w = W::default(); + w.huffman_count(2, 2, &[(1, 0b10, 2)], u32::MAX); + let bytes = w.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), 2, false), Err(PrcError::Truncated("a Huffman element count")))); + } + + #[test] + fn huffman_widths_out_of_range_are_errors() { + // Well-formed payloads at each width, which only the width check refuses. + for nbits in [0, 17] { + let mut w = W::default(); + w.huffman(nbits, 2, &[(1, 0b10, 2)], &[1]); + let bytes = w.bytes(); + assert!(matches!(huffman(&mut BitReader::new(&bytes), nbits, false), Err(PrcError::Malformed(_))), "{nbits}"); + } + } + + #[test] + fn uncompressed_arrays() { + let mut w = W::default(); + w.bit(false).uint(2).put(0xfe, 8).put(3, 8); + assert_eq!(read(&w, |r| character_array(r, 6, None, true)), [-2, 3]); + let mut s = W::default(); + s.bit(false).uint(1).put(0x34, 8).put(0x12, 8); + assert_eq!(read(&s, |r| short_array(r, 16)), [0x1234]); + } + + #[test] + fn integer_array_reads_one_width_per_value() { + let mut w = W::default(); + w.bit(false).uint(3).put(3, 8).put(0, 8).put(4, 8); + w.bit(true).put(3, 2); // -3 in 3 bits + w.bit(false).put(5, 3); // 5 in 4 bits + assert_eq!(read(&w, compressed_integer_array), [-3, 0, 5]); + } + + #[test] + fn indice_array_sums_widths_and_deltas() { + // Values 5, 6, 4: widths 4, 2, 3 stored as diffs 4, -2, +1. + let mut w = W::default(); + w.bit(false).uint(3).put(4, 8).put(0xfe, 8).put(1, 8); + w.bit(false).put(5, 3); // 5 + w.bit(false).put(1, 1); // +1 + w.bit(true).put(2, 2); // -2 + assert_eq!(read(&w, |r| compressed_indice_array(r, None)), [5, 6, 4]); + // The implicit-flag form reads no flag bit. + let mut i = W::default(); + i.uint(1).put(2, 8).bit(false).put(1, 1); + assert_eq!(read(&i, |r| compressed_indice_array(r, Some(false))), [1]); + // A negative running index is damage. + let mut n = W::default(); + n.bit(false).uint(1).put(2, 8).bit(true).put(1, 1); + let bytes = n.bytes(); + assert!(compressed_indice_array(&mut BitReader::new(&bytes), None).is_err()); + } + + #[test] + fn a_bool_array_longer_than_the_data_is_truncated() { + assert!(matches!(bool_array(&mut BitReader::new(&[0]), 9), Err(PrcError::Truncated(_)))); + assert_eq!(bool_array(&mut BitReader::new(&[0x80]), 2).unwrap(), [true, false]); + } +} diff --git a/crates/prc/src/entity.rs b/crates/prc/src/entity.rs new file mode 100644 index 0000000000..e6f488c8f8 --- /dev/null +++ b/crates/prc/src/entity.rs @@ -0,0 +1,121 @@ +//! What every section reader shares: the cursor, the schema, the authoring version, and the +//! base fields every PRC entity starts with [WD 7.2.3, 7.4.3, 8.6]. + +use crate::PrcError; +use crate::bits::BitReader; +use crate::schema::Schema; + +const ATTRIBUTE: u32 = 201; + +/// A [`PrcError::Malformed`] naming `what`. +pub(crate) fn malformed(what: String) -> PrcError { + PrcError::Malformed(what) +} + +/// A section reader. +pub(crate) struct Ctx<'a, 's> { + pub(crate) r: BitReader<'a>, + pub(crate) schema: &'s Schema, + /// The file structure's authoring version; gates version-added fields. + pub(crate) version: u32, + /// The current name: what a `same_name` entity reuses; every entity's own name replaces + /// it [WD 7.2.3.4]. Each section starts with none. + pub(crate) name: Option, +} + +impl<'a, 's> Ctx<'a, 's> { + pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self { + Ctx { r, schema, version, name: None } + } + + /// Reads an entity type code; any value but `want` is malformed. + pub(crate) fn expect_type(&mut self, want: u32) -> Result<(), PrcError> { + let t = self.r.unsigned_integer()?; + if t != want { + return Err(malformed(format!("entity type {t} where {want} belongs"))); + } + Ok(()) + } + + /// A count that must fit in the remaining data at `min_bits` per item, which bounds any + /// allocation sized by it. + pub(crate) fn count(&mut self, min_bits: usize, what: &'static str) -> Result { + let n = self.r.unsigned_integer()? as usize; + if n.saturating_mul(min_bits) > self.r.remaining() { + return Err(PrcError::Truncated(what)); + } + Ok(n) + } + + /// A counted array of `UnsignedInteger`s. + pub(crate) fn uints(&mut self, what: &'static str) -> Result, PrcError> { + let n = self.count(1, what)?; + (0..n).map(|_| self.r.unsigned_integer()).collect() + } + + /// A counted array of `Double`s. + pub(crate) fn doubles(&mut self, what: &'static str) -> Result, PrcError> { + let n = self.count(2, what)?; + (0..n).map(|_| self.r.double()).collect() + } + + /// `AttributeEntry` [WD 7.4.3]: a predefined title code or a string. + fn attribute_entry(&mut self) -> Result<(), PrcError> { + if self.r.bit()? { + self.r.unsigned_integer()?; + } else { + self.r.string()?; + } + Ok(()) + } + + /// One attribute value [WD 7.4.3], read past. + fn attribute_value(&mut self) -> Result<(), PrcError> { + match self.r.unsigned_integer()? { + 0 => {} + 1 | 3 => { + self.r.integer()?; + } + 2 => { + self.r.double()?; + } + 4 => { + self.r.string()?; + } + // prc-rs: a 64-bit integer is its high part, then its low part. + 5 => { + self.r.integer()?; + self.r.unsigned_integer()?; + } + k => return Err(malformed(format!("attribute value kind {k}"))), + } + Ok(()) + } + + /// `ContentPRCBase` [WD 7.2.3]: attributes, then the name; returns the entity's name, + /// which is also the current name afterwards. + pub(crate) fn content_prc_base(&mut self) -> Result, PrcError> { + let n = self.count(1, "attributes")?; + for _ in 0..n { + self.expect_type(ATTRIBUTE)?; + self.attribute_entry()?; + let pairs = self.count(2, "attribute pairs")?; + for _ in 0..pairs { + self.attribute_entry()?; + self.attribute_value()?; + } + self.schema.skip_added_fields(ATTRIBUTE, &mut self.r)?; + } + // `same_name` TRUE reuses the current name [WD 7.2.3.4]. + if !self.r.bit()? { + self.name = self.r.string()?; + } + Ok(self.name.clone()) + } + + /// `UserData` [WD 8.6]: a bit count, then that many opaque bits. + pub(crate) fn user_data(&mut self) -> Result<(), PrcError> { + let n = self.r.unsigned_integer()? as usize; + self.r.skip_bits(n) + } +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs index 1a62f9f272..fd49302234 100644 --- a/crates/prc/src/lib.rs +++ b/crates/prc/src/lib.rs @@ -13,10 +13,13 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod acof; +mod arrays; mod bits; mod container; +mod entity; mod error; mod schema; +mod tess; #[cfg(test)] mod testw; @@ -24,3 +27,4 @@ pub use bits::BitReader; pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; pub use error::PrcError; pub use schema::Schema; +pub use tess::{Tessellation, TriangleMesh, TriangleStyle}; diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs new file mode 100644 index 0000000000..42704de652 --- /dev/null +++ b/crates/prc/src/tess.rs @@ -0,0 +1,419 @@ +//! The tessellation section: `FileStructureTessellation` (305) and its entities, `TESS_3D` +//! (172) with its `TESS_Face`s (174), `TESS_3D_Wire` (175), `TESS_Markup` (176) and +//! `TESS_3D_Compressed` (173) [WD 7.3.7, 7.8]. +//! +//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7] so the entities after +//! it decode; its triangles are not rebuilt. +//! +//! The schema runs after the fields of each concrete type read here (201, 172, 173, 174, 175, +//! 176, 305), not for the abstract levels (`PRCBase`, `TESS`). + +mod triangulate; + +use crate::arrays; +use crate::bits::BitReader; +use crate::entity::{Ctx, malformed}; +use crate::{FileStructure, PrcError, SectionKind}; +use triangulate::{Corners, Face}; + +const FILE_STRUCTURE_TESSELLATION: u32 = 305; +const TESS_3D: u32 = 172; +const TESS_3D_COMPRESSED: u32 = 173; +const TESS_FACE: u32 = 174; +const TESS_3D_WIRE: u32 = 175; +const TESS_MARKUP: u32 = 176; + +/// prc-rs: `origin` exists in a compressed tessellation from this version (pdf-issues #705). +const ORIGIN_FROM: u32 = 7031; +/// prc-rs: `has_loops` exists from this file-structure authoring version. +const HAS_LOOPS_FROM: u32 = 7039; +/// prc-rs: `must_recalculate_normals` exists from this version. +const RECALCULATE_FROM: u32 = 7047; + +/// A wire header's count bits; the top four are flags [WD 7.8.7.5]. +const WIRE_COUNT_MASK: u32 = 0x0FFF_FFFF; +const WIRE_IS_CLOSING: u32 = 0x1000_0000; +const WIRE_IS_CONTINUOUS: u32 = 0x2000_0000; + +/// Normal angles wider than this are not a `ShortArray` [WD 7.8.9.7]. +const MAX_ANGLE_BITS: u32 = 16; + +/// One entry of the tessellation array; a representation item's `index_tessellation` selects +/// one by position. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Tessellation { + /// A `TESS_3D` triangle mesh. + Mesh(TriangleMesh), + /// A `TESS_3D_Wire`: polylines. + Wire(Vec>), + /// A `TESS_Markup` (PMI drawing), read past but not decoded. + Markup, + /// A `TESS_3D_Compressed` mesh, read past but not rebuilt. + #[non_exhaustive] + Compressed { + /// The entity's triangle count. + triangles: usize, + }, +} + +/// A `TESS_3D` decoded to indexed triangles, in the entity's own frame (the representation +/// item's placement is not applied). +#[derive(Debug, Clone, Default, PartialEq)] +#[non_exhaustive] +pub struct TriangleMesh { + /// The coordinate array as points. + pub positions: Vec<[f64; 3]>, + /// Counter-clockwise triangles seen from the outside, as indices into + /// [`Self::positions`] [WD 7.8.5.2]. + pub triangles: Vec<[u32; 3]>, + /// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`. + pub faces: Vec>, + /// The producer asks the reader to compute normals (none are stored). + pub normals_recalculated: bool, + /// The stored vertex normals [WD 7.8.5.1], as written (not renormalised); empty when the + /// mesh stores none or any of its normal indices is unusable. + pub normals: Vec<[f64; 3]>, + /// Per triangle, the index into [`Self::normals`] of each corner's normal, in the corner + /// order of [`Self::triangles`]; empty exactly when [`Self::normals`] is. + pub triangle_normals: Vec<[u32; 3]>, + /// Per triangle, the style its face's line attributes give it [WD 7.8.6]; empty when no + /// face carries any. + pub triangle_styles: Vec, + /// The stored texture coordinates as (u, v) pairs [WD 7.8.5.1]; empty when the mesh + /// stores none. + pub uvs: Vec<[f64; 2]>, + /// Per texture-coordinate set, per triangle, the index into [`Self::uvs`] of each + /// corner's coordinate, in the corner order of [`Self::triangles`]; each set is as long + /// as [`Self::triangles`], `None` for a triangle whose face stores no coordinate for the + /// set. Empty when [`Self::uvs`] is. + pub triangle_uvs: Vec>>, +} + +/// The graphics a face's `line_attributes` give one triangle [WD 7.8.6]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct TriangleStyle { + /// An index into the globals' style array plus one; 0 keeps the owner's graphics. + pub style: u32, + /// The face's behaviour bits [WD 5.4]. + pub behaviour: u16, +} + +impl FileStructure { + /// The tessellation section's entities, in the order a representation item's + /// `index_tessellation` counts them [WD 7.3.7]. + /// + /// # Errors + /// As [`crate::Schema::read`] for the globals' schema; [`PrcError::Truncated`] or + /// [`PrcError::Malformed`] for a damaged section; [`PrcError::Unsupported`] for + /// optimised vertex colours. + pub fn tessellations(&self) -> Result, PrcError> { + let schema = self.schema()?; + let r = BitReader::new(self.section(SectionKind::Tessellation)); + Ctx::new(r, &schema, self.authoring_version).file_structure_tessellation() + } +} + +impl Ctx<'_, '_> { + /// `FileStructureTessellation` (305) [WD 7.3.7]. + pub(crate) fn file_structure_tessellation(&mut self) -> Result, PrcError> { + self.expect_type(FILE_STRUCTURE_TESSELLATION)?; + self.content_prc_base()?; + let n = self.count(1, "tessellations")?; + let mut out = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + out.push(match self.r.unsigned_integer()? { + TESS_3D => Tessellation::Mesh(self.tess_3d()?), + TESS_3D_WIRE => Tessellation::Wire(self.wire()?), + TESS_MARKUP => { + self.markup()?; + Tessellation::Markup + } + TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? }, + t => return Err(malformed(format!("entity type {t} in the tessellation array"))), + }); + } + self.schema.skip_added_fields(FILE_STRUCTURE_TESSELLATION, &mut self.r)?; + self.user_data()?; + Ok(out) + } + + /// `ContentBaseTessData` [WD 7.8.4]: `is_calculated`, then coordinates. + fn base_tess_data(&mut self) -> Result, PrcError> { + self.r.bit()?; + self.doubles("tessellation coordinates") + } + + /// `VertexColors` [WD 7.8.7.2] for `count` points, read past. + fn vertex_colors(&mut self, count: usize, in_wire: bool) -> Result<(), PrcError> { + let rgba = self.r.bit()?; + // prc-rs: `is_segment_color` exists only inside a wire. + let per_segment = in_wire && self.r.bit()?; + if self.r.bit()? { + return Err(PrcError::Unsupported("optimised vertex colours")); + } + let count = if per_segment { count / 2 } else { count }; + let bytes = if rgba { 4 } else { 3 }; + // Every colour but the first is at least its reuse bit. + if count.saturating_mul(1 + 8 * bytes) > self.r.remaining().saturating_add(8 * bytes) { + return Err(PrcError::Truncated("vertex colours")); + } + for i in 0..count { + if i == 0 || !self.r.bit()? { + for _ in 0..bytes { + self.r.character()?; + } + } + } + Ok(()) + } + + /// `TESS_3D` (172) after its type code [WD 7.8.5]. + fn tess_3d(&mut self) -> Result { + let coords = self.base_tess_data()?; + let _has_faces = self.r.bit()?; + if self.version >= HAS_LOOPS_FROM { + let _has_loops = self.r.bit()?; + } + let recalc = self.version >= RECALCULATE_FROM && self.r.bit()?; + if recalc { + let _flags = self.r.character()?; + let _crease_angle = self.r.double()?; + } + let normal_coords = self.doubles("normal coordinates")?; + let _wire_indices = self.uints("wire indices")?; + let indices = self.uints("triangulated indices")?; + let n_faces = self.count(1, "faces")?; + let mut faces = Vec::with_capacity(n_faces.min(1024)); + for _ in 0..n_faces { + self.expect_type(TESS_FACE)?; + faces.push(self.face()?); + } + let texture = self.doubles("texture coordinates")?; + self.schema.skip_added_fields(TESS_3D, &mut self.r)?; + + let mut mesh = TriangleMesh { positions: points(&coords), normals_recalculated: recalc, ..TriangleMesh::default() }; + let styled = faces.iter().any(|f| !f.styles.is_empty()); + let mut corners = Corners::default(); + for f in &faces { + let start = mesh.triangles.len(); + let entities = f.triangulate(&indices, !recalc, mesh.positions.len(), &mut mesh.triangles, &mut corners)?; + mesh.faces.push(start..mesh.triangles.len()); + if styled { + f.styles_into(&entities, &mut mesh.triangle_styles); + } + } + (mesh.normals, mesh.triangle_normals) = stored_normals(&normal_coords, &corners.normals, mesh.triangles.len()); + (mesh.uvs, mesh.triangle_uvs) = stored_uvs(&texture, corners.textures); + Ok(mesh) + } + + /// `TESS_Face` (174) after its type code [WD 7.8.6]. + fn face(&mut self) -> Result { + let styles = self.uints("face line attributes")?; + let _start_of_wire = self.r.unsigned_integer()?; + let _sizes_wire = self.uints("face wire sizes")?; + let flags = self.r.unsigned_integer()?; + let start = self.r.unsigned_integer()? as usize; + let data = self.uints("triangulated data")?; + let textures = self.r.unsigned_integer()? as usize; + let mut face = Face { flags, start, data, textures, styles, behaviour: 0 }; + if self.r.bit()? { + let n = face.point_count()?; + self.vertex_colors(n, false)?; + } + if !face.styles.is_empty() { + face.behaviour = u16::try_from(self.r.unsigned_integer()?).unwrap_or(0); + } + self.schema.skip_added_fields(TESS_FACE, &mut self.r)?; + Ok(face) + } + + /// `TESS_3D_Wire` (175) after its type code [WD 7.8.7]. + fn wire(&mut self) -> Result>, PrcError> { + let pts = points(&self.base_tess_data()?); + let words = self.uints("wire indices")?; + let mut wires: Vec> = Vec::new(); + let mut colours = 0usize; + if words.is_empty() { + colours = pts.len(); + if !pts.is_empty() { + wires.push(pts.clone()); + } + } + let mut it = words.iter().copied(); + while let Some(header) = it.next() { + let n = (header & WIRE_COUNT_MASK) as usize; + let mut wire = Vec::with_capacity(n.min(words.len())); + for _ in 0..n { + let idx = it.next().ok_or_else(|| malformed("wire runs past its indices".into()))?; + wire.push(point_at(&pts, idx)?); + } + colours = colours.saturating_add(n); + if header & WIRE_IS_CLOSING != 0 { + colours = colours.saturating_add(1); + if let Some(&first) = wire.first() { + wire.push(first); + } + } + match wires.last_mut() { + Some(prev) if header & WIRE_IS_CONTINUOUS != 0 => prev.extend(wire), + _ => wires.push(wire), + } + } + if self.r.bit()? { + self.vertex_colors(colours, true)?; + } + self.schema.skip_added_fields(TESS_3D_WIRE, &mut self.r)?; + Ok(wires) + } + + /// `TESS_Markup` (176) after its type code [WD 7.8.8], read past. + fn markup(&mut self) -> Result<(), PrcError> { + self.base_tess_data()?; + self.uints("markup codes")?; + let n = self.count(1, "markup strings")?; + for _ in 0..n { + self.r.string()?; + } + self.r.string()?; + self.r.character()?; + self.schema.skip_added_fields(TESS_MARKUP, &mut self.r) + } + + /// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7], read past; returns its + /// triangle count. prc-rs: the field order and version gates follow its schema. + /// + /// Sizes the WD leaves unstated: the face count is the largest face index + 1, + /// `normal_is_reversed` is one bit per triangle, and the point-reference array's + /// compressed flag is implicit (more than three references). + fn tess_3d_compressed(&mut self) -> Result { + let r = &mut self.r; + r.bit()?; // is_calculated + r.bit()?; // has_faces + r.double()?; // tolerance + if self.version >= ORIGIN_FROM { + for _ in 0..3 { + r.float_as_bytes()?; + } + } + arrays::compressed_integer_array(r)?; // point_array + let edge_status = arrays::character_array(r, 2, None, false)?; + let face_of = arrays::compressed_indice_array(r, None)?; + let t = face_of.len(); + if edge_status.len() != t && edge_status.len() != t.saturating_mul(3) { + return Err(malformed(format!("{} edge statuses for {t} triangles", edge_status.len()))); + } + let faces = face_of.iter().max().map_or(0, |&m| (m as usize).saturating_add(1)); + let n = r.unsigned_integer()? as usize; + let references = arrays::bool_array(r, n)?.iter().filter(|&&b| b).count(); + let refs = arrays::compressed_indice_array(r, Some(references > 3))?; + if refs.len() != references { + return Err(malformed(format!("{} point references for {references} flagged points", refs.len()))); + } + self.compressed_normals(t, faces)?; + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, faces)?; // is_point_color_on_face + arrays::character_array(r, 8, None, false)?; // point_color_array + } + if r.bit()? { + arrays::bool_array(r, faces)?; // is_multiple_line_attribute_on_face + } + arrays::short_array(r, 16)?; // line_attributes + if !r.bit()? { + self.compressed_texture_parameter()?; + if !self.r.bit()? { + arrays::bool_array(&mut self.r, faces)?; // face_has_texture + } + } + if self.r.bit()? { + arrays::character_array(&mut self.r, 8, None, false)?; // behaviours + } + self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?; + Ok(t) + } + + /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past. + fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> { + let r = &mut self.r; + if r.bit()? { + arrays::bool_array(r, triangles)?; // normal_is_reversed + r.double()?; // crease_angle + r.character()?; // normal_recalculation_flags + return Ok(()); + } + let angle_bits = u32::from(r.character()?); + if angle_bits > MAX_ANGLE_BITS { + return Err(malformed(format!("{angle_bits}-bit normal angles"))); + } + let n = r.unsigned_integer()? as usize; + arrays::bool_array(r, n)?; // is_binary_normal + arrays::short_array(r, angle_bits)?; // normal angles + arrays::bool_array(r, faces)?; // is_face_planar + Ok(()) + } + + /// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past. + fn compressed_texture_parameter(&mut self) -> Result<(), PrcError> { + let words = self.count(32, "texture data")?; + // count() bounded words * 32 by the remaining bits. + self.r.skip_bits(words.saturating_mul(32))?; + self.r.unsigned_integer()?; // last_integer_used_bit_number + let n = self.count(5, "texture references")?; + for _ in 0..n { + self.r.nbits_then_unsigned()?; + } + self.r.double()?; // tolerance + let n = self.count(32, "texture parameters")?; + for _ in 0..n { + self.r.float_as_bytes()?; + } + Ok(()) + } +} + +/// The normal array and each triangle's corner normals, or both empty when any slot fails to +/// name a normal: a mesh shades from its stored normals wholly or not at all. +fn stored_normals(coords: &[f64], slots: &[[u32; 3]], triangles: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) { + let normals = points(coords); + let named = |k: u32| k.is_multiple_of(3) && (k as usize / 3) < normals.len(); + if normals.is_empty() || slots.len() != triangles || !slots.iter().flatten().all(|&k| named(k)) { + return (Vec::new(), Vec::new()); + } + let corners = slots.iter().map(|t| t.map(|k| k / 3)).collect(); + (normals, corners) +} + +/// Per texture-coordinate set, each triangle's corner pair indices. +type UvSets = Vec>>; + +/// The texture coordinates as (u, v) pairs, and per set the corner slots turned into pair +/// indices (a slot is a `Double` offset, so even); a triangle with a slot naming no pair is +/// `None`. +fn stored_uvs(coords: &[f64], sets: UvSets) -> (Vec<[f64; 2]>, UvSets) { + let uvs: Vec<[f64; 2]> = coords.chunks_exact(2).map(|c| [c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0)]).collect(); + if uvs.is_empty() { + return (Vec::new(), Vec::new()); + } + let pair = |k: u32| (k.is_multiple_of(2) && (k as usize / 2) < uvs.len()).then_some(k / 2); + let sets = + sets.into_iter().map(|set| set.into_iter().map(|tri| tri.and_then(|[a, b, c]| Some([pair(a)?, pair(b)?, pair(c)?]))).collect()).collect(); + (uvs, sets) +} + +fn points(coords: &[f64]) -> Vec<[f64; 3]> { + coords.chunks_exact(3).map(|c| [0, 1, 2].map(|i| c.get(i).copied().unwrap_or(0.0))).collect() +} + +/// The point a `Double`-offset index names [WD 7.8.5.2: multiples of 3]. +fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> { + if !idx.is_multiple_of(3) { + return Err(malformed(format!("point index {idx} is not a multiple of 3"))); + } + pts.get(idx as usize / 3).copied().ok_or_else(|| malformed(format!("point index {idx} past the coordinates"))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs new file mode 100644 index 0000000000..ee202e6a2a --- /dev/null +++ b/crates/prc/src/tess/tests.rs @@ -0,0 +1,581 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::triangulate::MAX_TEXTURE_SETS; +use super::*; +use crate::Schema; +use crate::testw::W; + +const SQUARE: [f64; 12] = [0., 0., 0., 1., 0., 0., 1., 1., 0., 0., 1., 0.]; +const ATTRIBUTE: u32 = 201; +const NORMAL_SINGLE: u32 = 0x4000_0000; + +/// A 305 section holding `body` (already-written entities, `n` of them). +fn section(n: u32, body: &W) -> W { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(n).append(body).uint(0); + w +} + +fn uints(w: &mut W, v: &[u32]) { + w.uint(u32::try_from(v.len()).unwrap()); + for &x in v { + w.uint(x); + } +} + +fn square(w: &mut W, entity: u32) { + w.uint(entity).bit(false).uint(12); + for c in SQUARE { + w.double(c); + } +} + +#[derive(Default)] +struct Mesh<'a> { + recalc: bool, + indices: &'a [u32], + faces: &'a [(u32, u32, &'a [u32])], + textures: u32, + uvs: &'a [f64], +} + +/// A TESS_3D over [`SQUARE`] at authoring version `v`. +fn tess_3d(w: &mut W, v: u32, m: &Mesh<'_>) { + square(w, 172); + w.bit(true); + if v >= 7039 { + w.bit(false); + } + if v >= 7047 { + w.bit(m.recalc); + if m.recalc { + w.put(0, 8).double(0.5); + } + } + if m.recalc { + w.uint(0); + } else { + // Two normals: slot 0 is +z, slot 3 is -z. + w.uint(6); + for c in [0., 0., 1., 0., 0., -1.] { + w.double(c); + } + } + w.uint(0); + uints(w, m.indices); + w.uint(u32::try_from(m.faces.len()).unwrap()); + for &(flags, start, data) in m.faces { + w.uint(174).uint(0).uint(0).uint(0).uint(flags).uint(start); + uints(w, data); + w.uint(m.textures).bit(false); + } + w.uint(u32::try_from(m.uvs.len()).unwrap()); + for &c in m.uvs { + w.double(c); + } +} + +fn decode(w: &W, schema: &Schema, version: u32) -> Result, PrcError> { + let bytes = w.bytes(); + Ctx::new(BitReader::new(&bytes), schema, version).file_structure_tessellation() +} + +fn mesh(t: &Tessellation) -> &TriangleMesh { + match t { + Tessellation::Mesh(m) => m, + other => panic!("not a mesh: {other:?}"), + } +} + +fn one(indices: &[u32], faces: &[(u32, u32, &[u32])], textures: u32) -> TriangleMesh { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices, faces, textures, ..Mesh::default() }); + mesh(&decode(§ion(1, &b), &Schema::default(), 8137).unwrap()[0]).clone() +} + +#[test] +fn triangle_and_fan_with_normals() { + // One triangle (n,p x3), then a fan of 4 vertices (n,p each). + let m = one(&[3, 0, 0, 3, 3, 6, 0, 0, 3, 3, 0, 6, 3, 9], &[(0x2 | 0x4, 0, &[1, 1, 4])], 0); + assert_eq!(m.positions.len(), 4); + assert_eq!(m.positions[2], [1., 1., 0.]); + assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [0, 2, 3]]); + assert_eq!(m.faces, vec![0..3]); + assert!(!m.normals_recalculated); + assert_eq!(m.normals, [[0., 0., 1.], [0., 0., -1.]]); + // Each corner keeps the normal stored beside its point. + assert_eq!(m.triangle_normals, [[1, 0, 1], [0, 1, 0], [0, 0, 1]]); +} + +#[test] +fn strip_normals_follow_the_alternating_winding() { + let m = one(&[3, 0, 0, 3, 3, 6, 0, 9], &[(0x8, 0, &[1, 4])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + assert_eq!(m.triangle_normals, [[1, 0, 1], [1, 0, 0]]); +} + +#[test] +fn a_normal_index_past_the_array_drops_every_normal_but_no_triangle() { + let m = one(&[0, 0, 6, 3, 0, 6], &[(0x2, 0, &[1])], 0); + assert_eq!(m.triangles, [[0, 1, 2]]); + assert!(m.normals.is_empty()); + assert!(m.triangle_normals.is_empty()); +} + +#[test] +fn recalculated_normals_drop_the_normal_slots_and_strips_alternate() { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { recalc: true, indices: &[0, 3, 6, 9], faces: &[(0x8, 0, &[1, 4])], ..Mesh::default() }); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert!(m.normals_recalculated); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + assert!(m.normals.is_empty() && m.triangle_normals.is_empty()); +} + +#[test] +fn version_gates_the_loop_and_recalculation_flags() { + for v in [7038, 7039, 7046, 7047, 8137] { + let mut b = W::default(); + tess_3d(&mut b, v, &Mesh { indices: &[0, 0, 0, 3, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() }); + // A trailing wire: a misread flag desyncs it. + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), v).unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]], "version {v}"); + assert!(matches!(t[1], Tessellation::Wire(_)), "version {v}"); + } +} + +#[test] +fn one_normal_blocks_and_textures() { + // 0x20: n,p,p,p. 0x40 with NORMAL_Single: n then p per vertex. + // 0x2000 with one texture set: n,t,p,t,p,t,p. + let idx = [ + 0, 0, 3, 6, // 0x20 + 3, 0, 6, 9, // 0x40 single, 3 vertices + 0, 0, 9, 2, 0, 4, 3, // 0x2000 + ]; + let m = one(&idx, &[(0x20 | 0x40 | 0x2000, 0, &[1, 1, 3 | NORMAL_SINGLE, 1])], 1); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3], [3, 0, 1]]); + // A one-normal entity gives every corner its single normal. + assert_eq!(m.triangle_normals, [[0, 0, 0], [1, 1, 1], [0, 0, 0]]); +} + +#[test] +fn one_normal_fan_without_single_keeps_a_normal_per_vertex() { + let m = one(&[0, 0, 0, 3, 0, 6, 0, 9], &[(0x40, 0, &[1, 4])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); +} + +#[test] +fn textured_fan_and_strip_carry_texture_slots_per_vertex() { + // 0x400 then 0x800, two texture sets: n,t,t,p per vertex. + let mut idx = Vec::new(); + for p in [0, 3, 6, 0, 3, 6, 9] { + idx.extend([0, 0, 2, p]); + } + let m = one(&idx, &[(0x400 | 0x800, 0, &[1, 3, 1, 4])], 2); + assert_eq!(m.triangles, [[0, 1, 2], [0, 1, 2], [2, 1, 3]]); +} + +/// The unit square as two textured triangles (one set; per vertex a normal, a texture slot, +/// a point), its corners at the picture's corners. +fn textured_square() -> W { + let mut body = W::default(); + let indices = [0, 0, 0, 0, 2, 3, 0, 4, 6, 0, 0, 0, 0, 4, 6, 0, 6, 9]; + tess_3d( + &mut body, + 8137, + &Mesh { indices: &indices, faces: &[(0x200, 0, &[2])], textures: 1, uvs: &[0., 0., 1., 0., 1., 1., 0., 1.], ..Mesh::default() }, + ); + section(1, &body) +} + +#[test] +fn a_textured_mesh_keeps_each_corners_coordinates() { + let t = decode(&textured_square(), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); + assert_eq!(m.uvs, [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]); + assert_eq!(m.triangle_uvs, [vec![Some([0, 1, 2]), Some([0, 2, 3])]]); + // An odd slot names no pair: that triangle has no coordinates. + let mut b = W::default(); + tess_3d( + &mut b, + 8137, + &Mesh { indices: &[0, 1, 0, 0, 2, 3, 0, 4, 6], faces: &[(0x200, 0, &[1])], textures: 1, uvs: &[0.0; 6], ..Mesh::default() }, + ); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + let m = mesh(&t[0]); + assert_eq!(m.triangles, [[0, 1, 2]]); + assert_eq!(m.triangle_uvs, [vec![None]]); +} + +#[test] +fn too_many_texture_sets_are_refused_before_anything_is_allocated() { + // An empty fan on a face claiming 2^32 - 1 texture sets: no slot bounds the count. + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: u32::MAX, ..Mesh::default() }); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + let sets = u32::try_from(MAX_TEXTURE_SETS).unwrap(); + let mut ok = W::default(); + tess_3d(&mut ok, 8137, &Mesh { indices: &[0], faces: &[(0x400, 0, &[1, 0])], textures: sets, ..Mesh::default() }); + assert!(decode(§ion(1, &ok), &Schema::default(), 8137).is_ok()); +} + +#[test] +fn two_faces_partition_the_triangles() { + let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0); + assert_eq!(m.triangles, [[0, 1, 2], [0, 2, 3]]); + assert_eq!(m.faces, [0..1, 1..2]); +} + +/// A wire over [`SQUARE`]: an open two-point wire continued by a closing two-point wire, +/// then optionally per-segment RGB colours. +fn wire(w: &mut W, colours: bool) { + square(w, 175); + uints(w, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]); + w.bit(colours); + if colours { + // 5 points (4 + the implicit closing one) -> 2 segment colours. + w.bit(false).bit(true).bit(false).put(0xff0000, 24); + w.bit(true); + } +} + +#[test] +fn wires_close_and_continue_and_colours_are_counted() { + for colours in [false, true] { + let mut b = W::default(); + wire(&mut b, colours); + // A second entity: a wrong colour count would desync it. + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + for e in &t { + let Tessellation::Wire(w) = e else { panic!("not a wire") }; + assert_eq!(w, &[vec![[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [1., 1., 0.]]]); + } + } +} + +#[test] +fn a_closing_wire_colours_its_implicit_point() { + // Per-point RGB: 4 indexed points + 1 implicit = 5 colours. + let mut b = W::default(); + square(&mut b, 175); + uints(&mut b, &[2, 0, 3, WIRE_IS_CONTINUOUS | WIRE_IS_CLOSING | 2, 6, 9]); + b.bit(true).bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..5 { + b.bit(true); + } + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + assert!(matches!(t[1], Tessellation::Wire(_))); + assert_eq!(t[0], t[1]); +} + +#[test] +fn a_wire_index_must_name_a_point() { + let mut b = W::default(); + square(&mut b, 175); + uints(&mut b, &[2, 0, 12]); + b.bit(false); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); +} + +#[test] +fn a_wire_without_indices_is_the_coordinate_polyline() { + let mut b = W::default(); + square(&mut b, 175); + b.uint(0).bit(true); + // 4 implicit points -> 4 RGB colours, all "same" after the first. + b.bit(false).bit(false).bit(false).put(1, 24); + for _ in 1..4 { + b.bit(true); + } + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + let Tessellation::Wire(w) = &t[0] else { panic!("not a wire") }; + assert_eq!(w.len(), 1); + assert_eq!(w[0].len(), 4); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +/// A square as one triangle plus one 4-vertex fan (two entities, three triangles), the face +/// carrying `attrs` and, when it has any, behaviour 0x30; then `colours` per-point colours +/// and a trailing wire. +fn styled_face(attrs: &[u32], colours: usize) -> Vec { + let mut b = W::default(); + square(&mut b, 172); + b.bit(true).bit(false).bit(false).uint(0).uint(0); + uints(&mut b, &[0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]); + b.uint(1).uint(174); + uints(&mut b, attrs); + b.uint(0).uint(0).uint(0x2 | 0x4).uint(0); + uints(&mut b, &[1, 1, 4]); + b.uint(0).bit(colours > 0); + if colours > 0 { + b.bit(false).bit(false).put(0x10_2030, 24); + for _ in 1..colours { + b.bit(true); + } + } + if !attrs.is_empty() { + b.uint(0x30); + } + b.uint(0); + wire(&mut b, false); + decode(§ion(2, &b), &Schema::default(), 8137).unwrap() +} + +#[test] +fn face_vertex_colours_count_points_not_data() { + // A triangle plus a fan of 4 vertices = 7 points -> 7 colours. + let t = styled_face(&[], 7); + assert_eq!(mesh(&t[0]).triangles.len(), 3); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +fn styles(attrs: &[u32]) -> Vec<(u32, u16)> { + let t = styled_face(attrs, 0); + assert!(matches!(t[1], Tessellation::Wire(_))); + mesh(&t[0]).triangle_styles.iter().map(|g| (g.style, g.behaviour)).collect() +} + +#[test] +fn face_line_attributes_style_its_triangles() { + assert!(styles(&[]).is_empty()); + assert_eq!(styles(&[5]), [(5, 0x30); 3]); + // One per entity: the triangle, then both triangles of the fan. + assert_eq!(styles(&[4, 7]), [(4, 0x30), (7, 0x30), (7, 0x30)]); + // An entity styled 0 inherits, as do entities past the list. + assert_eq!(styles(&[4, 0, 9]), [(4, 0x30), (0, 0x30), (0, 0x30)]); +} + +#[test] +fn markup_is_read_past() { + let mut b = W::default(); + b.uint(176).bit(false).uint(3); + for c in [1., 2., 3.] { + b.double(c); + } + uints(&mut b, &[6, 0]); + b.uint(1).string(Some("R1")).string(Some("label")).put(7, 8); + wire(&mut b, false); + let t = decode(§ion(2, &b), &Schema::default(), 8137).unwrap(); + assert_eq!(t[0], Tessellation::Markup); + assert!(matches!(t[1], Tessellation::Wire(_))); +} + +#[test] +fn schema_fields_are_skipped_after_each_entity() { + // A newer producer added one UnsignedInteger to TESS_3D and one to TESS_Face. + let mut s = W::default(); + s.uint(2).uint(172).uint(1).uint(3).uint(174).uint(1).uint(3); + let sb = s.bytes(); + let schema = Schema::read(&mut BitReader::new(&sb)).unwrap(); + + let mut b = W::default(); + square(&mut b, 172); + b.bit(true).bit(false).bit(false).uint(0).uint(0); + uints(&mut b, &[0, 0, 0, 3, 0, 6]); + b.uint(1).uint(174).uint(0).uint(0).uint(0).uint(0x2).uint(0); + uints(&mut b, &[1]); + b.uint(0).bit(false).uint(99); // the face's added field + b.uint(0).uint(77); // texture count, then TESS_3D's added field + wire(&mut b, false); + let t = decode(§ion(2, &b), &schema, 8137).unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2]]); + assert!(matches!(t[1], Tessellation::Wire(_))); + // Without the schema the same bytes do not decode to the same thing. + assert_ne!(decode(§ion(2, &b), &Schema::default(), 8137).ok(), Some(t)); +} + +#[test] +fn attributes_names_and_user_data_are_read_past() { + let mut w = W::default(); + w.uint(305).uint(1).uint(ATTRIBUTE); + w.bit(true).uint(2); // predefined title + w.uint(2); + w.bit(false).string(Some("k")).uint(4).string(Some("v")); + w.bit(true).uint(1).uint(5).int(-1).uint(9); + w.uint(42); // the attribute's schema-added field + w.bit(false).string(Some("tess")).uint(1); + wire(&mut w, false); + w.uint(5).put(0b10110, 5); + let mut sw = W::default(); + sw.uint(1).uint(ATTRIBUTE).uint(1).uint(3); + let sb = sw.bytes(); + let schema = Schema::read(&mut BitReader::new(&sb)).unwrap(); + let bytes = w.bytes(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137); + let t = ctx.file_structure_tessellation().unwrap(); + assert!(matches!(t[..], [Tessellation::Wire(_)])); + assert_eq!(ctx.name.as_deref(), Some("tess")); + assert_eq!(ctx.r.position(), w.len()); +} + +/// A one-triangle TESS_3D_Compressed. `full` takes every optional branch: stored normals, +/// colours, line attributes, texture, behaviours. Otherwise `three_t` picks the 3T edge form. +fn compressed(w: &mut W, v: u32, full: bool, three_t: bool) { + w.uint(173).bit(false).bit(true).double(0.001); + if v >= 7031 { + w.put(0, 32).put(0, 32).put(0, 32); + } + // point_array: nine 2-bit values. + w.bit(false).uint(9); + for _ in 0..9 { + w.put(2, 8); + } + for _ in 0..9 { + w.bit(false).bit(true); + } + // edge_status_array: the 3T form (Huffman) or the T form. + if full { + w.bit(true).huffman(2, 2, &[(0, 0b10, 2)], &[0, 0, 0]); + } else if three_t { + w.bit(false).uint(3).put(0, 8).put(0, 8).put(0, 8); + } else { + w.bit(false).uint(1).put(0, 8); + } + // triangle_face_array [0]. `full`: four references, so the flagless reference array is + // Huffman; else none. + w.bit(false).uint(1).put(1, 8).bit(false); + if full { + w.uint(4).put(0b1111, 4); + w.huffman(6, 2, &[(1, 0b10, 2), (0, 0b11, 2)], &[1, 0, 0, 0]); + w.put(0, 4); + } else { + w.uint(3).put(0, 3).uint(0); + } + if full { + w.bit(false).put(10, 8).uint(3).put(0b101, 3); + w.bit(true).huffman(10, 2, &[(5, 0b10, 2)], &[5, 5]); + w.bit(true); // is_face_planar + w.bit(true).bit(true).bit(false).uint(5); + for c in [1, 255, 0, 0, 128] { + w.put(c, 8); + } + w.bit(true).bit(false); + } else { + w.bit(true).bit(false).double(0.5).put(0, 8); + w.bit(false).bit(false); + } + w.bit(false).uint(1).put(1, 8).put(0, 8); // line_attribute_array + if full { + w.bit(false).uint(1).put(0, 32).uint(32); + w.uint(1).put(1, 5).bit(true).double(0.5).uint(2); + w.put(0, 32).put(0, 32).bit(false).bit(true); + w.bit(true).bit(false).uint(1).put(1, 8); + } else { + w.bit(true).bit(false); + } +} + +#[test] +fn a_compressed_mesh_is_read_to_its_end() { + for (full, three_t) in [(false, false), (false, true), (true, true)] { + for v in [7030, 7031, 8137] { + let mut b = W::default(); + compressed(&mut b, v, full, three_t); + let indices = [0, 0, 0, 3, 0, 6, 0, 0, 0, 3, 0, 6, 0, 9]; + tess_3d(&mut b, v, &Mesh { indices: &indices, faces: &[(0x2 | 0x4, 0, &[1, 1, 4])], ..Mesh::default() }); + let w = section(2, &b); + let bytes = w.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, v); + let t = ctx.file_structure_tessellation().unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }, Tessellation::Mesh(_)]), "full={full} v={v}: {t:?}"); + assert_eq!(ctx.r.position(), w.len(), "full={full} v={v}"); + } + } +} + +#[test] +fn refusals_and_damage_are_errors() { + // Edge statuses neither T nor 3T long. + let mut c = W::default(); + c.uint(173).bit(false).bit(true).double(0.001); + c.put(0, 32).put(0, 32).put(0, 32); + c.bit(false).uint(0).bit(false).uint(2).put(0, 16); + c.bit(false).uint(1).put(1, 8).bit(false); + assert!(matches!(decode(§ion(1, &c), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + // Optimised vertex colours. + let mut o = W::default(); + square(&mut o, 175); + o.uint(0).bit(true).bit(false).bit(false).bit(true); + assert!(matches!(decode(§ion(1, &o), &Schema::default(), 8137), Err(PrcError::Unsupported(_)))); + // A point index that is not a multiple of 3, and one past the end. + for bad in [4, 12] { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, bad, 0, 6], faces: &[(0x2, 0, &[1])], ..Mesh::default() }); + assert!(matches!(decode(§ion(1, &b), &Schema::default(), 8137), Err(PrcError::Malformed(_)))); + } + // A fan whose count claims a billion points. + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0], faces: &[(0x4, 0, &[1, 0x3FFF_FFFF])], ..Mesh::default() }); + assert!(decode(§ion(1, &b), &Schema::default(), 8137).is_err()); + // A tessellation count far past the data. + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(u32::MAX); + assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Truncated(_)))); + // A wrong entity type where the section's own belongs. + let mut w = W::default(); + w.uint(306); + assert!(matches!(decode(&w, &Schema::default(), 8137), Err(PrcError::Malformed(_)))); +} + +#[test] +fn every_truncation_of_a_section_is_an_error_not_a_panic() { + let mut b = W::default(); + compressed(&mut b, 8137, true, true); + wire(&mut b, true); + let bytes = section(2, &b).bytes(); + for end in 0..bytes.len() { + let cut = bytes.get(..end).unwrap(); + // The zero padding of the last byte may still decode; anything shorter must not. + let _ = Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation(); + } + let cut = bytes.get(..bytes.len() / 2).unwrap(); + assert!(Ctx::new(BitReader::new(cut), &Schema::default(), 8137).file_structure_tessellation().is_err()); +} + +/// A one-file-structure PRC stream whose tessellation section is `tess`, with an empty +/// globals section schema. +fn prc_file(tess: &[u8]) -> Vec { + use std::io::Write as _; + const HEADER_LEN: usize = 107; + let zlib = |data: &[u8]| { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + }; + let u32s = |out: &mut Vec, vs: &[u32]| vs.iter().for_each(|v| out.extend_from_slice(&v.to_le_bytes())); + let mut globals = W::default(); + globals.uint(0); + let sections: [&[u8]; 5] = [&globals.bytes(), b"", tess, b"", b""]; + let mut body = b"PRC".to_vec(); + u32s(&mut body, &[8137, 8137, 5, 6, 7, 8, 0, 0, 0, 0, 0]); + let mut offs = vec![HEADER_LEN]; + for s in sections { + offs.push(HEADER_LEN + body.len()); + body.extend(zlib(s)); + } + let mf_start = HEADER_LEN + body.len(); + body.extend(zlib(b"")); + let mf_end = HEADER_LEN + body.len(); + let mut out = b"PRC".to_vec(); + u32s(&mut out, &[8137, 8137, 1, 2, 3, 4, 0, 0, 0, 0, 1]); + u32s(&mut out, &[5, 6, 7, 8, 0, 6]); + u32s(&mut out, &offs.iter().map(|&o| u32::try_from(o).unwrap()).collect::>()); + u32s(&mut out, &[u32::try_from(mf_start).unwrap(), u32::try_from(mf_end).unwrap(), 0]); + out.extend(body); + out +} + +#[test] +fn a_prc_file_yields_its_tessellations() { + let f = crate::PrcFile::parse(&prc_file(&textured_square().bytes())).unwrap(); + let t = f.file_structures[0].tessellations().unwrap(); + assert_eq!(mesh(&t[0]).triangles, [[0, 1, 2], [0, 2, 3]]); +} diff --git a/crates/prc/src/tess/triangulate.rs b/crates/prc/src/tess/triangulate.rs new file mode 100644 index 0000000000..56917d3dc8 --- /dev/null +++ b/crates/prc/src/tess/triangulate.rs @@ -0,0 +1,258 @@ +//! A `TESS_Face`'s triangulated data turned into triangles [WD 7.8.5.5, 7.8.6]. + +use super::TriangleStyle; +use crate::PrcError; +use crate::entity::malformed; + +/// A fan/strip count's point-count bits; bit 30 is `NORMAL_Single` [WD 7.8.6.2]. +const COUNT_MASK: u32 = 0x3FFF_FFFF; +const NORMAL_SINGLE: u32 = 0x4000_0000; + +/// Texture-coordinate sets a face may carry. Each set costs a slot per point and a corner +/// triple per triangle of the whole mesh, so the count is capped. +pub(super) const MAX_TEXTURE_SETS: usize = 8; + +pub(super) struct Face { + pub(super) flags: u32, + pub(super) start: usize, + pub(super) data: Vec, + pub(super) textures: usize, + /// `line_attributes`, each a style index + 1: none = the owner's graphics, one = the + /// whole face's, more = one per triangulation entity [WD 7.8.6]. + pub(super) styles: Vec, + /// The behaviour bits read when `styles` is not empty. + pub(super) behaviour: u16, +} + +/// A `used_entities_flag` block's shape [WD 7.8.5.5]. +#[derive(Clone, Copy)] +enum Shape { + Triangles, + Fan, + Strip, +} + +/// One `used_entities_flag` block. +#[derive(Clone, Copy)] +struct Block { + bit: u32, + shape: Shape, + /// One normal per entity rather than per point. + one_normal: bool, + textured: bool, +} + +const fn block(bit: u32, shape: Shape, one_normal: bool, textured: bool) -> Block { + Block { bit, shape, one_normal, textured } +} + +/// The blocks in stream order, low bit first [WD 7.8.5.5]. +const BLOCKS: [Block; 12] = [ + block(0x0002, Shape::Triangles, false, false), + block(0x0004, Shape::Fan, false, false), + block(0x0008, Shape::Strip, false, false), + block(0x0020, Shape::Triangles, true, false), + block(0x0040, Shape::Fan, true, false), + block(0x0080, Shape::Strip, true, false), + block(0x0200, Shape::Triangles, false, true), + block(0x0400, Shape::Fan, false, true), + block(0x0800, Shape::Strip, false, true), + block(0x2000, Shape::Triangles, true, true), + block(0x4000, Shape::Fan, true, true), + block(0x8000, Shape::Strip, true, true), +]; + +/// One triangulation entity's index slots: per point its position, normal slot (when the +/// array stores normals) and texture slots. +#[derive(Default)] +struct Slots { + verts: Vec, + normals: Vec, + /// Per texture set, each point's slot. + textures: Vec>, +} + +/// What a mesh's triangles carry besides positions, built in step with them: corner normal +/// slots and, per texture set, corner texture slots (`None` for a triangle whose face stores +/// fewer sets). +#[derive(Default)] +pub(super) struct Corners { + pub(super) normals: Vec<[u32; 3]>, + pub(super) textures: Vec>>, +} + +impl Face { + /// Entity sizes per set block, from `TriangulatedData` [WD 7.8.6]. A triangle block is + /// one size word (its triangle count); fans and strips list a vertex count each. + fn entities(&self) -> Result)>, PrcError> { + let mut d = self.data.iter().copied(); + let mut next = || d.next().ok_or_else(|| malformed("triangulated data ends early".into())); + let mut out = Vec::new(); + for b in BLOCKS { + if self.flags & b.bit == 0 { + continue; + } + let sizes = match b.shape { + Shape::Triangles => vec![next()?], + Shape::Fan | Shape::Strip => { + let n = next()? as usize; + if n > self.data.len() { + return Err(malformed("triangulated data ends early".into())); + } + (0..n).map(|_| next()).collect::>()? + } + }; + out.push((b, sizes)); + } + Ok(out) + } + + /// Points the face's index slots name: the vertex-colour count (pdf-issues #820). + pub(super) fn point_count(&self) -> Result { + let mut n = 0usize; + for (b, sizes) in self.entities()? { + for s in sizes { + let s = (s & COUNT_MASK) as usize; + n = n.saturating_add(if matches!(b.shape, Shape::Triangles) { s.saturating_mul(3) } else { s }); + } + } + Ok(n) + } + + /// Emit the face's triangles into `out`; `normals` = the index array stores normal slots, + /// in which case each triangle's corner normal slots go to `corners`, as do its corner + /// texture slots. Returns the triangles each triangulation entity emitted, in order: one + /// entity per triangle of a triangle block, per fan, per strip [WD 7.8.6]. + pub(super) fn triangulate( + &self, + indices: &[u32], + normals: bool, + n_points: usize, + out: &mut Vec<[u32; 3]>, + corners: &mut Corners, + ) -> Result, PrcError> { + if self.textures > MAX_TEXTURE_SETS { + return Err(malformed(format!("{} texture-coordinate sets on a face", self.textures))); + } + let mut entities = Vec::new(); + let mut slots = indices.get(self.start..).unwrap_or(&[]).iter().copied(); + for (b, sizes) in self.entities()? { + let t = if b.textured { self.textures } else { 0 }; + for word in sizes { + let count = (word & COUNT_MASK) as usize; + // Points in this entity, and whether each carries its own normal slot. + let (points, per_point_normal) = match b.shape { + Shape::Triangles => (count.saturating_mul(3), !b.one_normal), + Shape::Fan | Shape::Strip => (count, !b.one_normal || word & NORMAL_SINGLE == 0), + }; + if points.saturating_mul(t.saturating_add(1)) > indices.len() { + return Err(malformed("face runs past the triangulated index array".into())); + } + let s = read_slots(&mut slots, b.shape, points, t, normals.then_some(per_point_normal), n_points)?; + let made = emit(b.shape, &s, out, corners); + match b.shape { + Shape::Triangles => entities.extend(std::iter::repeat_n(1, made)), + Shape::Fan | Shape::Strip => entities.push(made), + } + } + } + Ok(entities) + } + + /// Append one [`TriangleStyle`] per triangle of the face, whose entities emitted + /// `entities[i]` triangles each. Entities past the face's styles inherit (style 0). + pub(super) fn styles_into(&self, entities: &[usize], out: &mut Vec) { + let whole = match self.styles.as_slice() { + [] => Some(0), + [one] => Some(*one), + _ => None, + }; + for (i, &n) in entities.iter().enumerate() { + let style = whole.unwrap_or_else(|| self.styles.get(i).copied().unwrap_or(0)); + out.extend(std::iter::repeat_n(TriangleStyle { style, behaviour: self.behaviour }, n)); + } + } +} + +/// One entity's `points` index slots. `normals` is `None` when the array stores no normal +/// slots, else whether every point has its own (rather than one per entity). +fn read_slots( + slots: &mut impl Iterator, + shape: Shape, + points: usize, + texture_sets: usize, + normals: Option, + n_points: usize, +) -> Result { + let mut take = || slots.next().ok_or_else(|| malformed("face runs past the triangulated index array".into())); + // The caller bounded points * (texture_sets + 1) by the index array's length. + let mut s = Slots { textures: vec![Vec::with_capacity(points); texture_sets], ..Slots::default() }; + let mut current = 0; + for k in 0..points { + let new_entity = match shape { + Shape::Triangles => k % 3 == 0, + Shape::Fan | Shape::Strip => k == 0, + }; + if let Some(per_point) = normals { + if per_point || new_entity { + current = take()?; + } + s.normals.push(current); + } + for set in &mut s.textures { + set.push(take()?); + } + let p = take()?; + if !p.is_multiple_of(3) || p as usize / 3 >= n_points { + return Err(malformed(format!("point index {p} is not a point"))); + } + s.verts.push(p / 3); + } + Ok(s) +} + +/// Append the triangles of one entity's `slots` to `out` and `corners`, keeping every +/// texture set as long as `out`. Returns how many it made. +fn emit(shape: Shape, slots: &Slots, out: &mut Vec<[u32; 3]>, corners: &mut Corners) -> usize { + let before = out.len(); + assemble(shape, &slots.verts, out); + assemble(shape, &slots.normals, &mut corners.normals); + let made = out.len().saturating_sub(before); + while corners.textures.len() < slots.textures.len() { + corners.textures.push(vec![None; before]); + } + for (k, set) in corners.textures.iter_mut().enumerate() { + match slots.textures.get(k) { + Some(own) => { + let mut tris = Vec::with_capacity(made); + assemble(shape, own, &mut tris); + set.extend(tris.into_iter().map(Some)); + } + None => set.extend(std::iter::repeat_n(None, made)), + } + } + made +} + +/// Append the triangles `shape` makes of `verts`. +fn assemble(shape: Shape, verts: &[u32], out: &mut Vec<[u32; 3]>) { + match shape { + Shape::Triangles => out.extend(verts.chunks_exact(3).filter_map(|c| match c { + [a, b, c] => Some([*a, *b, *c]), + _ => None, + })), + Shape::Fan => { + if let Some((&c, rim)) = verts.split_first() { + out.extend(rim.windows(2).filter_map(|w| match w { + [a, b] => Some([c, *a, *b]), + _ => None, + })); + } + } + Shape::Strip => out.extend(verts.windows(3).enumerate().filter_map(|(k, w)| match w { + [a, b, c] if k % 2 == 0 => Some([*a, *b, *c]), + [a, b, c] => Some([*b, *a, *c]), + _ => None, + })), + } +} diff --git a/crates/prc/src/testw.rs b/crates/prc/src/testw.rs index 4706a20ef8..37e90606b6 100644 --- a/crates/prc/src/testw.rs +++ b/crates/prc/src/testw.rs @@ -94,8 +94,73 @@ impl W { self } + /// A Huffman container [WD 12.2]: `leaves` are `(symbol, stored code, length)`, each value + /// is emitted as its leaf's code. + pub(crate) fn huffman(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], values: &[i32]) -> &mut Self { + let mask = (1u32 << nbits) - 1; + let codes: Vec<(u32, u32)> = values + .iter() + .map(|&v| { + let l = leaves.iter().find(|l| l.0 & mask == v.cast_unsigned() & mask).expect("a leaf per value"); + (l.1, l.2) + }) + .collect(); + self.huffman_raw(nbits, len_width, leaves, &codes) + } + + /// As [`Self::huffman`] with each element's `(code, length)` given. + pub(crate) fn huffman_raw(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], codes: &[(u32, u32)]) -> &mut Self { + let mut blob = Self::huffman_head(nbits, len_width, leaves); + lsb(&mut blob, u32::try_from(codes.len()).expect("a short test array"), 32); + for &(c, l) in codes { + for i in (0..l).rev() { + blob.push((c >> i) & 1 == 1); + } + } + self.huffman_words(blob) + } + + /// A Huffman container whose element count is `count` with no elements written. + pub(crate) fn huffman_count(&mut self, nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)], count: u32) -> &mut Self { + let mut blob = Self::huffman_head(nbits, len_width, leaves); + lsb(&mut blob, count, 32); + self.huffman_words(blob) + } + + fn huffman_head(nbits: u32, len_width: u32, leaves: &[(u32, u32, u32)]) -> Vec { + let mut blob = Vec::new(); + lsb(&mut blob, u32::try_from(leaves.len()).expect("a short test table"), nbits + 1); + lsb(&mut blob, len_width, 8); + for &(s, c, l) in leaves { + lsb(&mut blob, s, nbits); + lsb(&mut blob, l, len_width); + lsb(&mut blob, c, l); + } + blob + } + + /// The payload as whole 32-bit words of LSB-first bytes, then the last word's used bits. + fn huffman_words(&mut self, mut blob: Vec) -> &mut Self { + let bits = blob.len(); + let words = bits.div_ceil(32); + blob.resize(words * 32, false); + self.uint(u32::try_from(words).expect("a short test payload")); + for byte in blob.chunks(8) { + let b = byte.iter().enumerate().fold(0u64, |a, (i, &x)| a | (u64::from(x) << i)); + self.put(b, 8); + } + self.uint(u32::try_from(bits - (words - 1) * 32).expect("at most 32")) + } + /// The bits packed MSB-first, the last byte zero-padded. pub(crate) fn bytes(&self) -> Vec { self.bits.chunks(8).map(|c| c.iter().enumerate().fold(0u8, |a, (i, &b)| a | (u8::from(b) << (7 - i)))).collect() } } + +/// The low `n` bits of `v`, least significant first. +fn lsb(blob: &mut Vec, v: u32, n: u32) { + for i in 0..n { + blob.push((v >> i) & 1 == 1); + } +} From c571b1346936bbe75876818cbc2f7253f92b8377 Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 10:56:03 -0400 Subject: [PATCH 151/562] M12: PRC reader: product tree and styles (#1297) Walk the PRC product tree into placed, coloured tessellations. - PrcFile::placements / placements_with(StyleAlpha, EntityOverrides): every drawable tessellation reached from the model file's roots, placed by its occurrence path's locations and then the representation item's local coordinate systems (a 4x4 Matrix) [WD 7.3.10.1, 7.6.3.2]. - Colours: styles and materials from the file structure globals, the father/son inheritance bits [WD 7.2.4.2], style transparency combined with material alpha by StyleAlpha, per-face styles and topology references via Placement::triangle_colours. A prototype's part is coloured by its own structure's styles. A colour index counts the colour array's doubles (pdf-issues #816). - Occurrences' entity references: colour, hide or relocate the entity they name, in the occurrence's subtree, everywhere, or not at all. - PrcFile::model_tree: the assembly tree as a model-tree panel lists it, with name sources, visibility and each node's placement range. - TriangleMesh::transformed applies a Matrix: positions, cofactor-mapped normals, winding flipped under a mirror. Never panics: occurrence and prototype cycles are Malformed; depth 64, 2^20 visits, MAX_PLACEMENTS and a face-override ceiling bound the walk; counts are checked against the remaining data before allocating. Markups, views, scene lights and clipping planes are Unsupported; textures are read past. Field orders the working draft leaves open follow prc-rs (MIT); each is marked `// prc-rs:`, as are the step-1 container, Double and schema choices that did. Adapted from pdfcer (MIT, same author), crates/pdfcer-3d/src/tree and vec3.rs at 0c9742b5936232af89cd707138ef149dc1f4a660. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/prc/README.md | 13 +- crates/prc/src/bits.rs | 2 + crates/prc/src/container.rs | 3 +- crates/prc/src/entity.rs | 7 +- crates/prc/src/geometry.rs | 179 ++++++++++ crates/prc/src/lib.rs | 8 +- crates/prc/src/schema.rs | 2 + crates/prc/src/tess.rs | 2 +- crates/prc/src/tess/tests.rs | 7 + crates/prc/src/tree/mod.rs | 293 ++++++++++++++++ crates/prc/src/tree/node.rs | 68 ++++ crates/prc/src/tree/overrides.rs | 128 +++++++ crates/prc/src/tree/read.rs | 451 ++++++++++++++++++++++++ crates/prc/src/tree/style.rs | 348 +++++++++++++++++++ crates/prc/src/tree/tests.rs | 576 +++++++++++++++++++++++++++++++ crates/prc/src/tree/walk.rs | 262 ++++++++++++++ 16 files changed, 2344 insertions(+), 5 deletions(-) create mode 100644 crates/prc/src/geometry.rs create mode 100644 crates/prc/src/tree/mod.rs create mode 100644 crates/prc/src/tree/node.rs create mode 100644 crates/prc/src/tree/overrides.rs create mode 100644 crates/prc/src/tree/read.rs create mode 100644 crates/prc/src/tree/style.rs create mode 100644 crates/prc/src/tree/tests.rs create mode 100644 crates/prc/src/tree/walk.rs diff --git a/crates/prc/README.md b/crates/prc/README.md index cb3865d3fd..614231cf64 100644 --- a/crates/prc/README.md +++ b/crates/prc/README.md @@ -14,6 +14,10 @@ PrcFile::parse_with_limit(data, max_inflated) -> Result FileStructure::section(SectionKind) -> &[u8] // inflated globals / tree / tessellation / geometry / extra geometry FileStructure::schema() -> Result // the globals section's leading schema FileStructure::tessellations() -> Result, PrcError> // meshes, wires, markups, compressed meshes +PrcFile::placements() / placements_with(StyleAlpha, EntityOverrides) -> Result, PrcError> +PrcFile::model_tree() -> Result, PrcError> // the assembly tree as a model-tree panel lists it +Placement::triangle_colours(&TriangleMesh) -> Option>> +TriangleMesh::transformed(&Matrix) -> TriangleMesh Schema::read(&mut BitReader) / Schema::skip_added_fields(entity_type, &mut BitReader) BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_vbn, nbits_then_unsigned, string, float_as_bytes, double ``` @@ -26,6 +30,13 @@ BitReader: bit, bits, character, unsigned_integer, integer, integer_vbn, double_ `Wire` is polylines; `Markup` is read past. - `Tessellation::Compressed` is read to its end so later entities decode; its triangles are not rebuilt yet. - A face may carry at most 8 texture-coordinate sets; more is `Malformed`. Optimised vertex colours are `Unsupported`. -- Not yet in the crate: the product tree and rebuilding compressed meshes. +- The product tree: each drawable tessellation placed by its occurrence path's locations and the item's coordinate + systems (a 4x4 `Matrix`), coloured by the style inheritance rules, with occurrences' entity references applied + (`EntityOverrides`: subtree, everywhere or ignored) and style transparency combined with material alpha by `StyleAlpha`. + A prototype's part is coloured by its own file structure's styles. Each `ModelNode` lists its name, where the name + came from, its visibility and the range of placements it drew. +- Occurrence and prototype cycles are `Malformed`; the walk is capped at depth 64, 2^20 visits and `MAX_PLACEMENTS`. +- Trees carrying markups (PMI), views, scene lights or clipping planes are `Unsupported`; textures are read past. +- Not yet in the crate: rebuilding compressed meshes, textures. - The `Double` code table's rows come from [prc-rs](https://github.com/ralovich/prc-rs) (MIT); its notice is in `src/acof/table.rs`. A few reading choices also follow prc-rs; each is marked `// prc-rs:` in the source. diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs index b4a2ff352b..a1efe7a64f 100644 --- a/crates/prc/src/bits.rs +++ b/crates/prc/src/bits.rs @@ -201,6 +201,8 @@ impl<'a> BitReader<'a> { /// [`PrcError::UnknownDoubleCode`], [`PrcError::Truncated`], or [`PrcError::Malformed`] /// for a back-reference outside the value. pub fn double(&mut self) -> Result { + // prc-rs: the decoding steps (code lookup, sign, exponent, byte-wise mantissa with + // back-references) follow prc-rs's. let mut code = 0u32; let mut entry = None; for n in 1..=acof::MAX_CODE_BITS { diff --git a/crates/prc/src/container.rs b/crates/prc/src/container.rs index 8a44921334..15c70af8fc 100644 --- a/crates/prc/src/container.rs +++ b/crates/prc/src/container.rs @@ -181,7 +181,7 @@ impl<'a> Bytes<'a> { /// A stored `u32` offset or count as a `usize` (saturating on a 16-bit target, where no data /// that large could exist). -fn offset(v: u32) -> usize { +pub(crate) fn offset(v: u32) -> usize { usize::try_from(v).unwrap_or(usize::MAX) } @@ -316,6 +316,7 @@ impl PrcFile { } let mut budget = Budget { left: limit, limit }; + // prc-rs: the last section runs to the model file's start. let last = mf_start.min(data.len()); let file_structures = descriptions.iter().map(|(id, offs)| file_structure(data, *id, &Extent { offs, last }, &mut budget)).collect::, _>>()?; diff --git a/crates/prc/src/entity.rs b/crates/prc/src/entity.rs index e6f488c8f8..1ad53dbd72 100644 --- a/crates/prc/src/entity.rs +++ b/crates/prc/src/entity.rs @@ -4,6 +4,7 @@ use crate::PrcError; use crate::bits::BitReader; use crate::schema::Schema; +use crate::tree::Graphics; const ATTRIBUTE: u32 = 201; @@ -21,11 +22,15 @@ pub(crate) struct Ctx<'a, 's> { /// The current name: what a `same_name` entity reuses; every entity's own name replaces /// it [WD 7.2.3.4]. Each section starts with none. pub(crate) name: Option, + /// The PRC unique id of the last `ContentPRCRefBase` read [WD 7.2.3.2]. + pub(crate) uid: u32, + /// The current graphics: what a `same_graphics` entity reuses [WD 7.2.4]. + pub(crate) graphics: Graphics, } impl<'a, 's> Ctx<'a, 's> { pub(crate) fn new(r: BitReader<'a>, schema: &'s Schema, version: u32) -> Self { - Ctx { r, schema, version, name: None } + Ctx { r, schema, version, name: None, uid: 0, graphics: Graphics::default() } } /// Reads an entity type code; any value but `want` is malformed. diff --git a/crates/prc/src/geometry.rs b/crates/prc/src/geometry.rs new file mode 100644 index 0000000000..3328bf10f1 --- /dev/null +++ b/crates/prc/src/geometry.rs @@ -0,0 +1,179 @@ +//! Model-space vectors and matrices, and their application to meshes. + +use crate::TriangleMesh; + +/// A point or direction in model space. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) struct Vec3(pub(crate) [f64; 3]); + +impl Vec3 { + /// The right-handed cross product `self × b`. + pub(crate) fn cross(self, b: Vec3) -> Vec3 { + let ([ax, ay, az], [bx, by, bz]) = (self.0, b.0); + Vec3([ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]) + } +} + +/// A 4×4 matrix, `m.0[row][col]`, acting on column vectors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Matrix(pub [[f64; 4]; 4]); + +impl Default for Matrix { + fn default() -> Self { + Matrix::IDENTITY + } +} + +impl Matrix { + /// The identity matrix. + pub const IDENTITY: Matrix = Matrix([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]); + + /// The entry at `row`, `col`; 0 outside the matrix. + #[must_use] + pub fn at(&self, row: usize, col: usize) -> f64 { + self.0.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0) + } + + /// `self × b`: `b` applied first. + #[must_use] + pub fn multiply(&self, b: &Matrix) -> Matrix { + let mut m = [[0.0; 4]; 4]; + for (i, row) in m.iter_mut().enumerate() { + for (j, cell) in row.iter_mut().enumerate() { + *cell = (0..4).map(|k| self.at(i, k) * b.at(k, j)).sum(); + } + } + Matrix(m) + } + + /// `self × (p, 1)`, divided by the homogeneous coordinate when it is neither 0 nor 1. + #[must_use] + pub fn transform_point(&self, p: [f64; 3]) -> [f64; 3] { + let [x, y, z] = p; + let row = |i: usize| self.at(i, 0) * x + self.at(i, 1) * y + self.at(i, 2) * z + self.at(i, 3); + let w = row(3); + let s = if w == 0.0 || w == 1.0 { 1.0 } else { 1.0 / w }; + [row(0) * s, row(1) * s, row(2) * s] + } + + /// The determinant of the upper-left 3×3 block. + fn det3(&self) -> f64 { + let a = |i, j| self.at(i, j); + a(0, 0) * (a(1, 1) * a(2, 2) - a(1, 2) * a(2, 1)) - a(0, 1) * (a(1, 0) * a(2, 2) - a(1, 2) * a(2, 0)) + + a(0, 2) * (a(1, 0) * a(2, 1) - a(1, 1) * a(2, 0)) + } + + /// Cofactor `(i, j)` of the upper-left 3×3 block (`i`, `j` < 3). + fn cofactor(&self, i: usize, j: usize) -> f64 { + let (r0, r1) = ((i + 1) % 3, (i + 2) % 3); + let (c0, c1) = ((j + 1) % 3, (j + 2) % 3); + self.at(r0, c0) * self.at(r1, c1) - self.at(r0, c1) * self.at(r1, c0) + } +} + +impl TriangleMesh { + /// This mesh with every position mapped through `m` and every stored normal through its + /// inverse transpose (renormalised); a mirroring `m` (negative determinant) reverses each + /// triangle so the outside stays counter-clockwise. + /// + /// ``` + /// use pdfcraft_prc::{Matrix, TriangleMesh}; + /// let mut mirror = Matrix::IDENTITY; + /// mirror.0[0][0] = -1.0; + /// let mut mesh = TriangleMesh::default(); + /// mesh.positions = vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + /// mesh.triangles = vec![[0, 1, 2]]; + /// let placed = mesh.transformed(&mirror); + /// assert_eq!(placed.positions, [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + /// assert_eq!(placed.triangles, [[0, 2, 1]]); + /// ``` + #[must_use] + pub fn transformed(&self, m: &Matrix) -> Self { + let mut out = self.clone(); + for p in &mut out.positions { + *p = m.transform_point(*p); + } + // The cofactor matrix is det times the inverse transpose, so its sign is corrected + // and the result renormalised. + let det = m.det3(); + let sign = if det < 0.0 { -1.0 } else { 1.0 }; + for n in &mut out.normals { + let [x, y, z] = *n; + let v = [0, 1, 2].map(|i| sign * (m.cofactor(i, 0) * x + m.cofactor(i, 1) * y + m.cofactor(i, 2) * z)); + let len = v.iter().map(|c| c * c).sum::().sqrt(); + *n = if len > 0.0 && len.is_finite() { v.map(|c| c / len) } else { v }; + } + if det < 0.0 { + for t in &mut out.triangles { + t.swap(1, 2); + } + for t in &mut out.triangle_normals { + t.swap(1, 2); + } + for t in out.triangle_uvs.iter_mut().flatten().flatten() { + t.swap(1, 2); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_follows_the_right_hand_rule() { + assert_eq!(Vec3([1.0, 0.0, 0.0]).cross(Vec3([0.0, 1.0, 0.0])), Vec3([0.0, 0.0, 1.0])); + assert_eq!(Vec3([0.0, 1.0, 0.0]).cross(Vec3([1.0, 0.0, 0.0])), Vec3([0.0, 0.0, -1.0])); + } + + #[test] + fn multiply_applies_the_right_operand_first() { + let mut t = Matrix::IDENTITY; + t.0[0][3] = 5.0; + let mut s = Matrix::IDENTITY; + s.0[0][0] = 2.0; + assert_eq!(t.multiply(&s).transform_point([1.0, 0.0, 0.0]), [7.0, 0.0, 0.0]); + assert_eq!(s.multiply(&t).transform_point([1.0, 0.0, 0.0]), [12.0, 0.0, 0.0]); + } + + #[test] + fn a_projective_point_is_divided_by_w() { + let mut m = Matrix::IDENTITY; + m.0[3][3] = 2.0; + assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [1.0, 2.0, 3.0]); + m.0[3][3] = 0.0; + assert_eq!(m.transform_point([2.0, 4.0, 6.0]), [2.0, 4.0, 6.0]); + } + + #[test] + fn normals_take_the_inverse_transpose_and_mirror_with_their_triangle() { + // A shear in x by y turns the plane x = 0 into x = y; its x normal tilts to stay perpendicular. + let mut shear = Matrix::IDENTITY; + shear.0[0][1] = 1.0; + let mesh = TriangleMesh { + positions: vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], + triangles: vec![[0, 1, 2]], + normals: vec![[1.0, 0.0, 0.0]], + triangle_normals: vec![[0, 0, 0]], + ..TriangleMesh::default() + }; + let n = shear.transformed_normal(&mesh); + let s = std::f64::consts::FRAC_1_SQRT_2; + assert!((n[0] - s).abs() < 1e-12 && (n[1] + s).abs() < 1e-12 && n[2] == 0.0, "{n:?}"); + + let mut mirror = Matrix::IDENTITY; + mirror.0[2][2] = -1.0; + let placed = mesh.transformed(&mirror); + assert_eq!(placed.triangles, [[0, 2, 1]]); + assert_eq!(placed.triangle_normals, [[0, 0, 0]]); + assert_eq!(placed.normals, [[1.0, 0.0, 0.0]]); + } + + impl Matrix { + fn transformed_normal(&self, mesh: &TriangleMesh) -> [f64; 3] { + mesh.transformed(self).normals.first().copied().unwrap_or_default() + } + } +} diff --git a/crates/prc/src/lib.rs b/crates/prc/src/lib.rs index fd49302234..8e1a749944 100644 --- a/crates/prc/src/lib.rs +++ b/crates/prc/src/lib.rs @@ -3,7 +3,9 @@ //! //! Layer L0, standalone: this crate depends on no other PdfCraft crate. It reads the //! uncompressed container, inflates each section, and decodes the bit-level primitives and -//! the schema a newer producer uses to describe fields this reader does not know. +//! the schema a newer producer uses to describe fields this reader does not know; it decodes +//! tessellations and walks the product tree into placed, coloured meshes +//! ([`PrcFile::placements`], [`PrcFile::model_tree`]). //! //! Clause numbers in `[WD x.y]` refer to the PRC working draft, ISO/TC 171/SC 2 N570. //! @@ -18,13 +20,17 @@ mod bits; mod container; mod entity; mod error; +mod geometry; mod schema; mod tess; #[cfg(test)] mod testw; +mod tree; pub use bits::BitReader; pub use container::{FileStructure, MAX_FILE_STRUCTURES, MAX_INFLATED_BYTES, PRC_READER_VERSION, PrcFile, PrcHeader, SectionKind, UniqueId}; pub use error::PrcError; +pub use geometry::Matrix; pub use schema::Schema; pub use tess::{Tessellation, TriangleMesh, TriangleStyle}; +pub use tree::{EntityOverrides, MAX_PLACEMENTS, ModelNode, NameSource, Placement, Rgba, StyleAlpha}; diff --git a/crates/prc/src/schema.rs b/crates/prc/src/schema.rs index 686cc8e056..ba58474f3d 100644 --- a/crates/prc/src/schema.rs +++ b/crates/prc/src/schema.rs @@ -213,6 +213,8 @@ impl Exec<'_, '_, '_> { self.repeat(prog, pos, count, run, depth)?; } 16 => { + // prc-rs: reads SimpleFor's count as an Integer; this reader deliberately reads an + // UnsignedInteger (module doc). let count = if run { i64::from(self.r.unsigned_integer()?) } else { 0 }; self.repeat(prog, pos, count, run, depth)?; } diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs index 42704de652..f41ea0a260 100644 --- a/crates/prc/src/tess.rs +++ b/crates/prc/src/tess.rs @@ -416,4 +416,4 @@ fn point_at(pts: &[[f64; 3]], idx: u32) -> Result<[f64; 3], PrcError> { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs index ee202e6a2a..ad7f497e82 100644 --- a/crates/prc/src/tess/tests.rs +++ b/crates/prc/src/tess/tests.rs @@ -223,6 +223,13 @@ fn too_many_texture_sets_are_refused_before_anything_is_allocated() { assert!(decode(§ion(1, &ok), &Schema::default(), 8137).is_ok()); } +/// A tessellation section holding one TESS_3D: [`SQUARE`] as two faces of one triangle each. +pub(crate) fn two_face_square() -> W { + let mut b = W::default(); + tess_3d(&mut b, 8137, &Mesh { indices: &[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], faces: &[(0x2, 0, &[1]), (0x2, 6, &[1])], ..Mesh::default() }); + section(1, &b) +} + #[test] fn two_faces_partition_the_triangles() { let m = one(&[0, 0, 0, 3, 0, 6, 0, 0, 0, 6, 0, 9], &[(0x2, 0, &[1]), (0x2, 6, &[1])], 0); diff --git a/crates/prc/src/tree/mod.rs b/crates/prc/src/tree/mod.rs new file mode 100644 index 0000000000..6967ba40ea --- /dev/null +++ b/crates/prc/src/tree/mod.rs @@ -0,0 +1,293 @@ +//! The product tree: the tree section (`FileStructureTree`, 304), the globals' styles and +//! coordinate systems (303) and the model file (301), walked into placed tessellations +//! [WD 7.3.3-7.3.11, 7.5, 7.6]. +//! +//! Markups, views, scene lights and clipping planes are not read past; a tree carrying any +//! of them is [`PrcError::Unsupported`]. + +// prc-rs: where the working draft leaves an entity's field order open, the order here is +// prc-rs's format description's; each such entity's reader carries its own marker. + +use std::sync::Arc; + +use crate::bits::BitReader; +use crate::container::offset; +use crate::entity::{Ctx, malformed}; +use crate::geometry::{Matrix, Vec3}; +use crate::{PrcError, PrcFile, Schema, SectionKind, UniqueId}; + +mod node; +mod overrides; +mod read; +mod style; +mod walk; + +pub use node::{ModelNode, NameSource}; +pub use overrides::EntityOverrides; +use overrides::{Override, Target}; +pub(crate) use style::Graphics; +use style::{Globals, Paint, resolve_style}; +pub use style::{Rgba, StyleAlpha}; +use walk::{At, Walk}; + +const BASE_WITH_GRAPHICS: u32 = 2; +const MODEL_FILE: u32 = 301; +const FILE_STRUCTURE: u32 = 302; +const FILE_STRUCTURE_GLOBALS: u32 = 303; +const FILE_STRUCTURE_TREE: u32 = 304; +const PRODUCT_OCCURRENCE: u32 = 310; +const PART_DEFINITION: u32 = 311; +const FILTER: u32 = 320; +const CARTESIAN_TRANSFORMATION: u32 = 202; +const ENTITY_REFERENCE: u32 = 203; +const REFERENCE_ON_PRC_BASE: u32 = 205; +const REFERENCE_ON_TOPOLOGY: u32 = 206; +const GENERAL_TRANSFORMATION: u32 = 207; +const RI_BREP_MODEL: u32 = 232; +const RI_CURVE: u32 = 233; +const RI_DIRECTION: u32 = 234; +const RI_PLANE: u32 = 235; +const RI_POINT_SET: u32 = 236; +const RI_POLY_BREP_MODEL: u32 = 237; +const RI_POLY_WIRE: u32 = 238; +const RI_SET: u32 = 239; +const RI_COORDINATE_SYSTEM: u32 = 240; +const STYLE: u32 = 701; +const MATERIAL: u32 = 702; +const PICTURE: u32 = 703; +const TEXTURE_APPLICATION: u32 = 711; +const TEXTURE_DEFINITION: u32 = 712; +const TEXTURE_TRANSFORMATION: u32 = 713; +const LINE_PATTERN: u32 = 721; +const SCENE_DISPLAY_PARAMETERS: u32 = 741; +const CAMERA: u32 = 742; +/// prc-rs: a scene's `is_absolute` exists from this authoring version. +const ABSOLUTE_SCENE_FROM: u32 = 8137; +/// `TextureDefinition` mapping type "operator" and function "blend", 0-based +/// (pdf-issues #485). +const TEXTURE_MAPPING_OPERATOR: i32 = 3; +const TEXTURE_FUNCTION_BLEND: i32 = 3; +/// Texture application mode: alpha test [WD 7.5.7]. +const TEXTURE_ALPHA_TEST: u8 = 0x02; + +/// Ceiling on `RI_Set` nesting, the occurrence walk's depth and a prototype chain's length. +const MAX_DEPTH: usize = 64; +/// Ceiling on occurrences visited in one walk: prototypes share subtrees, so a small file can +/// describe an exponential walk. +const MAX_VISITS: usize = 1 << 20; +/// Ceiling on placements one walk yields. +pub const MAX_PLACEMENTS: usize = 1 << 20; + +/// `GraphicsContent` behaviour bits [WD 7.2.4.2]. +const SHOW: u16 = 0x0001; +const SON_HERIT_COLOR: u16 = 0x0008; +const FATHER_HERIT_COLOR: u16 = 0x0010; +const REMOVED: u16 = 0x2000; +/// `product_behavior` SUPPRESSED [WD 7.3.10]. +const SUPPRESSED: u8 = 0x01; + +/// Per file structure, entry `b` is the paint of biased style `b`. +type Palettes = Arc<[Arc<[Option]>]>; + +/// A tessellation drawn at a place in the model: world = [`Self::matrix`] × the +/// tessellation's own coordinates. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Placement { + /// Index into [`PrcFile::file_structures`]. + pub file_structure: usize, + /// Index into that file structure's [`tessellations`](crate::FileStructure::tessellations). + pub tessellation: usize, + /// The occurrence path's locations, then the representation item's coordinate systems. + pub matrix: Matrix, + /// The colour of the style the tree resolves for this item; `None` when no style + /// reaches it or the style names no colour this reader resolves (a textured material + /// without a base). Faces with their own style are coloured by + /// [`Self::triangle_colours`]. + pub colour: Option, + /// The graphics from the root to this item, outermost first. + chain: Vec, + palettes: Palettes, + /// An occurrence's entity reference replacing the item's style. + item_override: Option, + /// Per face index, sorted, an entity reference replacing that face's style. + face_overrides: Vec<(u32, Graphics)>, +} + +/// The paint of the style `won` names, in its file structure's globals. +fn paint_of(palettes: &Palettes, won: Graphics) -> Option { + palettes.get(won.fs)?.get(offset(won.style)).copied().flatten() +} + +impl Placement { + /// Per triangle of `mesh` (this placement's tessellation), its colour as + /// [`Self::colour`] describes, with each face's own style taking part in the inheritance + /// [WD 7.2.4, 7.8.6]; `None` when no face of `mesh` carries a style or an override, so + /// every triangle is [`Self::colour`]. + #[must_use] + pub fn triangle_colours(&self, mesh: &crate::TriangleMesh) -> Option>> { + if mesh.triangle_styles.is_empty() && self.face_overrides.is_empty() { + return None; + } + let faces = overrides::triangle_faces(mesh); + let by_face = |k: usize| { + let f = faces.get(k)?; + let i = self.face_overrides.binary_search_by_key(f, |(i, _)| *i).ok()?; + self.face_overrides.get(i).map(|(_, g)| *g) + }; + let colour = |won| paint_of(&self.palettes, won).map(|p| p.rgba); + if mesh.triangle_styles.is_empty() { + let whole = self.item_override.unwrap_or_else(|| resolve_style(&self.chain)); + return Some((0..mesh.triangles.len()).map(|k| colour(by_face(k).unwrap_or(whole))).collect()); + } + let mut chain = self.chain.clone(); + chain.push(Graphics::default()); + let looks = mesh.triangle_styles.iter().enumerate().map(|(k, s)| { + if let Some(won) = by_face(k).or(self.item_override) { + return colour(won); + } + if let Some(last) = chain.last_mut() { + *last = Graphics { style: s.style, bits: s.behaviour, fs: self.file_structure }; + } + colour(resolve_style(&chain)) + }); + Some(looks.collect()) + } +} + +/// A representation item that can be drawn, flattened out of any `RI_Set`. +#[derive(Debug, Clone, PartialEq)] +struct Item { + /// `index_local_coordinate_system + 1` of each enclosing set and the item, outermost + /// first; 0 entries dropped. + local: Vec, + /// `index_tessellation + 1`; never 0 here. + tessellation: u32, + /// The part's graphics, each enclosing set's, then the item's own. + graphics: Vec, + /// The PRC unique id of each enclosing set, then the item's own. + uids: Vec, + /// A B-rep model's stored `(context, body)` ids [WD 7.6.2]. + brep: Option<(u32, u32)>, +} + +/// A `ProductOccurrence` (310) as far as placing and naming. +#[derive(Debug, Clone, PartialEq, Default)] +struct Product { + part: u32, + prototype: u32, + prototype_fs: Option, + external: u32, + external_fs: Option, + sons: Vec, + /// Its own graphics hide it (Show clear or Removed set). + hidden: bool, + suppressed: bool, + graphics: Graphics, + location: Option, + name: Option, + /// Its entity references that name a target. + overrides: Vec, +} + +/// One file structure's tree section. +#[derive(Debug, Clone, PartialEq, Default)] +struct Tree { + parts: Vec>, + /// Each part definition's name, parallel to `parts`. + part_names: Vec>, + products: Vec, +} + +/// The model file's roots: file structure id and biased occurrence index. +#[derive(Debug, Clone, PartialEq)] +struct ModelFile { + roots: Vec<(UniqueId, u32)>, +} + +impl PrcFile { + /// Where each drawable tessellation sits in the model: the product-occurrence tree walked + /// from the model file's roots, each occurrence's location composed onto its father's, + /// and each representation item's local coordinate systems applied last [WD 7.3.10.1, + /// 7.6.3.2], coloured with [`StyleAlpha::ZeroUnset`] and entity references applied by + /// [`EntityOverrides::Subtree`]. Suppressed occurrences and hidden entities are left + /// out; a tessellation several occurrences draw appears once per occurrence. Empty when + /// the file stores no tree ([`Self::stores_tree`]). + /// + /// # Errors + /// [`PrcError::Unsupported`] for a tree carrying markups (PMI), views, scene lights or + /// clipping planes, or, when an item is placed by a reference coordinate system, globals + /// carrying fonts or fill patterns; [`PrcError::Malformed`] for an index naming no entity, + /// a cycle of occurrences or prototypes, or a tree past [`MAX_PLACEMENTS`] or the depth + /// ceiling; read errors as [`crate::FileStructure::tessellations`]. + pub fn placements(&self) -> Result, PrcError> { + self.placements_with(StyleAlpha::default(), EntityOverrides::default()) + } + + /// [`Self::placements`], combining style transparency with material alpha by `rule` and + /// applying occurrences' entity references by `scope`. + /// + /// # Errors + /// As [`Self::placements`]. + pub fn placements_with(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result, PrcError> { + Ok(self.walk(rule, scope)?.0) + } + + /// The assembly tree as a model-tree panel lists it: every product occurrence the model + /// file's roots reach, depth first, with its display name and stored visibility. Hidden + /// and suppressed subtrees are listed (with `drawn` false) though [`Self::placements`] + /// leaves them out; each node's `placements` range indexes that list, so attributes each + /// placed mesh to its part. Empty when the file stores no tree. + /// + /// # Errors + /// As [`Self::placements`]. + pub fn model_tree(&self) -> Result, PrcError> { + Ok(self.walk(StyleAlpha::default(), EntityOverrides::default())?.1) + } + + /// Whether the file stores a tree: its model file holds an entity. ISO 14739-1 requires + /// one, but a tessellation-only file still draws (unplaced), so its absence reads as an + /// empty tree, not an error. + #[must_use] + pub fn stores_tree(&self) -> bool { + self.model_file.iter().any(|&b| b != 0) + } + + fn walk(&self, rule: StyleAlpha, scope: EntityOverrides) -> Result<(Vec, Vec), PrcError> { + if !self.stores_tree() { + return Ok((Vec::new(), Vec::new())); + } + let mut trees = Vec::with_capacity(self.file_structures.len()); + for fs in &self.file_structures { + let schema = fs.schema()?; + let tree = Ctx::new(BitReader::new(fs.section(SectionKind::Tree)), &schema, fs.authoring_version).file_structure_tree()?; + // The globals carry the coordinate systems and the colours; colours alone are not + // worth refusing the placements over. + let globals = (|| { + let mut r = BitReader::new(fs.section(SectionKind::Globals)); + Schema::read(&mut r)?; + Ctx::new(r, &schema, fs.authoring_version).globals() + })(); + let globals = match globals { + Ok(g) => g, + Err(e) if tree.parts.iter().flatten().any(|i| !i.local.is_empty()) => return Err(e), + Err(_) => Globals::default(), + }; + trees.push((fs.id, tree, globals)); + } + let mut r = BitReader::new(&self.model_file); + let schema = Schema::read(&mut r)?; + let model = Ctx::new(r, &schema, self.header.authoring_version).model_file()?; + let mut walk = Walk::new(trees.iter().map(|(id, t, g)| (*id, t, g)).collect(), rule, scope); + for (id, root) in model.roots { + let fs = trees.iter().position(|t| t.0 == id).ok_or_else(|| malformed("a root names an unknown file structure".into()))?; + if let Some(index) = root.checked_sub(1) { + walk.occurrence(fs, offset(index), &Matrix::IDENTITY, &[], At::ROOT)?; + } + } + Ok((walk.out, walk.nodes)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tree/node.rs b/crates/prc/src/tree/node.rs new file mode 100644 index 0000000000..19a8e04e5c --- /dev/null +++ b/crates/prc/src/tree/node.rs @@ -0,0 +1,68 @@ +//! The assembly tree as a list: what a model-tree panel shows. + +use std::ops::Range; + +/// Where a [`ModelNode`]'s name came from. PRC does not say which name a +/// viewer should show; this reader takes the first present of these, in order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum NameSource { + /// The occurrence's own name. + Occurrence, + /// The name of an occurrence in its prototype chain. + Prototype, + /// The name of the part definition it draws. + Part, + /// No name anywhere: [`ModelNode::name`] is `None`. + Unnamed, +} + +/// One product occurrence of a PRC model's assembly tree, as +/// [`PrcFile::model_tree`](crate::PrcFile::model_tree) lists it: depth +/// first, a parent before its children, children in file order. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ModelNode { + /// The name to show, by [`Self::name_from`]; `None` when unnamed. + pub name: Option, + /// Where [`Self::name`] came from. + pub name_from: NameSource, + /// Index of the parent node in the list; `None` for a root. + pub parent: Option, + /// 0 for a root. + pub depth: usize, + /// Index into [`crate::PrcFile::file_structures`] of the structure + /// holding this occurrence. + pub file_structure: usize, + /// The occurrence's 0-based index in that structure's tree. + pub occurrence: usize, + /// The occurrence's own graphics hide it (Show clear, or Removed) + /// [WD 7.2.4.2]: the visibility the file stores, which a viewer opens on. + pub hidden: bool, + /// The occurrence is suppressed (`product_behavior`) [WD 7.3.10]. + pub suppressed: bool, + /// Whether anything of this node is drawn: false when it or an + /// ancestor is hidden or suppressed. + pub drawn: bool, + /// Whether the occurrence (or its prototype) carries a part definition, + /// i.e. geometry of its own rather than only children. + pub has_part: bool, + /// The placements this node and its descendants draw: indices into + /// [`PrcFile::placements`](crate::PrcFile::placements) (or + /// [`placements_with`](crate::PrcFile::placements_with), which yields the same order): + /// the meshes attributed to this occurrence's part and its descendants' parts. Empty when + /// nothing under it is drawn. + pub placements: Range, +} + +impl ModelNode { + /// The label the node is listed under: its name, or `occurrence F:I` + /// (file structure, occurrence) when it has none. + #[must_use] + pub fn label(&self) -> String { + match &self.name { + Some(name) => name.clone(), + None => format!("occurrence {}:{}", self.file_structure, self.occurrence), + } + } +} diff --git a/crates/prc/src/tree/overrides.rs b/crates/prc/src/tree/overrides.rs new file mode 100644 index 0000000000..a85a83ba09 --- /dev/null +++ b/crates/prc/src/tree/overrides.rs @@ -0,0 +1,128 @@ +//! Product-occurrence entity references: the colour, visibility and placement an occurrence +//! imposes on an entity it reaches [WD 7.3.10.1, 7.4.4]. + +use super::{Graphics, Item, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, UniqueId}; + +/// `PRC_TYPE_TOPO_Face` [WD 6.2]. +const TOPO_FACE: u32 = 149; + +/// Which entities an occurrence's entity references reach. ISO 14739-1 says a reference +/// overrides the referenced entity's properties, and not where. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum EntityOverrides { + /// Only where the occurrence's own subtree places the target, so one instance of a + /// shared part can be coloured alone. + #[default] + Subtree, + /// Every placement of the target anywhere in the model. + Everywhere, + /// Read past them: every entity keeps its own and inherited graphics. + Ignore, +} + +/// One `MISC_EntityReference` held by a product occurrence. +#[derive(Debug, Clone, PartialEq)] +pub(super) struct Override { + /// The reference's graphics; style 0 leaves the target's colour alone. + pub(super) graphics: Graphics, + /// The graphics hide the target (Show clear or Removed set). + pub(super) hidden: bool, + /// `index_local_coordinate_system + 1` replacing the target's; 0 none. + pub(super) local: u32, + pub(super) target: Target, +} + +/// What an entity reference names. +#[derive(Debug, Clone, PartialEq)] +pub(super) enum Target { + /// `ReferenceOnPRCBase`: the entity of type `kind` whose PRC unique id is `uid` in + /// structure `fs` (`None`: the occurrence's own). + Entity { kind: u32, fs: Option, uid: u32 }, + /// `ReferenceOnTopology` into the B-rep stored as `(context, body)`: its topological + /// items of type `kind` at `indices` (0-based). With `fs` `None` it matches that B-rep in + /// any structure the subtree reaches: producers write "same structure" for an assembly's + /// reference into a part's own structure. + Topology { kind: u32, fs: Option, brep: (u32, u32), indices: Vec }, +} + +impl Target { + /// The structure the reference names, if it names one. + pub(super) fn fs(&self) -> Option { + match self { + Target::Entity { fs, .. } | Target::Topology { fs, .. } => *fs, + } + } +} + +/// An entity reference in force: the structure of the occurrence holding it, the structure +/// it names (`None`: any), and the reference. +pub(super) type Active<'t> = (usize, Option, &'t Override); + +/// The overrides that reach one representation item. +#[derive(Debug, Default)] +pub(super) struct Reached { + /// Graphics replacing the item's resolved style, in its owner's structure. + pub(super) item: Option, + pub(super) hidden: bool, + /// The owner's structure and its local coordinate system. + pub(super) local: Option<(usize, u32)>, + /// Per face index, sorted, graphics replacing that face's style. + pub(super) faces: Vec<(u32, Graphics)>, +} + +impl Reached { + /// Applies `active` innermost first, so an outer occurrence's override wins over an + /// inner one's, as an assembly's appearance beats its sub-assembly's. + pub(super) fn collect(active: &[Active<'_>], fs: usize, item: &Item) -> Self { + let mut r = Reached::default(); + // A map, not a list searched per index: one reference may name millions of faces. + let mut faces = std::collections::BTreeMap::new(); + for &(owner, named, ov) in active.iter().rev() { + let in_fs = named.is_none_or(|n| n == fs); + let g = Graphics { fs: owner, ..ov.graphics }; + match &ov.target { + Target::Entity { kind, uid, .. } => { + if !in_fs || !is_item(*kind) || !item.uids.contains(uid) { + continue; + } + if ov.graphics.style != 0 { + r.item = Some(g); + } + r.hidden = ov.hidden; + if ov.local != 0 { + r.local = Some((owner, ov.local)); + } + } + Target::Topology { kind, brep, indices, .. } => { + if !in_fs || *kind != TOPO_FACE || item.brep != Some(*brep) || ov.graphics.style == 0 { + continue; + } + for &f in indices { + faces.insert(f, g); + } + } + } + } + r.faces = faces.into_iter().collect(); + r + } +} + +/// Representation-item types a `ReferenceOnPRCBase` can colour. +fn is_item(kind: u32) -> bool { + (RI_BREP_MODEL..=RI_COORDINATE_SYSTEM).contains(&kind) +} + +/// Per triangle of `mesh`, the index of the `TESS_Face` it was read from (`u32::MAX` for +/// none). +pub(super) fn triangle_faces(mesh: &crate::TriangleMesh) -> Vec { + let mut out = vec![u32::MAX; mesh.triangles.len()]; + for (i, range) in mesh.faces.iter().enumerate() { + let face = u32::try_from(i).unwrap_or(u32::MAX); + for f in out.get_mut(range.clone()).into_iter().flatten() { + *f = face; + } + } + out +} diff --git a/crates/prc/src/tree/read.rs b/crates/prc/src/tree/read.rs new file mode 100644 index 0000000000..6c8047b989 --- /dev/null +++ b/crates/prc/src/tree/read.rs @@ -0,0 +1,451 @@ +//! The tree section's and the model file's entities: part definitions, representation items, +//! product occurrences and their transformations and entity references [WD 7.3, 7.4, 7.6]. + +use super::{ + ABSOLUTE_SCENE_FROM, BASE_WITH_GRAPHICS, CAMERA, CARTESIAN_TRANSFORMATION, ENTITY_REFERENCE, FILE_STRUCTURE, FILE_STRUCTURE_TREE, FILTER, + GENERAL_TRANSFORMATION, Graphics, Item, MAX_DEPTH, MODEL_FILE, Matrix, ModelFile, Override, PART_DEFINITION, PRODUCT_OCCURRENCE, Product, + REFERENCE_ON_PRC_BASE, REFERENCE_ON_TOPOLOGY, REMOVED, RI_BREP_MODEL, RI_COORDINATE_SYSTEM, RI_CURVE, RI_DIRECTION, RI_PLANE, RI_POINT_SET, + RI_POLY_BREP_MODEL, RI_POLY_WIRE, RI_SET, SCENE_DISPLAY_PARAMETERS, SHOW, SUPPRESSED, Target, Tree, UniqueId, Vec3, +}; +use crate::PrcError; +use crate::entity::{Ctx, malformed}; + +impl Ctx<'_, '_> { + pub(super) fn vector3(&mut self) -> Result { + Ok(Vec3([self.r.double()?, self.r.double()?, self.r.double()?])) + } + + pub(super) fn unique_id(&mut self) -> Result { + let mut id = [0; 4]; + for v in &mut id { + *v = self.r.unsigned_integer()?; + } + Ok(UniqueId(id)) + } + + /// `ContentPRCRefBase` [WD 7.2.3.2; pdf-issues #697]: the base, then the CAD ids and the + /// PRC unique id, which becomes [`Ctx::uid`]. + pub(super) fn content_prc_ref_base(&mut self) -> Result<(), PrcError> { + self.content_prc_base()?; + self.r.unsigned_integer()?; // CAD identifier + self.r.unsigned_integer()?; // CAD persistent identifier + self.uid = self.r.unsigned_integer()?; + Ok(()) + } + + /// `PRCBaseWithGraphics` [WD 7.2.4; pdf-issues #405]: whether the entity's own graphics + /// hide it (Show clear or Removed set), and its graphics. Reused graphics + /// (`same_graphics`) are the current graphics and never hide. + pub(super) fn base_with_graphics(&mut self) -> Result<(bool, Graphics), PrcError> { + self.content_prc_ref_base()?; + let hidden = if self.r.bit()? { + false + } else { + self.r.unsigned_integer()?; // layer + 1 + let style = self.r.unsigned_integer()?; + let lo = u16::from(self.r.character()?); + let hi = u16::from(self.r.character()?); + let bits = lo | hi << 8; + self.graphics = Graphics { style, bits, fs: 0 }; + bits & SHOW == 0 || bits & REMOVED != 0 + }; + // prc-rs: schema additions to the base itself follow the graphics. + self.schema.skip_added_fields(BASE_WITH_GRAPHICS, &mut self.r)?; + Ok((hidden, self.graphics)) + } + + /// `CartesianTransformation` (202) or `GeneralTransformation` (207), type code included + /// [WD 7.4.8, 7.4.9, 7.4.11; pdf-issues #430]. + pub(super) fn transformation(&mut self) -> Result { + let t = self.r.unsigned_integer()?; + let m = match t { + GENERAL_TRANSFORMATION => { + // Column-major [WD 7.4.9]. + let mut m = Matrix::IDENTITY; + for col in 0..4 { + for row in &mut m.0 { + if let Some(c) = row.get_mut(col) { + *c = self.r.double()?; + } + } + } + m + } + CARTESIAN_TRANSFORMATION => self.transformation_3d()?, + t => return Err(malformed(format!("entity type {t} as a transformation"))), + }; + self.schema.skip_added_fields(t, &mut self.r)?; + Ok(m) + } + + /// The embedded `Transformation3D` [WD 7.4.11.1]: a behaviour byte, then each field its + /// bits name. + fn transformation_3d(&mut self) -> Result { + let b = self.r.character()?; + let mut m = Matrix::IDENTITY; + let set_col = |m: &mut Matrix, col: usize, v: Vec3| { + for (row, x) in m.0.iter_mut().zip(v.0) { + if let Some(c) = row.get_mut(col) { + *c = x; + } + } + }; + if b & 0x01 != 0 { + let t = self.vector3()?; + set_col(&mut m, 3, t); + } + if b & 0x20 != 0 { + for col in 0..3 { + let v = self.vector3()?; + set_col(&mut m, col, v); + } + } else if b & 0x02 != 0 { + let x = self.vector3()?; + let y = self.vector3()?; + // Mirrored (0x04): the third axis is y × x. + let z = if b & 0x04 != 0 { y.cross(x) } else { x.cross(y) }; + set_col(&mut m, 0, x); + set_col(&mut m, 1, y); + set_col(&mut m, 2, z); + } + let mut scale = [1.0; 3]; + if b & 0x10 != 0 { + scale = self.vector3()?.0; + } + // prc-rs: with both scale bits set, both are read and multiply. + if b & 0x08 != 0 { + let s = self.r.double()?; + scale = scale.map(|v| v * s); + } + for row in m.0.iter_mut().take(3) { + for (c, s) in row.iter_mut().zip(scale) { + *c *= s; + } + } + if b & 0x40 != 0 { + let h = [self.r.double()?, self.r.double()?, self.r.double()?, self.r.double()?]; + if let Some(r) = m.0.get_mut(3) { + *r = h; + } + } + Ok(m) + } + + /// A polymorphic representation item [WD 7.6]; drawable ones are pushed onto `out` with + /// `local` (the enclosing sets' coordinate systems) prefixed. + // prc-rs: the item's own fields follow its local coordinate system and tessellation. + fn representation_item(&mut self, at: &Item, out: &mut Vec, depth: usize) -> Result<(), PrcError> { + if depth > MAX_DEPTH { + return Err(malformed("representation sets nest too deep".into())); + } + let t = self.r.unsigned_integer()?; + let (hidden, own) = self.base_with_graphics()?; + let mut item = at.clone(); + item.uids.push(self.uid); + item.graphics.push(own); + let cs = self.r.unsigned_integer()?; + let tess = self.r.unsigned_integer()?; + if cs != 0 { + item.local.push(cs); + } + let (drawable, brep) = if t == RI_SET { + let n = self.count(1, "representation set")?; + let mut members = Vec::new(); + for _ in 0..n { + self.representation_item(&item, &mut members, depth + 1)?; + } + if !hidden { + out.extend(members); + } + (false, None) + } else { + self.leaf_item_fields(t)? + }; + self.schema.skip_added_fields(t, &mut self.r)?; + self.user_data()?; + if drawable && !hidden && tess != 0 { + out.push(Item { tessellation: tess, brep, ..item }); + } + Ok(()) + } + + /// The type-specific fields of a representation item other than `RI_Set` [WD 7.6]; + /// whether the item can be drawn, and a B-rep model's `(context, body)`. + fn leaf_item_fields(&mut self, t: u32) -> Result<(bool, Option<(u32, u32)>), PrcError> { + match t { + RI_BREP_MODEL => { + let brep = self.exact_tolerance()?; + self.r.bit()?; // is_closed + return Ok((true, brep)); + } + RI_CURVE | RI_PLANE => { + self.exact_tolerance()?; + } + RI_DIRECTION => { + if self.r.bit()? { + self.vector3()?; // origin + } + self.vector3()?; + return Ok((false, None)); + } + RI_POINT_SET => { + let n = self.count(6, "point set")?; + for _ in 0..n { + self.vector3()?; + } + } + RI_POLY_BREP_MODEL => { + self.r.bit()?; // is_closed + } + RI_POLY_WIRE => {} + RI_COORDINATE_SYSTEM => { + self.transformation()?; + return Ok((false, None)); + } + t => return Err(malformed(format!("entity type {t} as a representation item"))), + } + Ok((true, None)) + } + + /// A B-rep, curve or plane item's optional exact-geometry `(context, body)` ids + /// [WD 7.6.2]. + fn exact_tolerance(&mut self) -> Result, PrcError> { + if self.r.bit()? { + return Ok(Some((self.r.unsigned_integer()?, self.r.unsigned_integer()?))); + } + Ok(None) + } + + /// `SceneDisplayParameters` (741) [WD 7.5.19]: read past. Lights and clipping planes are + /// [`PrcError::Unsupported`]. + // prc-rs: the scene's field order, and `is_absolute` from version 8137. + fn scene_display_parameters(&mut self) -> Result<(), PrcError> { + self.expect_type(SCENE_DISPLAY_PARAMETERS)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_active + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("scene lights")); + } + if self.r.bit()? { + self.camera()?; + } + if self.r.bit()? { + self.vector3()?; // rotation centre + } + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("scene clipping planes")); + } + self.r.unsigned_integer()?; // background style + 1 + self.r.unsigned_integer()?; // default style + 1 + let n = self.count(2, "scene default styles")?; + for _ in 0..n { + self.r.unsigned_integer()?; // entity type + self.r.unsigned_integer()?; // style + 1 + } + if self.version >= ABSOLUTE_SCENE_FROM { + self.r.bit()?; // is_absolute + } + self.schema.skip_added_fields(SCENE_DISPLAY_PARAMETERS, &mut self.r) + } + + /// `Camera` (742) [WD 7.5.20]: read past. + // prc-rs: the camera's field order. + fn camera(&mut self) -> Result<(), PrcError> { + self.expect_type(CAMERA)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_orthographic + for _ in 0..3 { + self.vector3()?; // position, look, up + } + for _ in 0..6 { + self.r.double()?; // x, y, ratio, near, far, zoom + } + self.schema.skip_added_fields(CAMERA, &mut self.r) + } + + /// `MarkupData` [WD 7.3.10.4]: only the empty form reads past. + fn markup_data(&mut self) -> Result<(), PrcError> { + for _ in 0..4 { + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("tree markups (PMI)")); + } + } + Ok(()) + } + + fn views(&mut self) -> Result<(), PrcError> { + if self.r.unsigned_integer()? != 0 { + return Err(PrcError::Unsupported("tree views")); + } + Ok(()) + } + + /// `MISC_EntityReference` (203) [WD 7.4.4, 7.4.10]; `None` when it names no target. + // prc-rs: the local coordinate system precedes the optional reference data. + fn entity_reference(&mut self) -> Result, PrcError> { + self.expect_type(ENTITY_REFERENCE)?; + let (hidden, graphics) = self.base_with_graphics()?; + let local = self.r.unsigned_integer()?; + let mut target = None; + if self.r.bit()? { + match self.r.unsigned_integer()? { + REFERENCE_ON_PRC_BASE => { + let kind = self.r.unsigned_integer()?; + let fs = self.file_identifier()?; + let uid = self.r.unsigned_integer()?; + target = Some(Target::Entity { kind, fs, uid }); + } + REFERENCE_ON_TOPOLOGY => target = self.reference_on_topology()?, + t => return Err(malformed(format!("entity type {t} as reference data"))), + } + } + self.schema.skip_added_fields(ENTITY_REFERENCE, &mut self.r)?; + self.user_data()?; + Ok(target.map(|target| Override { graphics, hidden, local, target })) + } + + /// `ReferenceOnTopology` (206) after its type code; `None` without a body. + fn reference_on_topology(&mut self) -> Result, PrcError> { + let kind = self.r.unsigned_integer()?; + if !self.r.bit()? { + return Ok(None); + } + let fs = self.file_identifier()?; + let brep = (self.r.unsigned_integer()?, self.r.unsigned_integer()?); + let indices = self.uints("topology reference indices")?; + Ok(Some(Target::Topology { kind, fs, brep, indices })) + } + + /// `ASM_Filter` (320) [WD 7.3.12]: read past. + // prc-rs: the filter's field order. + fn filter(&mut self) -> Result<(), PrcError> { + self.expect_type(FILTER)?; + self.content_prc_ref_base()?; + self.r.bit()?; // is_active + self.r.bit()?; // layers inclusive + self.uints("filter layers")?; + self.r.bit()?; // entities inclusive + let n = self.count(1, "filter entities")?; + for _ in 0..n { + self.entity_reference()?; + } + self.schema.skip_added_fields(FILTER, &mut self.r)?; + self.user_data() + } + + /// `PartDefinition` (311): its drawable items and its name. + fn part_definition(&mut self) -> Result<(Vec, Option), PrcError> { + self.expect_type(PART_DEFINITION)?; + let (hidden, own) = self.base_with_graphics()?; + let name = self.name.clone(); + self.vector3()?; // bounding box + self.vector3()?; + let n = self.count(1, "representation items")?; + let mut items = Vec::new(); + let at = Item { local: Vec::new(), tessellation: 0, graphics: vec![own], uids: Vec::new(), brep: None }; + for _ in 0..n { + self.representation_item(&at, &mut items, 0)?; + } + self.markup_data()?; + self.views()?; + self.schema.skip_added_fields(PART_DEFINITION, &mut self.r)?; + self.user_data()?; + if hidden { + items.clear(); + } + Ok((items, name)) + } + + /// `FileIdentifier` [WD 7.3.10.2.2]: `None` for the same structure. + fn file_identifier(&mut self) -> Result, PrcError> { + if self.r.bit()? { Ok(None) } else { self.unique_id().map(Some) } + } + + /// `ProductOccurrence` (310) [WD 7.3.10]. + fn product_occurrence(&mut self) -> Result { + self.expect_type(PRODUCT_OCCURRENCE)?; + let (hidden, graphics) = self.base_with_graphics()?; + let mut p = Product { hidden, graphics, name: self.name.clone(), part: self.r.unsigned_integer()?, ..Product::default() }; + p.prototype = self.r.unsigned_integer()?; + if p.prototype != 0 { + p.prototype_fs = self.file_identifier()?; + } + p.external = self.r.unsigned_integer()?; + if p.external != 0 { + p.external_fs = self.file_identifier()?; + } + p.sons = self.uints("son occurrences")?; + p.suppressed = self.r.character()? & SUPPRESSED != 0; + // ProductInformation [WD 7.3.10.3]: unit flag, unit, load status, information. + self.r.bit()?; + self.r.double()?; + self.r.character()?; + self.r.integer()?; + if self.r.bit()? { + p.location = Some(self.transformation()?); + } + let n = self.count(1, "entity references")?; + for _ in 0..n { + p.overrides.extend(self.entity_reference()?); + } + self.markup_data()?; + self.views()?; + if self.r.bit()? { + self.filter()?; + } + let n = self.count(1, "display filters")?; + for _ in 0..n { + self.filter()?; + } + let n = self.count(1, "scene display parameters")?; + for _ in 0..n { + self.scene_display_parameters()?; + } + self.schema.skip_added_fields(PRODUCT_OCCURRENCE, &mut self.r)?; + self.user_data()?; + Ok(p) + } + + /// `FileStructureTree` (304) [WD 7.3.6], with its `FileStructure` (302) inside. + pub(super) fn file_structure_tree(&mut self) -> Result { + self.expect_type(FILE_STRUCTURE_TREE)?; + self.content_prc_base()?; + let n = self.count(1, "part definitions")?; + let mut tree = Tree::default(); + for _ in 0..n { + let (items, name) = self.part_definition()?; + tree.parts.push(items); + tree.part_names.push(name); + } + let n = self.count(1, "product occurrences")?; + for _ in 0..n { + tree.products.push(self.product_occurrence()?); + } + self.expect_type(FILE_STRUCTURE)?; + self.content_prc_base()?; + self.r.unsigned_integer()?; // next_available_index + self.r.unsigned_integer()?; // product_occurrence_index + 1 (the structure's root) + self.schema.skip_added_fields(FILE_STRUCTURE, &mut self.r)?; + self.schema.skip_added_fields(FILE_STRUCTURE_TREE, &mut self.r)?; + self.user_data()?; + Ok(tree) + } + + /// `ModelFile` (301) after its schema [WD 7.3.3; pdf-issues #724, #740]: `units_in_mm` is + /// always present. + pub(super) fn model_file(&mut self) -> Result { + self.expect_type(MODEL_FILE)?; + self.content_prc_base()?; + self.r.bit()?; // units_from_cad_file + self.r.double()?; // units_in_mm + let n = self.count(6, "root occurrences")?; + let mut roots = Vec::with_capacity(n); + for _ in 0..n { + let id = self.unique_id()?; + let index = self.r.unsigned_integer()?; + self.r.bit()?; // is_active + roots.push((id, index)); + } + Ok(ModelFile { roots }) + } +} diff --git a/crates/prc/src/tree/style.rs b/crates/prc/src/tree/style.rs new file mode 100644 index 0000000000..d313fb2f64 --- /dev/null +++ b/crates/prc/src/tree/style.rs @@ -0,0 +1,348 @@ +//! Graphics, styles, materials and the globals that hold them [WD 7.2.4, 7.3.5, 7.5]. + +use super::{ + FATHER_HERIT_COLOR, FILE_STRUCTURE_GLOBALS, LINE_PATTERN, MATERIAL, Matrix, PICTURE, RI_COORDINATE_SYSTEM, SON_HERIT_COLOR, STYLE, + TEXTURE_ALPHA_TEST, TEXTURE_APPLICATION, TEXTURE_DEFINITION, TEXTURE_FUNCTION_BLEND, TEXTURE_MAPPING_OPERATOR, TEXTURE_TRANSFORMATION, Vec3, +}; +use crate::PrcError; +use crate::container::offset; +use crate::entity::{Ctx, malformed}; + +/// One entity's `GraphicsContent` [WD 7.2.4]: `style` is `line_style_index + 1` (0 none), +/// `bits` the behaviour bits. `fs` is the file structure whose globals `style` indexes: the +/// one whose tree holds the entity. The walk sets it; a reader leaves it 0. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct Graphics { + pub(crate) style: u32, + pub(crate) bits: u16, + pub(crate) fs: usize, +} + +/// A straight (not premultiplied) colour, each channel 0-1; `a` is the opacity. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[non_exhaustive] +pub struct Rgba { + /// Red. + pub r: f64, + /// Green. + pub g: f64, + /// Blue. + pub b: f64, + /// Opacity: 0 transparent, 1 opaque. + pub a: f64, +} + +/// How a style's `transparency` combines with its material's diffuse alpha. ISO 14739-1 +/// defines both and not their combination, and gives a diffuse alpha of 0.0 no "unset" +/// meaning. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum StyleAlpha { + /// As [`Self::StyleWins`], except that a material diffuse alpha of exactly 0.0 under a + /// style with no transparency reads as unset, so opaque. Producers write 0.0 on the + /// materials of models meant to be seen; read literally, every part is invisible. + #[default] + ZeroUnset, + /// The style's transparency, when it has one, is the opacity; the material alpha, read + /// literally (0.0 invisible), applies only to a style without one. + StyleWins, + /// The opacity is material alpha × style transparency. + Multiply, +} + +/// A style's resolved colour, and whether its opacity comes from reading a zero material +/// alpha as unset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Paint { + pub(crate) rgba: Rgba, + pub(crate) alpha_unset: bool, +} + +/// A `Style` (701) as far as colour: `index` is `colour_or_material + 1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Style { + pub(crate) is_material: bool, + pub(crate) index: u32, + pub(crate) transparency: Option, +} + +/// A `materials` entry as far as colour. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Material { + /// `Material` (702): `diffuse + 1` (a colour index) and the diffuse alpha. + Plain { diffuse: u32, alpha: f64 }, + /// `TextureApplication` (711) [WD 7.5.6]: `material_generic_index + 1`, the material + /// under the texture. + Textured { base: u32 }, +} + +/// What `FileStructureGlobals` contributes to placing and colouring. +#[derive(Debug, Clone, PartialEq, Default)] +pub(crate) struct Globals { + /// Entry `i` places an item with biased local coordinate system `i + 1`. + pub(crate) systems: Vec, + /// RGB, `a` 1. + pub(crate) colours: Vec, + pub(crate) materials: Vec, + pub(crate) styles: Vec\n\n\n", - esc(title) + "\n\n\n\n{}\n{}\n\n\n", + esc(title), + if simple { + "" + } else { + "" + } ); for it in items(pages) { match it { @@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi } // The paragraph has its first run's colour; a run in another colour says so. let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { + if !simple && colour != rgb8(first.color) { let [red, green, blue] = colour; run = format!("{run}"); } t.push_str(&run); } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + 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")), @@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi body = format!("{body}"); } let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + 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"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..2b82919bbf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -27,9 +27,9 @@ pub(crate) use as_docx::hex; #[cfg(test)] use as_docx::{MAX_MEDIA, docx_table}; pub(crate) use as_html::esc; -pub use as_html::html; #[cfg(test)] use as_html::{allowed, base64}; +pub use as_html::{html, html_simple}; pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..f630d98d5d 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() { assert_eq!(base64(b"Ma"), "TWE="); } +#[test] +fn simple_html_has_no_css_or_layout_elements() { + let mut p = page(); + let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0); + unicode.bold = true; + unicode.italic = true; + p.blocks.push(unicode); + let source = html_simple(&[p, table_page()], "Simple export"); + for banned in ["", "", "", " Date: Tue, 3 Nov 2026 16:08:30 +0100 Subject: [PATCH 237/562] Ignore dictionary key order when checking signed pages for changes Fixes #5955 Co-Authored-By: Claude Opus 5.5 --- crates/sign/src/pdf/changes.rs | 113 ++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 3a10a74e41..28c96633cc 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -427,6 +427,35 @@ fn without(x: &Dict, keys: &[&[u8]]) -> Vec<(Vec, Object)> { v } +/// Whether two page dictionaries are the same apart from `/Annots`. Dictionaries are compared +/// without regard to key order at every depth: a writer may re-serialise a page's inline +/// resources in another order without changing what the page shows. +fn same_but_annots(d: &Dict, b: &Dict) -> bool { + same_entries(d, b, &[b"Annots"]) +} + +/// Whether two dictionaries hold the same entries apart from `skip`: both sorted by key, then +/// compared pairwise, so neither side can hide a key the other lacks. +fn same_entries(a: &Dict, b: &Dict, skip: &[&[u8]]) -> bool { + fn sorted<'a>(x: &'a Dict, skip: &[&[u8]]) -> Vec<(&'a [u8], &'a Object)> { + let mut v: Vec<(&[u8], &Object)> = x.iter().map(|(k, o)| (k.as_slice(), o)).filter(|(k, _)| !skip.contains(k)).collect(); + v.sort_by(|p, q| p.0.cmp(q.0)); + v + } + let (x, y) = (sorted(a, skip), sorted(b, skip)); + x.len() == y.len() && x.iter().zip(&y).all(|((k, v), (l, w))| k == l && same_object(v, w)) +} + +/// Object equality where dictionaries (and stream dictionaries) ignore key order; arrays keep it. +fn same_object(a: &Object, b: &Object) -> bool { + match (a, b) { + (Object::Dict(a), Object::Dict(b)) => same_entries(a, b, &[]), + (Object::Stream(a), Object::Stream(b)) => a.raw == b.raw && same_entries(&a.dict, &b.dict, &[]), + (Object::Array(a), Object::Array(b)) => a.len() == b.len() && a.iter().zip(b).all(|(v, w)| same_object(v, w)), + _ => a == b, + } +} + /// Whether a dictionary was a signature object: a signature or timestamp value, a signature /// reference or its transform parameters, or a signature form field. Used to decide from the /// signed revision, so a later relabel of some other object cannot pose as a signature change. @@ -521,7 +550,7 @@ fn signed_page_resources(old: &Document, pages: &HashSet) -> HashMap) -> &'static str { match before { - Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT, + Some(b) if !same_but_annots(d, b) => kind::PAGE_CONTENT, Some(b) => { let was = refs(old, b.get(b"Annots")); let added: Vec = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect(); @@ -584,7 +613,7 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) } Some(b"Page") => { return match before { - Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => kind::PAGE_CONTENT, + Some(b) if !same_but_annots(d, b) => kind::PAGE_CONTENT, Some(b) => { let was = refs(old, b.get(b"Annots")); let added: Vec = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect(); @@ -617,3 +646,83 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) } kind::OTHER } + +#[cfg(test)] +mod tests { + use super::*; + + fn font(order: &[&str], base: &str) -> Object { + let mut d = Dict::new(); + for k in order { + match *k { + "Type" => d.set(b"Type".to_vec(), Object::name("Font")), + "Subtype" => d.set(b"Subtype".to_vec(), Object::name("Type1")), + "BaseFont" => d.set(b"BaseFont".to_vec(), Object::name(base)), + _ => { + let mut w = Dict::new(); + w.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(32), Object::name("space")])); + w.set(b"Type".to_vec(), Object::name("Encoding")); + d.set(b"Encoding".to_vec(), Object::Dict(w)); + } + } + } + Object::Dict(d) + } + + fn page(fonts: Vec<(&str, Object)>, annots: Vec) -> Dict { + let mut f = Dict::new(); + fonts.into_iter().for_each(|(k, v)| f.set(k.as_bytes().to_vec(), v)); + let mut r = Dict::new(); + r.set(b"Font".to_vec(), Object::Dict(f)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Resources".to_vec(), Object::Dict(r)); + p.set(b"Annots".to_vec(), Object::Array(annots)); + p + } + + #[test] + fn reordered_inline_resources_are_not_page_content() { + let doc = Document::new_empty(); + let order = ["Type", "Subtype", "BaseFont", "Encoding"]; + let before = page(vec![("F1", font(&order, "Times-Roman")), ("F2", font(&order, "Helvetica"))], vec![]); + let unchanged = page_change_kind(&doc, &doc, &before, Some(&before)); + let reordered = page( + vec![ + ("F2", font(&["Encoding", "BaseFont", "Type", "Subtype"], "Helvetica")), + ("F1", font(&["BaseFont", "Subtype", "Encoding", "Type"], "Times-Roman")), + ], + vec![], + ); + assert_eq!(page_change_kind(&doc, &doc, &reordered, Some(&before)), unchanged); + assert_eq!(change_kind(&doc, &doc, &reordered, Some(&before)), change_kind(&doc, &doc, &before, Some(&before))); + + // A changed value inside the nested dictionary still is a page content change. + let changed = page(vec![("F2", font(&order, "Helvetica")), ("F1", font(&order, "Courier"))], vec![]); + assert_eq!(page_change_kind(&doc, &doc, &changed, Some(&before)), kind::PAGE_CONTENT); + assert_eq!(change_kind(&doc, &doc, &changed, Some(&before)), kind::PAGE_CONTENT); + } + + #[test] + fn a_swapped_nested_key_is_page_content_both_ways() { + let doc = Document::new_empty(); + let order = ["Type", "Subtype", "BaseFont"]; + let signed = page(vec![("F1", font(&order, "Times-Roman"))], vec![]); + // Same entry count, but /Subtype was dropped and /Encoding added in its place. + let swapped = page(vec![("F1", font(&["Type", "Encoding", "BaseFont"], "Times-Roman"))], vec![]); + for (now, then) in [(&swapped, &signed), (&signed, &swapped)] { + assert_eq!(page_change_kind(&doc, &doc, now, Some(then)), kind::PAGE_CONTENT); + assert_eq!(change_kind(&doc, &doc, now, Some(then)), kind::PAGE_CONTENT); + } + } + + #[test] + fn reordered_arrays_are_still_page_content() { + let doc = Document::new_empty(); + let mut before = page(vec![], vec![]); + before.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(612), Object::Int(792)])); + let mut after = before.clone(); + after.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(792), Object::Int(612)])); + assert_eq!(page_change_kind(&doc, &doc, &after, Some(&before)), kind::PAGE_CONTENT); + } +} From cdfdd6b782e71c208463a954c90087d62387d413 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:14:17 +0500 Subject: [PATCH 238/562] Open: measure cached objects once and by what they hold, read plain reals without a copy, and keep no /PieceInfo in inspection (#5873) (#5958) --- crates/cos/src/document.rs | 4 +- crates/cos/src/document/objects.rs | 49 ++++---------- crates/cos/src/document/state.rs | 105 ++++++++++++++++++++++++----- crates/cos/src/document/tests.rs | 59 ++++++++++++++-- crates/cos/src/parser.rs | 15 ++++- crates/render/src/inspect/tests.rs | 42 ++++++++++++ crates/render/src/structure.rs | 13 +++- 7 files changed, 224 insertions(+), 63 deletions(-) diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 082d1910d1..2aaf33ec63 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -27,7 +27,7 @@ pub(crate) use state::ObjectReader; use state::{ObjStm, Slot}; pub use state::{ReadState, SourceIdentity}; #[cfg(test)] -use state::{SECTION_READS, objstm_member, owned_bytes}; +use state::{SECTION_READS, held_bytes, objstm_member}; use xref::XrefIndex; pub use xref::{Revision, XrefEntry}; @@ -47,7 +47,7 @@ pub struct Document { /// `fill_xref_gaps`. located: Arc>, repair_log: Arc>, - cache: Arc>>>, + cache: Arc>, objstms: Arc>>>, /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ac18cff03..4efb185f1a 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,5 +1,5 @@ use super::reader::find; -use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; +use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member}; use super::xref::{Revision, XrefEntry}; use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, @@ -399,8 +399,9 @@ impl Document { let obj = Arc::new(obj); // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); - self.evict_editor_cache_if_over_budget(num, &obj)?; + let owned = held_bytes(&obj, &self.data, 0); + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone(), owned); + self.evict_editor_cache_if_over_budget(num)?; Ok(obj) } @@ -409,10 +410,10 @@ impl Document { /// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes /// count; file-backed views re-parse for free. /// - /// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` × - /// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full - /// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. - /// The count cap always holds; byte accounting between passes is approximate. + /// Worst case per document: input bytes + `EDITOR_CACHE_BYTES` (32 MB) of owned object + /// data + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. Each object is measured + /// once, as it is cached ([`ObjectCache`](super::state::ObjectCache)), so both caps hold + /// after every insertion. /// Clones share these caches, so one clone's eviction drops another's hot set; /// that only costs re-parsing, never correctness. Eviction keeps just the newcomer: /// file-backed views re-parse cheaply and editor working sets stay far below 1024. @@ -425,37 +426,13 @@ impl Document { const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024; - /// Newcomers above this force a full accounting pass even below the count cap: - /// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the - /// byte budget can double. - const EDITOR_LARGE_OBJECT: usize = 256 * 1024; - - fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { - // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer - // below the count cap. `len` is copied out first; nothing here holds the lock. - let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); - if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { - return Ok(()); - } - // Walk a snapshot off-lock; re-lock only to evict. - let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); - let data = &self.data; - let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); - if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } - // Re-check under the final lock: another thread may have inserted or evicted since - // the snapshot. Keep its newcomers, and skip the eviction if back within budget. - let snapshot: std::collections::HashSet = live.into_iter().map(|(n, _)| n).collect(); + fn evict_editor_cache_if_over_budget(&self, fresh_num: u32) -> Result<(), CosError> { let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; - if cache.len() <= Self::EDITOR_CACHE_OBJECTS { - let data = &self.data; - let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); - if owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } + if cache.len() <= Self::EDITOR_CACHE_OBJECTS && cache.owned() <= Self::EDITOR_CACHE_BYTES { + return Ok(()); } - cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n)); + // Eviction keeps just the newcomer: file-backed views re-parse cheaply. + cache.retain(|n| *n == fresh_num); Ok(()) } diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index b4323f982e..96dde01c0f 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -163,11 +163,10 @@ impl ObjectReader { // input bytes, which the document keeps anyway, so they don't count; decrypted // streams and strings do. Checked after failed loads too: resolving a /Length may // have cached large objects. - if let Ok(mut cache) = self.document.cache.lock() { - let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { - cache.clear(); - } + if let Ok(mut cache) = self.document.cache.lock() + && (cache.len() >= Self::OBJECTS || cache.owned() > Self::STREAM_BYTES) + { + cache.clear(); } object } @@ -179,26 +178,96 @@ thread_local! { pub(super) static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// Parsed objects by number, each with what it holds ([`held_bytes`]) measured once, when it goes +/// in, and their sum kept beside them. +/// +/// Both caches are bounded by those bytes. Summing them on every read walked every cached object +/// whole each time: a file whose page dictionaries carry kilobytes of private data (InDesign's +/// `/PieceInfo`) took seconds to open, almost all of it re-measuring objects already measured +/// (#5873). +#[derive(Default)] +pub(crate) struct ObjectCache { + entries: HashMap, usize)>, + owned: usize, +} + +impl ObjectCache { + pub(crate) fn get(&self, num: &u32) -> Option<&Arc> { + self.entries.get(num).map(|(object, _)| object) + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, num: &u32) -> bool { + self.entries.contains_key(num) + } + + /// Keep `object` as `num`, which holds `owned` bytes (what [`held_bytes`] says of it). + pub(crate) fn insert(&mut self, num: u32, object: Arc, owned: usize) { + if let Some((_, before)) = self.entries.insert(num, (object, owned)) { + self.owned = self.owned.saturating_sub(before); + } + self.owned = self.owned.saturating_add(owned); + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The bytes the cached objects hold between them. + pub(crate) fn owned(&self) -> usize { + self.owned + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.owned = 0; + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { + self.entries.retain(|num, _| keep(num)); + self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); + } + + #[cfg(test)] + pub(crate) fn values(&self) -> impl Iterator> { + self.entries.values().map(|(object, _)| object) + } +} + +/// Nesting [`held_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; -/// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured -/// and counts as too large to keep. -pub(super) fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { +/// What keeping `object` parsed costs: string and stream data that is not a view into `data` +/// (the input file), and the parsed tree around it -- an [`Object`] per value and a key per +/// dictionary entry. An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured and +/// counts as too large to keep. +/// +/// The tree counts. Measured by its strings and streams alone, a page dictionary carrying +/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of +/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB +/// (#5873). +pub(super) fn held_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { if depth > OWNED_BYTES_DEPTH { return usize::MAX; } + let node = std::mem::size_of::(); + // A key, its slot beside the value, and room in the index a large dictionary keeps. + let entry = |key: &[u8]| key.len().saturating_add(std::mem::size_of::<(crate::Name, Object)>()).saturating_add(16); + let dict = + |d: &crate::Dict, start: usize| d.iter().fold(start, |n, (k, v)| n.saturating_add(entry(k)).saturating_add(held_bytes(v, data, depth + 1))); match object { - Object::String(s) => s.bytes.len(), - Object::Stream(s) => { - let own = if s.raw.shares(data) { 0 } else { s.raw.len() }; - s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))) - } - Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))), - Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))), - _ => 0, + Object::String(s) => node.saturating_add(s.bytes.len()), + Object::Name(n) => node.saturating_add(n.len()), + Object::Stream(s) => dict(&s.dict, node.saturating_add(if s.raw.shares(data) { 0 } else { s.raw.len() })), + Object::Array(a) => a.iter().fold(node, |n, v| n.saturating_add(held_bytes(v, data, depth + 1))), + Object::Dict(d) => dict(d, node), + _ => node, } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index cca4b87326..4c7b3eb652 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1109,7 +1109,8 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // Data of its own (decrypted streams, strings) counts: past the budget the cache is // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + let size = held_bytes(&owned, &reader.document.data, 0); + reader.document.cache.lock().unwrap().insert(3, Arc::new(owned), size); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -1192,13 +1193,14 @@ fn editor_cache_counts_deeply_nested_strings() { source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); let doc = Document::open(bytes).unwrap(); - assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let held = held_bytes(&nested, &doc.data, 0); + assert!((1 << 20..(1 << 20) + 4096).contains(&held), "the string and a little tree: {held}"); let root = doc.get(doc.root().unwrap()); for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { let Object::Ref(reference) = reference else { panic!("indirect object") }; drop(doc.get(*reference)); let cache = doc.cache.lock().unwrap(); - let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); let strings = cache.values().filter(|o| o.as_array().is_some()).count(); assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); } @@ -1240,8 +1242,8 @@ fn editor_cache_stays_bounded() { #[test] fn editor_cache_byte_bound_fires() { - // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each - // newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass. + // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget, which is + // checked after every insertion. let mut source = Document::new_empty(); let objects: Vec<_> = (0..40) .map(|i| { @@ -1701,3 +1703,50 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { }); rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } + +/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has +/// to measure the cache again on every read (#5873). +#[test] +fn the_object_cache_keeps_its_total_as_objects_come_and_go() { + let data = Arc::new(Vec::new()); + let one = Arc::new(Object::String(crate::PdfString::literal(vec![b'x'; 100]))); + let two = Arc::new(Object::Array(vec![Object::Int(1); 10])); + let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0)); + let mut cache = super::state::ObjectCache::default(); + cache.insert(1, one.clone(), a); + cache.insert(2, two, b); + assert_eq!(cache.owned(), a + b); + cache.insert(2, one, a); + assert_eq!(cache.owned(), a + a, "a replaced object no longer counts"); + cache.retain(|n| *n == 1); + assert_eq!((cache.len(), cache.owned()), (1, a)); + cache.clear(); + assert_eq!((cache.len(), cache.owned()), (0, 0)); +} + +/// A dictionary of numbers owns no string or stream bytes, but parsed it is tens of thousands +/// of values: InDesign's per-page `/PieceInfo`. Counting only strings and streams, the editing +/// cache kept a thousand such pages and a 35 MB book held 1.5 GB (#5873). What the parsed tree +/// holds counts now, so the byte budget bounds them. +#[test] +fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() { + let entries: String = (0..2000).map(|i| format!("/{i}[{i} 141 3 116.22 -375.993 1.0]")).collect(); + let dict = format!("<< /PieceInfo << /InDesign << /Map << {entries} >> >> >> >>"); + let count = 300; + let refs: String = (0..count).map(|i| format!("{} 0 R ", i + 2)).collect(); + let mut bodies = vec![format!("<< /Type /Catalog /Extension [{refs}] >>")]; + bodies.extend((0..count).map(|_| dict.clone())); + let bodies: Vec<&str> = bodies.iter().map(String::as_str).collect(); + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap(); + let one = doc.get(crate::ObjRef::new(2, 0)); + let held = held_bytes(&one, &doc.data, 0); + assert!(held > 2000 * std::mem::size_of::(), "the parsed tree counts: {held} bytes"); + for i in 0..count { + drop(doc.get(crate::ObjRef::new(i as u32 + 2, 0))); + } + let cache = doc.cache.lock().unwrap(); + assert!(cache.owned() <= Document::EDITOR_CACHE_BYTES, "{} bytes held", cache.owned()); + assert!(cache.len() < count, "the byte budget evicted some, below the count cap: {} kept", cache.len()); + let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); + assert_eq!(cache.owned(), sum, "the kept total is the cache's own"); +} diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 1e9b45ad61..709659f537 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -341,7 +341,11 @@ impl<'a> Lexer<'a> { let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?; if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. - let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); + // Only a doubled sign needs a cleaned copy: a page of InDesign `/PieceInfo` is + // thousands of plain reals, and building a copy and a substring search for each + // was a large part of reading one (#5873). + let t = s.trim_start_matches('+'); + let cleaned = if t.as_bytes().windows(2).any(|w| w == b"--") { std::borrow::Cow::Owned(t.replacen("--", "-", 1)) } else { t.into() }; let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); return Ok(Object::Real(v.unwrap_or(0.0))); } @@ -707,4 +711,13 @@ mod tests { let mut lx = Lexer::new(two.as_bytes(), 0); assert!(lx.object().is_ok() && lx.object().is_ok()); } + + /// A plain real is read without a cleaned copy of its token; the forms that need one read + /// as they did (#5873). + #[test] + fn reals_read_the_same_with_and_without_a_doubled_sign() { + for (token, want) in [("1.5", 1.5), ("-375.993", -375.993), ("+2.25", 2.25), ("--5.5", -5.5), ("1.2.3", 1.2), ("-.5", -0.5), ("0.0", 0.0)] { + assert_eq!(Lexer::new(token.as_bytes(), 0).object().unwrap(), Object::Real(want), "{token}"); + } + } } diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index a5bfec474d..7221e97d5a 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1870,3 +1870,45 @@ trailer << /Root 1 0 R >> assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); } + +/// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it +/// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every +/// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what +/// inspection keeps, and what inspection reports is the same as for the page without it. +#[test] +fn inspection_keeps_no_private_page_data() { + use lopdf::dictionary; + let make = |private: bool| -> Vec { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut page = dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Rotate" => 90, + }; + if private { + let map: Dictionary = (0..50).map(|i| (i.to_string(), Object::Array(vec![Object::Integer(i), Object::Real(116.22)]))).collect(); + page.set("PieceInfo", dictionary! { "InDesign" => dictionary! { "PageItemUIDToLocationDataMap" => map } }); + } + let page_id = doc.add_object(page); + doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + doc.trailer.set("Root", catalog); + let mut out = Vec::new(); + doc.save_to(&mut out).unwrap(); + out + }; + let (with, without) = (make(true), make(false)); + let lazy = LazyStructure::new(Arc::new(with.clone()), None).unwrap(); + let page = lazy.get_object((2, 0)).unwrap().as_dict().unwrap(); + assert!(!page.has(b"PieceInfo"), "the private data is not kept"); + assert!(page.has(b"MediaBox") && page.has(b"Rotate") && page.has(b"Parent"), "the rest of the page is"); + + let report = |bytes: Vec| { + let mut info = inspect(Arc::new(bytes), None).unwrap(); + info.file_size = 0; + format!("{info:?}") + }; + assert_eq!(report(with), report(without), "what inspection reports does not change"); +} diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index bf9c16e23f..effbc87d92 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -187,9 +187,20 @@ fn convert_dict(dict: &pdfcraft_cos::Dict, depth: usize) -> Result { if depth > 100 { return Err(Error::RecursionLimit); } - dict.iter().map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))).collect::>>().map(|items| items.into_iter().collect()) + dict.iter() + .filter(|(key, _)| key.as_slice() != PRIVATE_DATA) + .map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))) + .collect::>>() + .map(|items| items.into_iter().collect()) } +/// `/PieceInfo`: an application's private data about a page, form or document (ISO 32000-1 +/// §14.5), left out of what inspection keeps. Nothing here reads it, and every object read is +/// kept converted for the inspection's lifetime: InDesign writes kilobytes of it into every page, +/// and a 1,412-page book held over a gigabyte of it (#5873). The file and the editing document are +/// untouched; this is the read-only view inspection walks. +const PRIVATE_DATA: &[u8] = b"PieceInfo"; + fn convert(object: &CosObject, depth: usize) -> Result { if depth > 100 { return Err(Error::RecursionLimit); From 3709926f39039b57e71644227aa8b41b46bde940 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:14:23 -0600 Subject: [PATCH 239/562] Continue Bates numbering across ordered PDFs (#5953) * feat: add multi-file Bates numbering * fix(automation): doc_bates_multi rejects a malformed start_number and >> in prefix or suffix, accepts a range ending at u64::MAX, and counts only staged outputs Addresses CodeRabbit's quick-win notes. Parity is partial until a GUI dialog exists. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/bates.rs | 214 +++++++++++++++++++++++ crates/automation/src/dispatch.rs | 1 + crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 8 + crates/automation/tests/it/automation.rs | 68 +++++++ parity/acrobat-features.toml | 8 +- 6 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 crates/automation/src/bates.rs diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs new file mode 100644 index 0000000000..3cd1d7849e --- /dev/null +++ b/crates/automation/src/bates.rs @@ -0,0 +1,214 @@ +use std::io::Write; + +use pdfcraft_engine::{Edit, HeaderFooter}; +use serde_json::{Value, json}; + +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::paths::read_input; +use super::{Arc, Automation, Path, PathBuf}; + +struct FilePlan { + input: String, + source: PathBuf, + password: Option, + output: PathBuf, + pages: usize, + first: u64, + last: u64, +} + +impl Automation { + pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result { + let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?; + if files.is_empty() || files.len() > 1000 { + return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into())); + } + let start = match args.0.get("start_number") { + None | Some(Value::Null) => 1, + Some(value) => value + .as_u64() + .filter(|n| *n >= 1) + .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?, + }; + let digits = args.opt_int("digits")?.unwrap_or(6); + let digits = + u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?; + let prefix = args.opt_str("prefix")?.unwrap_or(""); + let suffix = args.opt_str("suffix")?.unwrap_or(""); + // `#` separates the token's fields and `>>` ends it: either would cut the token short. + if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) { + return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into())); + } + let dry_run = args.opt_bool("dry_run")?.unwrap_or(false); + + // Resolve and reserve the complete path list before opening or writing anything. + let mut sources = Vec::with_capacity(files.len()); + let mut targets = Vec::with_capacity(files.len()); + let mut passwords = Vec::with_capacity(files.len()); + for entry in files { + let input = entry + .get("input") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?; + let output = entry + .get("output") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?; + let source = self.resolve(input, false)?; + let target = self.resolve(output, true)?; + if target.exists() { + return Err(failed(format!("output already exists; refusing to replace it: {output}"))); + } + let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?; + if !parent.is_dir() { + return Err(failed(format!("output directory does not exist: {}", parent.display()))); + } + sources.push((input.to_string(), source)); + targets.push((output.to_string(), target)); + passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned)); + } + let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect(); + let mut output_paths = std::collections::HashSet::new(); + for (name, path) in &targets { + if input_paths.contains(path) { + return Err(failed(format!("output {name} names an input file; choose a separate output path"))); + } + if !output_paths.insert(path.clone()) { + return Err(failed(format!("two files use the same output path: {name}"))); + } + } + + let mut plans = Vec::with_capacity(files.len()); + let mut next = start; + let mut input_bytes = 0u64; + for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() { + let size = std::fs::metadata(input_path).map_err(failed)?.len(); + input_bytes = input_bytes + .checked_add(size) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let bytes = read_input(input_path)?; + input_bytes = input_bytes + .saturating_sub(size) + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let password = passwords.get(index).and_then(Option::as_deref); + let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?; + let pages = + self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?; + self.session.close(id); + if pages == 0 { + return Err(failed(format!("{input_name} has no pages to number"))); + } + let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?; + let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; + plans.push(FilePlan { + input: input_name.clone(), + source: input_path.clone(), + password: passwords.get(index).cloned().flatten(), + output: output_path.clone(), + pages, + first: next, + last, + }); + if index + 1 < sources.len() { + next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?; + } + } + + let intervals: Vec = plans + .iter() + .zip(&targets) + .map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last})) + .collect(); + if dry_run { + return Ok(json!({"dry_run":true,"files":intervals})); + } + + let mut staged = Vec::with_capacity(plans.len()); + let mut results = Vec::with_capacity(plans.len()); + let mut output_bytes = 0u64; + for plan in &plans { + let source_bytes = match read_input(&plan.source) { + Ok(bytes) => bytes, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref()) + { + Ok(id) => id, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) { + self.session.close(id); + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"})); + continue; + } + let template = format!("<>", plan.first); + let mut settings = HeaderFooter::default(); + settings.text[4] = template; + let pages: Vec = (0..plan.pages).collect(); + let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false }); + let result = applied.and_then(|()| self.session.save_full_bytes(id)); + match result { + Ok(bytes) => { + let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + if total > 512 * 1024 * 1024 { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); + } else { + output_bytes = total; + staged.push((plan.output.clone(), bytes)); + } + } + Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})), + } + self.session.close(id); + } + for (path, bytes) in staged { + match publish_new(&path, &bytes) { + Ok(()) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages})); + } + } + Err(error) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + } + } + } + } + results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX)); + let completed = results.iter().filter(|r| r["status"] == "ok").count(); + let failed_count = results.len().saturating_sub(completed); + Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results})) + } +} + +/// Publish a PDF atomically while refusing a target created by another process after preflight. +fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?; + temp.write_all(bytes).map_err(failed)?; + temp.as_file().sync_all().map_err(failed)?; + temp.persist_noclobber(path).map(|_| ()).map_err(|error| { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + failed(format!("output appeared after preflight; refusing to replace it: {}", path.display())) + } else { + failed(error.error) + } + }) +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 49d4e6fd4d..f3c51d22e0 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -53,6 +53,7 @@ impl Automation { tools::check_args(def, args)?; let a = Args(args); let out = match name { + "doc_bates_multi" => self.doc_bates_multi(&a)?, "doc_open" => self.doc_open(&a)?, "doc_batch" => self.doc_batch(&a)?, "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1910867c9..5bcb032fce 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -16,6 +16,7 @@ mod a11y; mod batch; +mod bates; mod comments; mod content; mod conventions; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 24f81588cf..4672e49886 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,6 +121,14 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ + t("doc_bates_multi", "Add Bates numbering across files", "Apply one consecutive Bates sequence to an ordered list of PDFs. Each file entry has input and output paths and an optional open or permissions password. The operation first validates every input/output and computes inclusive numeric intervals; dry_run returns that allocation without writing. Existing output files are never replaced. On application, files that cannot be edited or saved are reported individually and later files continue.") + .destructive() + .with(schema(json!({ + "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}}, + "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."}, + "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."}, + "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."} + }), &["files"])), t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 875531b81a..828d2e89ac 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2832,6 +2832,74 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); } +#[test] +fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failures() { + let dir = workdir("bates-multi"); + let files = json!([ + {"input":"b.pdf","output":"b-numbered.pdf"}, + {"input":"a.pdf","output":"a-numbered.pdf"} + ]); + let mut a = auto(&dir); + let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true})); + assert_eq!(allocation["files"][0]["first"], 50); + assert_eq!(allocation["files"][0]["last"], 51); + assert_eq!(allocation["files"][1]["first"], 52); + assert_eq!(allocation["files"][1]["last"], 54); + assert!(!dir.join("a-numbered.pdf").exists()); + + let applied = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":17,"prefix":"CASE-"})); + assert_eq!(applied["completed"], 2); + let mut out = auto(&dir); + let first = ok(&mut out, "doc_open", json!({"path":"a-numbered.pdf"}))["doc"].as_u64().unwrap(); + let second = ok(&mut out, "doc_open", json!({"path":"b-numbered.pdf"}))["doc"].as_u64().unwrap(); + assert!(page_text(&mut out, second)[0].contains("CASE-000017")); + assert!(page_text(&mut out, first)[0].contains("CASE-000019")); + + let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX})); + assert!(overflow.to_string().contains("overflows")); + assert!(!dir.join("overflow.pdf").exists()); + // A range may end at u64::MAX itself. + let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true})); + assert_eq!(at_max["files"][0]["last"], u64::MAX); + for bad in + [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})] + { + let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true}); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}"); + } + + use pdfcraft_engine::{Changes, Edit, Protection, Session}; + let mut session = Session::new(); + let id = session.open("locked.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some("open".into()), + permissions_password: Some("owner".into()), + changes: Changes::None, + ..Default::default() + }), + ) + .unwrap(); + std::fs::write(dir.join("locked.pdf"), session.save_bytes(id).unwrap().as_ref()).unwrap(); + let result = ok( + &mut a, + "doc_bates_multi", + json!({"files":[ + {"input":"b.pdf","output":"partial-numbered.pdf"}, + {"input":"locked.pdf","output":"locked-numbered.pdf","password":"open"} + ]}), + ); + assert_eq!(result["completed"], 1); + assert_eq!(result["failed"], 1); + assert_eq!(result["files"][1]["first"], 3); + assert!(result["files"][1]["error"].as_str().unwrap().contains("owner password"), "{result}"); + assert!(dir.join("partial-numbered.pdf").exists()); + assert!(!dir.join("locked-numbered.pdf").exists()); +} + #[test] fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { let dir = workdir("hf-shrink"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 793220d4f6..3871b1496b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2607,11 +2607,11 @@ title = "Bates numbering across multiple files" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["doc_bates_multi"] +evidence = ["crates/automation/tests/it/automation.rs::bates_numbers_continue_across_files_and_report_preflight_and_permission_failures"] +notes = "The headless batch tool allocates inclusive intervals in input order and reports per-file failures; it refuses existing output paths. Missing: a GUI batch dialog (Acrobat sets this up from Header & Footer ▸ Add Bates numbering with a file list), so it is reachable only through `doc_bates_multi`." [[feature]] id = "edit.bates-remove" From 0d3f57e120a3bd0e103f870b5e32d59e43c3ba94 Mon Sep 17 00:00:00 2001 From: AdrianTM Date: Tue, 3 Nov 2026 10:24:45 -0500 Subject: [PATCH 240/562] M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850) (#5954) * M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850) * M5: preserve typewriter whitespace through readers and edits (#5850) * M5: retain direct-subtype comment listing for Fill & Sign fix (#5850) --- ROADMAP.md | 10 ++ crates/annot/src/info.rs | 8 +- crates/annot/src/lookup.rs | 8 +- crates/annot/src/tests.rs | 24 +++++ crates/automation/README.md | 2 + crates/automation/src/comments.rs | 6 +- crates/automation/tests/it/automation.rs | 29 ++++++ crates/engine/src/tests.rs | 17 ++++ crates/render/src/inspect/core.rs | 18 +++- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/fill_sign.rs | 16 +++- crates/ui-egui/tests/it/comments.rs | 84 ++++++++++++++++- crates/ui-egui/tests/it/fill_sign.rs | 92 +++++++++++++++++-- docs/roadmap.md | 8 +- parity/acrobat-features.toml | 4 +- 15 files changed, 302 insertions(+), 29 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6da118da92..5bfe1fc817 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) + > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) @@ -219,6 +223,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged. + +- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged. + - **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. @@ -749,6 +757,8 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | +| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..1a54dbc077 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -47,8 +47,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option Object::Name(n) => String::from_utf8_lossy(n).into_owned(), _ => return None, }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) + let s = s.trim_matches('\0'); + // Typewriter outer whitespace belongs to the text (keep in step with render's inspector). + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter"); + let s = if keep_breaks { s } else { s.trim() }; + (!s.trim().is_empty()).then(|| s.to_owned()) } /// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index c7a0fd9831..9af83b0542 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -137,11 +137,11 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { return true; } - let subtype = d.name(b"Subtype").unwrap_or_default(); - if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") { return true; } - if subtype == b"Stamp" { + if name_is(b"Subtype", b"Stamp") { if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { return true; } @@ -151,7 +151,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { return true; } } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") + name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature") } pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index cf800fa98a..32d28fc6ec 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2250,3 +2250,27 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); } } + +#[test] +fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { + let mut doc = fixture(); + let intent = Object::Ref(doc.add(Object::name("FreeTextTypeWriter"))); + let page = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, page); + let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") }; + annotation.set(b"Subtype".to_vec(), Object::name("FreeText")); + annotation.set(b"IT".to_vec(), intent); + let contents = " \nAda\n\nLovelace\n\n "; + annotation.set(b"Contents".to_vec(), PdfString::text(contents)); + set_annots(&mut doc, page, entries).unwrap(); + let check = |doc: &Document| { + let list = summaries(doc); + let summary = list.iter().find(|a| a.page == 0 && a.index == 0).expect("direct Subtype is listed"); + assert_eq!(summary.contents.as_deref(), Some(contents), "indirect typewriter intent preserves outer whitespace"); + assert_eq!(summary.subtype, "FreeText"); + assert_eq!(summary.intent.as_deref(), Some("FreeTextTypeWriter")); + assert!(summary.fill_sign, "indirect intent still identifies a Fill & Sign mark"); + }; + check(&doc); + check(&reopen(&doc)); +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 92fd2775c9..8ffa2d0b3e 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in - **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit. - **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`. +`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance. + ## API ```rust diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 5dbd5b8542..ae46be87d0 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -539,8 +539,10 @@ impl Automation { return self.apply(a, edit); } let text_at = |t: &str| { - let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); - let h = size * 1.2 + 6.0; + let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max); + let w = (longest + 8.0).clamp(20.0, 600.0); + let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); + let h = rows as f64 * size * 1.2 + 6.0; let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); Shape::Typewriter { rect, font_size: size } }; diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 828d2e89ac..13f9af4371 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -10613,3 +10613,32 @@ fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() { assert_eq!(page_text(&mut a, reopened)[0], plain); } } + +#[test] +fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() { + let dir = workdir("fill-sign-multiline"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let text = "\nAda\n\nLovelace\n"; + ok(&mut a, "fill_sign_add", json!({"doc":doc,"page":1,"type":"text","at":[20,20],"text":text})); + let id = pdfcraft_engine::DocId(doc); + let annot = &a.session().get(id).unwrap().info.annotations[0]; + assert_eq!(annot.contents.as_deref(), Some(text), "typewriter contents keep outer line breaks"); + assert!(annot.rect[3] - annot.rect[1] >= 66.0, "five text lines need their full height, including blank lines: {:?}", annot.rect); + let pixels = a.render_pixels(doc, 1, 1.0).unwrap(); + // The fixture's page text is at y=150; only the typewriter draws at y=65..80. + assert!( + (65..80).any(|y| (20..80).any(|x| { + let i = (y * pixels.0 as usize + x) * 4; + pixels.2[i..i + 3].iter().all(|v| *v < 100) + })), + "the lower nonempty line appears inside its box" + ); + let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"multiline.pdf"})); + let mut re = auto(&dir); + let reopened = ok(&mut re, "doc_open", json!({"path":"multiline.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(re.session().get(pdfcraft_engine::DocId(reopened)).unwrap().info.annotations[0].contents.as_deref(), Some(text)); + assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d60ed04d66..a77e471a7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10251,3 +10251,20 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() { assert_eq!(s.get(id).unwrap().bytes, before); } } + +#[test] +fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { + let (mut session, id) = rotated_blank(0); + let text = "\nAda\n\nLovelace\n"; + let shape = Shape::Typewriter { rect: text_box_at(0, [20.0, 250.0], 120.0, 66.0).unwrap(), font_size: 10.0 }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: text.into(), author: "Test".into() })).unwrap(); + assert_eq!(session.get(id).unwrap().info.annotations[0].contents.as_deref(), Some(text), "engine keeps outer typewriter line breaks"); + let before = render_page(&session, id); + assert!(before.rgba.as_chunks::<4>().0.iter().any(|p| p[..3] == [0, 0, 0]), "the appearance contains ink"); + let bytes = session.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let again = reopened.open("multiline.pdf", None, bytes, None).unwrap(); + assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines"); + assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical"); +} diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c190dc565b..924bfcc51d 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -288,11 +288,23 @@ impl<'a> Inspector<'a> { } pub(super) fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) + // Typewriter outer whitespace belongs to the text (keep in step with annot's summaries). + let s = if key == b"Contents" + && self.name(d, b"Subtype").as_deref() == Some("FreeText") + && self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter") + { + match self.resolve(d.get(key).ok()?) { + Object::String(bytes, _) => text_string(bytes).trim_matches('\0').to_owned(), + Object::Name(bytes) => String::from_utf8_lossy(bytes).trim_matches('\0').to_owned(), + _ => return None, + } + } else { + trimmed_text(self.resolve(d.get(key).ok()?))? + }; + (!s.trim().is_empty()).then_some(s) } - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims + /// Whitespace-trimmed metadata, spent from `budget` as [`spend`] does, so text that trims /// short or to nothing still spends its raw length. pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { let o = self.resolve(d.get(key).ok()?); diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index fa2d7f9b1a..8c3dee280c 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -111,7 +111,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, return None; } let c = view.comments.composer.take()?; - let text = c.text.trim_end().to_string(); + let typewriter_edit = matches!(c.kind, ComposerKind::Edit(index) if info.annotations.iter().any(|a| + a.page == page && a.index == index && a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") + )); + 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::Note => { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..597e139c9e 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -490,7 +490,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m FillAction::Edit(Box::new(typed_with_color( tb.page, tb.at, - tb.text.trim(), + &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, @@ -555,14 +555,20 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let Some(t) = view.fill_text.as_mut() else { return }; // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); + let font = egui::FontId::proportional((t.size as f32 * zoom).max(8.0)); + let natural = ui.fonts_mut(|f| f.layout_no_wrap(t.text.clone(), font.clone(), Color32::BLACK).size().x); + let margin = egui::Margin::symmetric(4, 2); + // Include the field padding and a pixel for layout rounding in the measured width. + let width = (natural + margin.sum().x + 1.0).max(8.0 * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); // The area's max rect is last frame's size, which caps the edit's width; widen it so the // box grows as text is typed. ui.set_min_width(width); let r = ui .add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + egui::TextEdit::multiline(&mut t.text) + .margin(margin) + .desired_rows(1) + .font(font) .desired_width(width) .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) .text_color(crate::comments::color32(t.color)) @@ -587,7 +593,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); } } None diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 939de58dff..bd70d802ef 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2925,7 +2925,7 @@ fn moved_fill_and_sign_text_is_drawn_where_it_goes_before_release() { click_pt(&mut h, (40.0, 150.0)); h.event(egui::Event::Text("WWWWWW".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + click(&mut h, pos2(350.0, 850.0)); // Outside the field: Enter now inserts a line break. h.run_steps(3); let a = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("WWWWWW")).expect("the typed text"); assert!(a.fill_sign && a.subtype == "FreeText", "{a:?}"); @@ -3379,3 +3379,85 @@ fn hotbar_floating_picker_does_not_overlap_rendered_bar() { settings_rect ); } + +fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) { + let shots = std::env::var_os("PDFCRAFT_COMPOSER_SHOTS").is_some(); + let _gpu = shots.then(crate::gpu); + use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Shape, Style}; + let mut h = harness(move |app| { + app.set_option("layout", "single").unwrap(); + app.set_option("panel", "comments").unwrap(); + app.quick_tool = QuickTool::Select; + // Both pages use index 0, with opposite intents. The lookup must use page AND index. + for page in 0..2 { + let typewriter = if page == 0 { kind != 0 } else { kind == 0 }; + let shape = if typewriter { + Shape::Typewriter { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 } + } else if kind == 2 { + Shape::Note { at: [80.0, 80.0], icon: NoteIcon::Comment } + } else { + Shape::TextBox { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 } + }; + let style = Style::default_for(&shape); + assert!(app.apply_edit(Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: "Original".into(), author: "Tester".into() }))); + } + }); + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + support::settle(&mut h); + let point = { + let s = h.state(); + let v = &s.views[0]; + let doc = s.session.get(v.id).unwrap(); + let a = doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap(); + let page = v.page_screen_rect(1).expect("the second page is visible"); + let info = &doc.info.pages[1]; + let p = info.user_to_view((a.rect[0] + a.rect[2]) / 2.0, (a.rect[1] + a.rect[3]) / 2.0); + page.min + egui::vec2(p[0], p[1]) * (page.width() / info.width) + }; + support::input::double_click(&mut h, point); + h.run_steps(3); + let c = h.state().views[0].comments.composer.as_ref().expect("double-click opens the edit composer"); + assert_eq!(c.page, 1); + assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0)); + h.input_mut().events.push(egui::Event::Text(" edited\n\n".into())); + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.composer.as_ref().unwrap().text, "Original edited\n\n"); + if shots { + h.render().unwrap().save(format!("/tmp/5954-composer-{kind}.png")).unwrap(); + } + h.get_all_by_label("Post").last().unwrap().click(); + h.run_steps(3); + assert!(h.state().views[0].comments.composer.is_none()); + let id = h.state().views[0].id; + let bytes = h.state().session.save_bytes(id).unwrap(); + // Check the raw saved string: ordinary-comment reader trimming must not hide a bad post. + let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let pages = pdfcraft_model::pages(&cos); + let annots = cos.resolve(pages[1].dict.get(b"Annots").unwrap()); + let annot = cos.resolve(&annots.as_array().unwrap()[0]); + let contents = cos.resolve(annot.as_dict().unwrap().get(b"Contents").unwrap()); + let expected = if kind == 0 { "Original edited\n\n" } else { "Original edited" }; + assert_eq!(contents.as_string().unwrap().to_text(), expected, "only a typewriter edit preserves trailing blank lines"); + let mut reopened = pdfcraft_engine::Session::new(); + let again = reopened.open("edited.pdf", None, bytes, None).unwrap(); + let saved = reopened.get(again).unwrap().info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap(); + assert_eq!(saved.contents.as_deref(), Some(expected)); +} + +#[test] +fn typewriter_edit_composer_keeps_trailing_blank_lines() { + edit_composer_preserves_only_typewriter_whitespace(0); +} + +#[test] +fn other_edit_composers_keep_trimming_trailing_whitespace() { + for kind in [1, 2] { + edit_composer_preserves_only_typewriter_whitespace(kind); + } +} diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..858e082137 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -130,7 +130,7 @@ fn text_marks_and_date() { click(&mut h, 50.0, 250.0); h.event(egui::Event::Text("Ada Lovelace".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(4); let v = items(&h); assert_eq!(v.len(), 4, "{v:?}"); @@ -139,9 +139,9 @@ fn text_marks_and_date() { assert!(v.iter().any(|(t, _)| t == "Stamp")); } -/// The open type box, found as the focused text input. +/// The open type box, found by its accessible name for either input role. fn type_box_rect(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { - h.query_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.is_focused()).expect("an open type box").rect() + h.get_by_label("Text to place").rect() } #[test] @@ -149,13 +149,13 @@ fn clicking_elsewhere_with_add_text_keeps_the_typed_text() { let mut h = harness(); h.state_mut().execute("sign.fill.text"); click(&mut h, 50.0, 250.0); - h.event(egui::Event::Text("Ada Lovelace".into())); + h.event(egui::Event::Text("\nAda\nLovelace\n".into())); h.run_steps(1); // Still on Add text: the next click starts a new box, and must keep the first one. click(&mut h, 50.0, 150.0); h.run_steps(2); let v = items(&h); - assert_eq!(v, vec![("FreeText".to_string(), Some("Ada Lovelace".to_string()))]); + assert_eq!(v, vec![("FreeText".to_string(), Some("\nAda\nLovelace\n".to_string()))]); assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open"); } @@ -170,7 +170,7 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() { assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0)); h.event(egui::Event::Text("Larger text".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let annotation = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Larger text")).unwrap(); @@ -192,7 +192,7 @@ fn fill_text_size_resizes_the_selected_text() { click(&mut h, 50.0, 250.0); h.event(egui::Event::Text("Resize me".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let height = |h: &Harness<'static, PdfCraftApp>| { let s = h.state(); @@ -297,7 +297,7 @@ fn fill_text_colour_survives_preview_commit_settings_and_save() { assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().color, color, "the editor uses the selected colour"); h.event(egui::Event::Text("Blue form text".into())); h.run_steps(2); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let id = h.state().views[0].id; let expected = color.map(|v| v as f32); @@ -1812,3 +1812,79 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { h.run_steps(3); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); } + +fn commit_typed_text(h: &mut Harness<'static, PdfCraftApp>) { + let p = pos2(350.0, 850.0); // Blank canvas outside the page and the type box. + h.hover_at(p); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +fn fill_input(h: &mut Harness<'static, PdfCraftApp>, events: impl IntoIterator) { + h.input_mut().events.extend(events); + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.run_steps(2); +} + +fn fill_key(h: &mut Harness<'static, PdfCraftApp>, key: egui::Key) { + fill_input( + h, + [true, false].map(|pressed| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE }), + ); +} + +#[test] +fn fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels() { + let shots = std::env::var_os("PDFCRAFT_FILL_SIGN_SHOTS").is_some(); + let _gpu = shots.then(gpu); + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.text")); + click(&mut h, 50.0, 300.0); + let initial_height = type_box_rect(&h).height(); + fill_key(&mut h, egui::Key::Enter); + assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.text.as_str()), Some("\n"), "Enter inserts a newline and keeps the editor open"); + fill_input(&mut h, [egui::Event::Text("Ada".into())]); + fill_key(&mut h, egui::Key::Enter); + fill_key(&mut h, egui::Key::Enter); + fill_input(&mut h, [egui::Event::Text("Lovelace".into())]); + fill_key(&mut h, egui::Key::Enter); + let text = "\nAda\n\nLovelace\n"; + assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().text, text); + assert!(items(&h).is_empty(), "Enter never places an annotation"); + let galley = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == text => Some(&t.galley), + _ => None, + }) + .unwrap(); + assert_eq!(galley.rows.len(), 5, "short typed lines fit without accidental soft wraps"); + assert!(type_box_rect(&h).height() > initial_height * 3.0, "the editor grows with its lines"); + if shots { + h.render().unwrap().save("/tmp/5850-multiline.png").unwrap(); + } + commit_typed_text(&mut h); + assert!(h.state().views[0].fill_text.is_none()); + assert_eq!(items(&h), [("FreeText".to_owned(), Some(text.to_owned()))]); + let id = h.state().views[0].id; + let saved = h.state().session.save_bytes(id).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let reopened = session.open("again.pdf", None, saved.clone(), None).unwrap(); + let annot = &session.get(reopened).unwrap().info.annotations[0]; + assert_eq!(annot.contents.as_deref(), Some(text)); + assert!(annot.rect[3] - annot.rect[1] >= 5.0 * 10.0 * 1.2, "the saved box keeps all five lines"); + click(&mut h, 50.0, 180.0); + fill_input(&mut h, [egui::Event::Text("Cancel me".into())]); + fill_key(&mut h, egui::Key::Enter); + fill_key(&mut h, egui::Key::Escape); + assert!(h.state().views[0].fill_text.is_none()); + assert_eq!(h.state().session.save_bytes(id).unwrap(), saved, "Escape cancels without writing"); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 1caf3f3e22..f376f7cb00 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,9 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) + > **2026-11-03, M7 / #5835 review:** active image crops retain secondary clicks over overlapping images; crop controls share measured layout with hit detection. Rotation, handle and page-edge regressions added; focused tests, scoped Clippy, formatting, layering and asset checks passed locally. Estimates unchanged. > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged) @@ -219,7 +223,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
    - command registry (menus, shortcuts and palette all use it);
    - undo/redo; incremental, atomic and encrypted saves;
    - autosave and crash recovery;
    - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
    - CLI `edit/combine/extract/split`.
    Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
    Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
    - command registry (menus, shortcuts and palette all use it);
    - undo/redo; incremental, atomic and encrypted saves;
    - autosave and crash recovery;
    - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
    - CLI `edit/combine/extract/split`.
    Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
    Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); each reply in the Comments panel has its own "…" (Edit, Copy text, Delete): Delete takes that reply and the replies to it, the comment and its other replies stay, and the card stays selected; stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; Enter adds line breaks in a growing typewriter editor, outside clicks commit, Escape cancels, and saved/headless appearances keep multiline text (#5850); indirect typewriter intent resolves before whitespace decisions and Fill & Sign classification, with comment listing/property lookup retaining direct Subtype, and composer edits retain typewriter text as written while other comments retain trimming; agent tools.
    Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); each reply in the Comments panel has its own "…" (Edit, Copy text, Delete): Delete takes that reply and the replies to it, the comment and its other replies stay, and the card stays selected; stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
    - command registry (menus, shortcuts and palette all use it);
    - undo/redo; incremental, atomic and encrypted saves;
    - autosave and crash recovery;
    - organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
    - CLI `edit/combine/extract/split`.
    Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
    Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
    Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -300,6 +304,8 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | +| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3871b1496b..8b7ac909a0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5399,8 +5399,8 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.text"] tools = ["fill_sign_add"] -evidence = ["crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] -notes = "typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" +evidence = ["crates/annot/src/tests.rs::indirect_typewriter_names_keep_outer_whitespace_in_summaries", "crates/ui-egui/tests/it/comments.rs::typewriter_edit_composer_keeps_trailing_blank_lines", "crates/ui-egui/tests/it/comments.rs::other_edit_composers_keep_trimming_trailing_whitespace", "crates/ui-egui/tests/it/comments.rs::moved_fill_and_sign_text_is_drawn_where_it_goes_before_release", "crates/engine/src/tests.rs::fill_sign_multiline_text_survives_engine_save_reopen_and_render", "crates/ui-egui/tests/it/fill_sign.rs::fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels", "crates/automation/tests/it/automation.rs::multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance", "crates/ui-egui/tests/it/fill_sign.rs::text_marks_and_date", "crates/annot/src/tests.rs::fill_and_sign_items_are_drawn", "crates/automation/tests/it/automation.rs::fill_and_sign_through_tools", "crates/engine/src/tests.rs::typed_text_reads_across_as_displayed_on_rotated_pages", "crates/ui-egui/tests/it/fill_sign.rs::fill_text_colour_survives_preview_commit_settings_and_save", "crates/automation/tests/it/automation.rs::fill_sign_text_and_date_colours_are_saved_and_validated"] +notes = "Indirect IT is resolved when recognizing typewriter contents and Fill & Sign marks; comment listing and properties retain direct Subtype as before; composer edits preserve the typewriter text as written, while other comments retain their existing trimming. Enter inserts a newline; the editor grows with its lines, outside clicks commit and Escape cancels. Nonempty text preserves leading/internal/trailing line breaks, including after save/reopen; fill_sign_add sizes all wrapped rows for the saved appearance (#5850). typewriter FreeText, 10 pt Helvetica. Reads across as displayed on pages with /Rotate (sideways-stored scans): the box follows the displayed axes and the appearance is turned back (PdfCraft key /PCTextRotate); other apps that regenerate the appearance from /DA draw it unturned. A later page rotation does not re-turn it. Field detection does not work on scanned (image-only) forms yet; typed fractional font sizes commit once; Fill & Sign text/date colour is remembered and preserved in preview and saved appearances (#2444)" [[feature]] id = "form.fill-sign-marks" From ecdd1e658cbb9ab5ceaa924a5598f091a6135bec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 10:30:12 -0500 Subject: [PATCH 241/562] fix(audit): read content one operator at a time and charge hidden text and findings (#5875) The faux-redaction audit held every operator of a page's content and of each form XObject in a vector before walking it, so a stream of millions of short operators (compressed to a few kilobytes) was materialised whole within the page's decode budget. Each Do and gs also copied the whole XObject or ExtGState table to pick one entry, and each annotation listed in /Annots was deep-copied before its subtype was read. The walk now reads operators one at a time through pdfcraft_content::ops, borrows resource tables and annotation dictionaries through the resolved Arc, charges a hidden line's text bytes (padding included) against the comparison bound once per cover, and charges each finding's record against the output bound, so a shared annotation list on many pages or withheld text cannot grow the finding list for free. --- crates/audit/src/cover.rs | 37 +++++++++++----------- crates/audit/src/lib.rs | 57 ++++++++++++++++++++++------------ crates/audit/src/tests.rs | 65 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 117 insertions(+), 42 deletions(-) diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 041ba9ddd8..bb5adf7ff8 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -15,7 +15,7 @@ //! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position. //! Positions compare lexicographically in paint order. -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -123,12 +123,13 @@ impl<'a> Walker<'a> { Ok(()) } - /// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to + /// Walk one stream's operators, read one at a time (a stream of millions of short operators + /// is never held whole), with the given graphics state. `at_do` is the `Do` position to /// stamp on rectangles found inside a form XObject, or `None` at page level (where each /// fill operator's own position is used). fn walk_ops( &mut self, - ops: &[Op], + ops: impl Iterator, gs: &mut Gs, stack: &mut Vec, resources: &Dict, @@ -136,8 +137,9 @@ impl<'a> Walker<'a> { at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { let mut path = Path::Empty; - for (i, op) in ops.iter().enumerate() { + for (i, op) in ops.enumerate() { self.bump()?; + let op = &op; // Paint position of this operator: its own `(piece, joined index)` at page level, // the enclosing `Do`'s position inside a form XObject. let pos = match at_do { @@ -257,8 +259,9 @@ impl<'a> Walker<'a> { /// changes inside never leak out; every rectangle found is stamped with the `Do` /// position `pos`. fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let Some(entry) = xobjects.get(name) else { return Ok(()) }; + // Borrowed, not copied: a `Do` must not cost the size of the whole XObject table. + let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) }; let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; @@ -274,18 +277,18 @@ impl<'a> Walker<'a> { (v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]])) }) .unwrap_or(Matrix::IDENTITY); - let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new()); + let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty); if !self.budget.enter_form() { return Err(AuditError::TooManyForms); } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; - let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); sub_gs.ctm = matrix.then(&gs.ctm); let mut sub_stack = Vec::new(); - self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos)) + self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos)) } } @@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { - let alpha = resources - .get(b"ExtGState") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(name).map(|o| doc.resolve(o))) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); + // Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table. + let states = resources.get(b"ExtGState").map(|o| doc.resolve(o)); + let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o)); + let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0) } @@ -330,10 +330,11 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi } } let pieces = Pieces::join(&streams); - let parsed = pieces.parse(); let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); - w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; + // Read one operator at a time: the joined stream can hold millions of them within the + // decode budget, and the walk only ever needs the current one. + w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?; Ok(w.out) } diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f2bcf63358..97429d4c40 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4; const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; +/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan +/// is far cheaper than a box comparison, but a long padded line under many covers adds up. +const TEXT_BYTES_PER_COMPARISON: usize = 16; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any /// content that cannot be decoded, or that needs more work or report than the audit's bounds, is @@ -157,15 +160,24 @@ impl Budget { cover: &[f64; 4], hides: impl Fn(&TextLine) -> bool, ) -> Result, AuditError> { - self.comparisons = self.comparisons.saturating_add(self.page_cost); - if self.comparisons > self.limits.comparisons { - return Err(AuditError::TooManyComparisons); - } + self.spend(self.page_cost)?; if !copying { - return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + let mut hidden = false; + for line in lines.iter().filter(|l| hides(l)) { + // Trimming walks the line's text (padding and all): it costs its bytes. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; + if !line.text.trim().is_empty() { + hidden = true; + break; + } + } + return Ok(hidden.then(String::new)); } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { + // Copying and trimming the line walks its text once per cover: a long padded + // line under many covers would otherwise be read far more than its runs suggest. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; if !covered.is_empty() { covered.push(' '); } @@ -174,11 +186,21 @@ impl Budget { Ok((!covered.trim().is_empty()).then_some(covered)) } + /// `n` units of work done against the comparison bound. + fn spend(&mut self, n: usize) -> Result<(), AuditError> { + self.comparisons = self.comparisons.saturating_add(n); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + Ok(()) + } + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it - /// reports against the output bound. + /// reports, and the finding's own record, against the output bound: a finding with no text + /// (copying withheld, or a shared annotation list on many pages) still takes memory. fn report_text(&mut self, covered: String, copying: bool) -> Result { let reported = if copying { covered } else { String::new() }; - self.covered_text = self.covered_text.saturating_add(reported.len()); + self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::()); if self.covered_text > self.limits.covered_text { return Err(AuditError::TooMuchOutput); } @@ -321,17 +343,14 @@ enum AnnotationCover { fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); - let items: Vec = match &*resolved { - pdfcraft_cos::Object::Array(items) => items.clone(), - // A missing /Annots means no marks; a malformed one fails the audit. - _ => return Err(AuditError::BadAnnots), - }; + // A missing /Annots means no marks; a malformed one fails the audit. + let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) }; let mut out = Vec::new(); - for a in &items { - let dict = match &*doc.resolve(a) { - pdfcraft_cos::Object::Dict(d) => d.clone(), - _ => continue, - }; + for a in items { + // Borrowed from the resolved object: one large dictionary listed many times is not + // copied once per listing. + let resolved = doc.resolve(a); + let Some(dict) = resolved.as_dict() else { continue }; let v: Vec = match dict.get(b"Rect") { None => continue, Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(), @@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match subtype(doc, &dict)?.as_deref() { + match subtype(doc, dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro } let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0); if alpha >= 1.0 - && let Some(rgb) = annotation_color(doc, &dict, b"IC") + && let Some(rgb) = annotation_color(doc, dict, b"IC") { out.push(AnnotationCover::Square { rect, rgb }); } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e5fb9ab683..074eb39f66 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() { let doc = open(one_page(&content, "")); let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); - let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + // Plus the hidden line's text, read once to copy it. + let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON; + let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX }; assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); - let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX }; assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); } @@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() { let findings = audit_redactions(&user).expect("audit must run"); assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); assert_eq!(findings[0].covered_text, ""); - // The output bound counts the text reported, so withheld text costs nothing. - let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + // The output bound counts the text reported and the finding's record, so withheld text + // costs only the record. + let record = std::mem::size_of::(); + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record }; assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); + let none = crate::Limits { covered_text: record - 1, ..nothing }; + assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput))); let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); let findings = audit_redactions(&owner).expect("audit must run"); assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].covered_text, "SECRET DATA 123"); - let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record }; assert!(crate::audit(&owner, exact).is_ok()); let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); @@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); } + +/// Copying a covered line's text walks all of it, padding included, so a long padded line under +/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit). +#[test] +fn covered_text_bytes_count_against_the_comparison_bound() { + // One run: 20 KiB of spaces around a word, which trims to the word alone. + let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec(); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b"SECRET"); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b") Tj ET\n0 g\n"); + for _ in 0..10 { + content.extend_from_slice(b"-1 688 1000000 32 re f\n"); + } + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.len(), 1); + let bytes = lines[0].text.len(); + assert!(bytes > 20_000, "{bytes} bytes of text"); + // Each cover: one comparison for the line, plus its bytes when it is hidden. + let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX }; + let findings = crate::audit(&doc, exact).expect("within the budget"); + assert_eq!(findings.len(), 10, "{findings:?}"); + assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}"); + let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons))); +} + +/// One annotation list shared by many pages makes a finding on each: every finding's record +/// counts against the output bound, text or no text (CodeRabbit). +#[test] +fn every_finding_record_counts_against_the_output_bound() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add_stream(content); + b.add(FONT); + b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + let doc = open(b.build()); + let one = "SECRET".len() + std::mem::size_of::(); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one }; + assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3); + let short = crate::Limits { covered_text: 3 * one - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput))); +} From bad9a8b44c89cf8384252fcc57a20ad8cf33041b Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 12:47:31 -0500 Subject: [PATCH 242/562] cos: an edited object stream forgets its members' cached failures A member that failed to load from the file's object stream was cached as a failure. Replacing or freeing that stream left the failure in place, so the member kept reading as null instead of from the edit (CodeRabbit on #5802). set and free now drop the cached failures of the stream's members. Cached objects are untouched; a failure holds no bytes, so the cache's total stands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/objects.rs | 11 +++++++++++ crates/cos/src/document/state.rs | 5 +++++ crates/cos/src/document/tests.rs | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 96d3a9dee4..619e64eb5f 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -642,6 +642,7 @@ impl Document { pub fn set(&mut self, r: ObjRef, obj: impl Into) { let obj = self.adopt(obj.into()); self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); + self.forget_member_failures(r.num); if r.num >= self.next_num { self.next_num = r.num.saturating_add(1); } @@ -669,6 +670,16 @@ impl Document { /// Delete an object (its number becomes free with the next generation). pub fn free(&mut self, r: ObjRef) { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); + self.forget_member_failures(r.num); + } + + /// Forget cached failures of the members `stream` held in the file: they were failures of + /// the file's copy, and an edit of `stream` may read. Clones share the cache, so theirs are + /// searched again too, which costs time only. A poisoned lock leaves the cache as it is. + fn forget_member_failures(&self, stream: u32) { + if let Ok(mut cache) = self.cache.lock() { + cache.forget_failures(|n| matches!(self.entries.lookup(*n, &self.data), Some(XrefEntry::InStream { stream: s, .. }) if s == stream)); + } } /// Free what was added since the file was read and nothing refers to any more: the objects diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 5f9bb49852..b3e6c1314a 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -250,6 +250,11 @@ impl ObjectCache { self.owned = 0; } + /// Drop the cached failures `stale` picks. A failure holds no bytes, so the total stands. + pub(crate) fn forget_failures(&mut self, mut stale: impl FnMut(&u32) -> bool) { + self.entries.retain(|num, (loaded, _)| loaded.is_ok() || !stale(num)); + } + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { self.entries.retain(|num, _| keep(num)); self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 3b18521a4e..62481df4bc 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -634,6 +634,26 @@ fn an_edited_object_stream_leaves_other_clones_members_alone() { assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"), "the replacement reached another clone"); } +/// A member that failed to load from the file's object stream is kept as a failure, but replacing +/// or freeing that stream forgets it: the member then reads from the edit. +#[test] +fn an_edited_object_stream_forgets_its_members_failures() { + let file = Arc::new(object_stream_file("3 0 ", ") broken", 0)); + let mut doc = Document::open(file.clone()).unwrap(); + assert!(doc.try_get(3).is_err(), "the file's member does not parse"); + let mut dict = Dict::new(); + dict.set(b"Type".to_vec(), Object::name("ObjStm")); + dict.set(b"N".to_vec(), Object::Int(1)); + dict.set(b"First".to_vec(), Object::Int(4)); + doc.set(ObjRef::new(4, 0), crate::Stream::from_raw(dict, b"3 0 << /Hidden (fixed) >>".to_vec())); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("fixed"), "the cached failure outlived the edit"); + assert!(doc.try_get(3).is_ok()); + let mut doc = Document::open(file).unwrap(); + assert!(matches!(doc.try_get(3), Err(CosError::Syntax { .. })), "the file's member does not parse"); + doc.free(ObjRef::new(4, 0)); + assert_eq!(doc.try_get(3), Err(CosError::MissingObject(4)), "the freed stream's member"); +} + /// An object stream framed by one clone's edit of its indirect `/Length` is that clone's alone: /// the others still read its members within the file's length. #[test] From 57ca07dea3b56bfb678f7831bbc11767c03b476f Mon Sep 17 00:00:00 2001 From: KenM76 Date: Tue, 3 Nov 2026 12:51:45 -0500 Subject: [PATCH 243/562] cos: test that a clone's scan reaches an unread original The clone-scan check ran after the original had already listed the object, so it passed whether or not scan findings were shared (CodeRabbit on #5816). A fresh document is now cloned before anything reads it, only the clone is scanned, and the original must list the object. Seen failing with the scan's list unshared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/cos/src/document/tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index 50a69159f1..fda2128ebc 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1727,6 +1727,10 @@ fn unreadable_objects_are_listed_with_the_reason() { let scanned = doc.clone(); assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1); assert_eq!(doc.unreadable_objects(), lines, "the scan found the same object"); + let unread = Document::open(Arc::new(with_unreadable_info())).unwrap(); + let scanned = unread.clone(); + assert_eq!(scanned.scan_objects_checked().filter(|(_, o)| o.is_err()).count(), 1); + assert_eq!(unread.unreadable_objects(), lines, "a clone's scan reaches the original"); let mut edited = doc.clone(); edited.set(ObjRef::new(3, 0), Object::Dict(Dict::new())); assert!(edited.unreadable_objects().is_empty(), "the edit replaced it"); From 01d3479f2680d1a931569a3605f540a6aac3b0dd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:06:56 +0100 Subject: [PATCH 244/562] M2: a JBIG2 region located past i32::MAX is off the page A region's location is unsigned (ISO/IEC 14492 5.4.4), and hayro-jbig2 took it for a signed one. A region at 0xFFFFFFFC was drawn back onto the page four pixels left of it, and a refinement anywhere past i32::MAX, 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. Vendored patch 14 (13 is #5999): Bitmap::combine_region keeps such a location past the page, for the four kinds of region drawn onto it, and a refinement's offset to its reference is taken in 64 bits and held to an i32, which leaves every reference pixel it reads outside the reference, as the exact offset does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 51 ++++++++++ vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 11 +++ .../src/decode/generic_refinement.rs | 53 +++++++---- vendor/hayro-jbig2/src/lib.rs | 93 ++++++++++++++----- 5 files changed, 168 insertions(+), 42 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index a018e75937..6d6759e3ea 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -143,6 +143,57 @@ mod tests { assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks); } + /// A region's location is unsigned (ISO/IEC 14492 5.4.4), so one past `i32::MAX` is off the + /// page. The decoder took it for a signed one: a region at 0xFFFFFFFC came back onto the page + /// four pixels left of it, and a refinement anywhere past `i32::MAX` was refused, and the page + /// with it. Each stream is a page of 16 × 12 covered by a generic region, then a region of 8 × 6 + /// of one kind past `i32::MAX`, coded by the standard's procedures, and decodes to that page + /// alone; moved onto the page, the region draws. Vendored hayro-jbig2 patch 14. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + const PAGE: &str = "0000000030000100000013000000100000000c00000000000000004200000000000126000100000035000000100000000c0000000000000000040003fffdff02fefefed906aa2cb6ee4ccb4bc975d02669ca7a9c14c20c638f5a5e1bffac"; + let cases = [ + // At 0xFFFFFFFC, 1. + ("generic", "00000002260001000000220000000800000006fffffffc00000001000003fffdff02fefefe17c876ae6f77ffac"), + // At 1, 0xFFFFFFFE, with its symbol dictionary. + ( + "text", + "000000020000010000001e000003fffdff02fefefe00000001000000014fdda14149cec477ed5fffac00000003062002010000001b000000080000000600000001fffffffe00001000000001a943ffac", + ), + // At 0xFFFFFFFC, 1, with its pattern dictionary. + ( + "halftone", + "000000021000010000000a000101000000011fffac00000003162002010000002e0000000800000006fffffffc0000000100000000000800000006000000000000000001000000d09301774485ffac", + ), + // At 0xFFFFFFFC, 1, reading the page there (7.4.7.4): 0s. + ("refinement of the page", "000000022a00010000001e0000000800000006fffffffc000000010400ffffffff0bd98f188428ffac"), + // Of an intermediate region at the same place (7.4.7.5), 1, 0xFFFFFFFE. + ( + "refinement of a region", + "0000000224000100000023000000080000000600000001fffffffe000003fffdff02fefefe3d81322da16b7fffac000000032a2002010000001e000000080000000600000001fffffffe0000ffffffffe29946b82229ffac", + ), + // Of an intermediate refinement of an intermediate region, all at 0xFFFFFFFC, 1. + ( + "refinement of a refinement", + "00000002240001000000230000000800000006fffffffc00000001000003fffdff02fefefecdfd921ec8c47fffac00000003282002010000001e0000000800000006fffffffc000000010000ffffffff903cdb447a47ffac000000042a2003010000001e0000000800000006fffffffc000000010000ffffffffdcd6078cab86ffac", + ), + ]; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + let decode = |segments: &str| decode_jbig2(&[hex(PAGE), hex(segments)].concat(), None, 16, 12, usize::MAX).unwrap(); + let page = decode(""); + assert!(page.iter().any(|&byte| byte != 0xFF), "the page has ink"); + for (kind, segments) in cases { + assert_eq!(decode(segments), page, "{kind}"); + // Moved onto the page, to 4, 1 or 1, 2, the same regions draw: they decode, and are + // left out for where they are. + let (off, on) = [("fffffffc00000001", "0000000400000001"), ("00000001fffffffe", "0000000100000002")] + .into_iter() + .find(|(off, _)| segments.contains(off)) + .unwrap(); + assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); + } + } + /// What a page costs is the same however much a run has left. Given exactly that it decodes, /// and less stops it: spending all that's left, and what comes after isn't read. A page that /// fails still spends what it took. diff --git a/vendor/README.md b/vendor/README.md index 4fda6483af..c668c77641 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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 | (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` | +| 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; (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, and a refinement's offset to its reference is taken in 64 bits and held to an `i32`, which leaves every reference pixel it reads outside the reference, as the exact offset does | (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`; (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 `decode::generic_refinement::tests::a_reference_offset_is_the_difference_of_unsigned_locations` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index 87738a849d..e9049f84bf 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -190,6 +190,17 @@ impl Bitmap { self.data[word_idx] |= (value as Word) << bit_pos; } + /// PdfCraft patch: combine a decoded region into this page at the region's own location. + /// + /// "Unless otherwise specified, all multi-bit values shall be treated as unsigned values" + /// (5.4.4), and a region's location (7.4.1.3, 7.4.1.4) is not specified otherwise. One past + /// `i32::MAX` is past any page, where cast to a signed coordinate it read as left of the page + /// or above it, and came back onto it. + pub(crate) fn combine_region(&mut self, region: &Self, operator: CombinationOperator) { + let signed = |location: u32| i32::try_from(location).unwrap_or(i32::MAX); + self.combine(region, signed(region.x_location), signed(region.y_location), operator); + } + /// Combine another bitmap into this one at a specific location. /// /// "These operators describe how the segment's bitmap is to be combined with diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index a6634439d4..c13d9bae65 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -10,7 +10,7 @@ use crate::ScratchBuffers; use crate::arithmetic_decoder::{ArithmeticDecoder, ArithmeticDecoderContext}; use crate::bitmap::{Bitmap, WORD_BITS, WORD_SHIFT, Word}; use crate::decode::generic::ContextGatherer; -use crate::error::{OverflowError, ParseError, RegionError, Result, bail}; +use crate::error::{ParseError, RegionError, Result, bail}; use crate::reader::Reader; /// Generic refinement region decoding procedure (6.3). @@ -35,6 +35,20 @@ pub(crate) fn decode( }) } +/// PdfCraft patch: the offset of a refinement's reference from the region, from their locations. +/// +/// A location is unsigned (5.4.4), so one past `i32::MAX` is a place off the page, not an +/// overflow: a refinement there, of a region at the same place (7.4.7.5) or of the page, was +/// refused, and the rest of the stream with it. Both bitmaps are at most 65,535 pixels a side, so +/// an offset of `i32::MAX` either way already puts every reference pixel the decoder reads outside +/// the reference, where they are 0 (6.3.5.2), as any larger offset would. +fn reference_offset(reference: u32, region: u32) -> i32 { + let limit = i64::from(i32::MAX); + let offset = (i64::from(reference) - i64::from(region)).clamp(-limit, limit); + // Provably in range after the clamp. + i32::try_from(offset).unwrap_or(0) +} + /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, @@ -51,22 +65,8 @@ pub(crate) fn decode_into( bail!(RegionError::InvalidDimension); } - let reference_dx = i32::try_from(reference.x_location) - .ok() - .and_then(|r: i32| { - i32::try_from(header.region_info.x_location) - .ok() - .and_then(|h| r.checked_sub(h)) - }) - .ok_or(OverflowError::ReferenceOffset)?; - let reference_dy = i32::try_from(reference.y_location) - .ok() - .and_then(|r: i32| { - i32::try_from(header.region_info.y_location) - .ok() - .and_then(|h| r.checked_sub(h)) - }) - .ok_or(OverflowError::ReferenceOffset)?; + let reference_dx = reference_offset(reference.x_location, header.region_info.x_location); + let reference_dy = reference_offset(reference.y_location, header.region_info.y_location); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -509,3 +509,22 @@ pub(crate) fn decode_bitmap( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// PdfCraft patch: the reference's offset is the difference of the locations, which past + /// `i32::MAX` are places off the page: equal ones give 0, as a refinement of a region has them + /// (7.4.7.5), and one too far for an `i32` is held to `i32::MAX`, either way. + #[test] + fn a_reference_offset_is_the_difference_of_unsigned_locations() { + assert_eq!(reference_offset(5, 3), 2); + assert_eq!(reference_offset(3, 5), -2); + assert_eq!(reference_offset(0x9000_0000, 0x9000_0000), 0, "a region and its refinement off the page"); + assert_eq!(reference_offset(0, 0xFFFF_FFFC), -i32::MAX, "the page and a refinement of it off the page"); + assert_eq!(reference_offset(u32::MAX, 0), i32::MAX); + assert_eq!(reference_offset(0x7FFF_FFFF, 0), i32::MAX, "in range"); + assert_eq!(reference_offset(0, 0x7FFF_FFFF), -i32::MAX, "in range"); + } +} diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 2ee0f8f050..5dd81bc6c0 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -356,12 +356,7 @@ fn decode_segments( generic::decode_into(&header, page_bitmap, scratch_buffers)?; } else { let region = generic::decode(&header, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -460,12 +455,7 @@ fn decode_segments( scratch_buffers, work_left, )?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -518,12 +508,7 @@ fn decode_segments( halftone::decode_into(&header, pattern_dict, page_bitmap, scratch_buffers)?; } else { let region = halftone::decode(&header, pattern_dict, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -582,12 +567,7 @@ fn decode_segments( } else { let reference = referred_segment.unwrap_or(page_bitmap); let region = generic_refinement::decode(&header, reference, scratch_buffers)?; - page_bitmap.combine( - ®ion.bitmap, - region.bitmap.x_location as i32, - region.bitmap.y_location as i32, - region.combination_operator, - ); + page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; } @@ -833,6 +813,71 @@ mod tests { assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); } + /// PdfCraft patch: a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the + /// page. Taken for a signed one, a region at 0xFFFFFFFE drew back onto the page two pixels left + /// of it, and a refinement there was refused, and the rest of the stream with it. Each region + /// here is 4 × 4 on a blank page of 8 × 8, its coded data arbitrary: on the page it draws, off + /// it nothing. + #[test] + fn a_region_located_past_i32_max_is_off_the_page() { + struct Ink(u32); + impl Decoder for Ink { + fn push_pixel(&mut self, black: bool) { + self.0 += u32::from(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.0 += if black { 8 * chunks } else { 0 }; + } + fn next_line(&mut self) {} + } + let segment = |number: u8, kind: u8, refers_to: &[u8], data: &[u8]| { + let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; + segment.extend_from_slice(refers_to); + segment.push(1); // the page it belongs to + segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); + segment.extend_from_slice(data); + segment + }; + // Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own header, then + // arbitrary coded data. + let region = |x: u32, y: u32, operator: u8, header: &[u8]| { + let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); + data.push(operator); + data.extend_from_slice(header); + data.extend_from_slice(&[0x5a; 16]); + data + }; + const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT + const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT + let (or, replace) = (0, 4); + let ink = |x: u32, y: u32| { + // 8 × 8, resolution unknown, flags, not striped. + let page = segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]); + let forms = [ + // An immediate generic region. + vec![segment(1, 38, &[], ®ion(x, y, or, GENERIC))], + // An immediate refinement of the page, which reads the page in its own area (7.4.7.4). + vec![segment(1, 42, &[], ®ion(x, y, replace, REFINEMENT))], + // An immediate refinement of an intermediate region at the same place (7.4.7.5). + vec![ + segment(1, 36, &[], ®ion(x, y, or, GENERIC)), + segment(2, 42, &[1], ®ion(x, y, or, REFINEMENT)), + ], + ]; + forms.map(|form| { + let stream = [page.clone(), form.concat()].concat(); + let mut ink = Ink(0); + Image::new_embedded(&stream, None).and_then(|image| image.decode(&mut ink)).map(|()| ink.0) + }) + }; + for (x, y) in [(0, 0), (2, 2)] { + assert!(ink(x, y).iter().all(|ink| matches!(ink, Ok(n) if *n > 0)), "{x}, {y}: {:?}", ink(x, y)); + } + for (x, y) in [(0xFFFF_FFFE, 0), (0, 0xFFFF_FFFE), (0x8000_0000, 0x8000_0000), (u32::MAX, u32::MAX)] { + assert_eq!(ink(x, y).map(|ink| ink.ok()), [Some(0); 3], "{x:#x}, {y:#x}"); + } + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From b9e068aa3662d603289bfd8e10ff5a027c2b98d6 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:32:29 +0100 Subject: [PATCH 245/562] M2: a JBIG2 refinement of a region refines it where it is "GRREFERENCEDX 0, GRREFERENCEDY 0" (ISO/IEC 14492 Table 35): a refinement region segment that refers to another region refines that region where it is. hayro-jbig2 offset the reference by the difference of the two locations, which 7.4.7.5 says must be equal; where a stream has them differ, the refinement decoded against a shifted reference, or, the region past i32::MAX, against none. It now decodes as coded, and only a refinement of the page takes an offset, from its own location. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/filters/src/jbig2/decode.rs | 8 +++ vendor/README.md | 2 +- .../src/decode/generic_refinement.rs | 53 ++++++++++--------- vendor/hayro-jbig2/src/lib.rs | 13 +++-- 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/crates/filters/src/jbig2/decode.rs b/crates/filters/src/jbig2/decode.rs index 6d6759e3ea..a10de4af45 100644 --- a/crates/filters/src/jbig2/decode.rs +++ b/crates/filters/src/jbig2/decode.rs @@ -192,6 +192,14 @@ mod tests { .unwrap(); assert_ne!(decode(&segments.replace(off, on)), page, "{kind} on the page"); } + + // A refinement at 2, 0 of an intermediate region at 0x80000000, 0. A refinement of a + // region refines it where it is, "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35), and the two + // should be at one place (7.4.7.5). Where they aren't, it decodes as it does where they are. + const ELSEWHERE: &str = "000000022400010000002300000008000000068000000000000000000003fffdff02fefefe1bca3149568cdfffac000000032a2002010000001e000000080000000600000002000000000000ffffffff784a909cc761ffac"; + let refined = decode(ELSEWHERE); + assert_eq!(refined, decode(&ELSEWHERE.replace("8000000000000000", "0000000200000000")), "the region at 2, 0 too"); + assert_ne!(refined, page, "drawn"); } /// What a page costs is the same however much a run has left. Given exactly that it decodes, diff --git a/vendor/README.md b/vendor/README.md index c668c77641..db21b1407e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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; (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, and a refinement's offset to its reference is taken in 64 bits and held to an `i32`, which leaves every reference pixel it reads outside the reference, as the exact offset does | (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`; (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 `decode::generic_refinement::tests::a_reference_offset_is_the_difference_of_unsigned_locations` | +| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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 `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/decode/generic_refinement.rs b/vendor/hayro-jbig2/src/decode/generic_refinement.rs index c13d9bae65..fe7f8cc33d 100644 --- a/vendor/hayro-jbig2/src/decode/generic_refinement.rs +++ b/vendor/hayro-jbig2/src/decode/generic_refinement.rs @@ -17,6 +17,7 @@ use crate::reader::Reader; pub(crate) fn decode( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, + refines_page: bool, ctx: &mut ScratchBuffers, ) -> Result { let mut region = Bitmap::new_with( @@ -27,7 +28,7 @@ pub(crate) fn decode( false, )?; - decode_into(header, reference, &mut region, ctx)?; + decode_into(header, reference, refines_page, &mut region, ctx)?; Ok(RegionBitmap { bitmap: region, @@ -35,24 +36,29 @@ pub(crate) fn decode( }) } -/// PdfCraft patch: the offset of a refinement's reference from the region, from their locations. +/// PdfCraft patch: the offsets of a refinement region segment's reference from the region at `x`, +/// `y`, the GRREFERENCEDX and GRREFERENCEDY of 6.3. /// -/// A location is unsigned (5.4.4), so one past `i32::MAX` is a place off the page, not an -/// overflow: a refinement there, of a region at the same place (7.4.7.5) or of the page, was -/// refused, and the rest of the stream with it. Both bitmaps are at most 65,535 pixels a side, so -/// an offset of `i32::MAX` either way already puts every reference pixel the decoder reads outside -/// the reference, where they are 0 (6.3.5.2), as any larger offset would. -fn reference_offset(reference: u32, region: u32) -> i32 { - let limit = i64::from(i32::MAX); - let offset = (i64::from(reference) - i64::from(region)).clamp(-limit, limit); - // Provably in range after the clamp. - i32::try_from(offset).unwrap_or(0) +/// "GRREFERENCEDX 0, GRREFERENCEDY 0" (Table 35): a refinement of another region refines it where +/// it is, whatever their locations say (they should be equal, 7.4.7.5). They were subtracted, so a +/// refinement of a region past `i32::MAX` was refused as an overflow, and the rest of the stream +/// with it. A refinement of the page refines the page at the refinement's location (7.4.7.4), the +/// page being at 0, 0. A location is unsigned (5.4.4), and the page is at most 65,535 pixels a +/// side, so held to `i32::MAX` one past that still puts every page pixel the decoder reads outside +/// the page, where they are 0 (6.3.5.2). +fn reference_offsets(refines_page: bool, x: u32, y: u32) -> (i32, i32) { + if !refines_page { + return (0, 0); + } + let offset = |location: u32| i32::try_from(location).map_or(-i32::MAX, |location| -location); + (offset(x), offset(y)) } /// Decode a generic refinement region directly into an existing bitmap. pub(crate) fn decode_into( header: &GenericRefinementRegionHeader<'_>, reference: &Bitmap, + refines_page: bool, region: &mut Bitmap, ctx: &mut ScratchBuffers, ) -> Result<()> { @@ -65,8 +71,8 @@ pub(crate) fn decode_into( bail!(RegionError::InvalidDimension); } - let reference_dx = reference_offset(reference.x_location, header.region_info.x_location); - let reference_dy = reference_offset(reference.y_location, header.region_info.y_location); + let (reference_dx, reference_dy) = + reference_offsets(refines_page, header.region_info.x_location, header.region_info.y_location); let mut decoder = ArithmeticDecoder::new(data); let num_context_bits = header.template.context_bits(); @@ -514,17 +520,14 @@ pub(crate) fn decode_bitmap( mod tests { use super::*; - /// PdfCraft patch: the reference's offset is the difference of the locations, which past - /// `i32::MAX` are places off the page: equal ones give 0, as a refinement of a region has them - /// (7.4.7.5), and one too far for an `i32` is held to `i32::MAX`, either way. + /// PdfCraft patch: a refinement of a region refines it where it is (Table 35), wherever their + /// locations are; a refinement of the page refines it at its own location, held to `i32::MAX` + /// past that. #[test] - fn a_reference_offset_is_the_difference_of_unsigned_locations() { - assert_eq!(reference_offset(5, 3), 2); - assert_eq!(reference_offset(3, 5), -2); - assert_eq!(reference_offset(0x9000_0000, 0x9000_0000), 0, "a region and its refinement off the page"); - assert_eq!(reference_offset(0, 0xFFFF_FFFC), -i32::MAX, "the page and a refinement of it off the page"); - assert_eq!(reference_offset(u32::MAX, 0), i32::MAX); - assert_eq!(reference_offset(0x7FFF_FFFF, 0), i32::MAX, "in range"); - assert_eq!(reference_offset(0, 0x7FFF_FFFF), -i32::MAX, "in range"); + fn a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location() { + assert_eq!(reference_offsets(false, 5, 0x8000_0000), (0, 0)); + assert_eq!(reference_offsets(true, 5, 3), (-5, -3)); + assert_eq!(reference_offsets(true, 0x7FFF_FFFF, 0), (-i32::MAX, 0), "in range"); + assert_eq!(reference_offsets(true, 0x8000_0000, u32::MAX), (-i32::MAX, -i32::MAX)); } } diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 5dd81bc6c0..1ada85fd2a 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -527,16 +527,17 @@ fn decode_segments( } SegmentType::IntermediateGenericRefinementRegion => { // Same logic as immediate refinement, but store result instead of combining. - let reference = seg + let referred_segment = seg .header .referred_to_segments .first() - .and_then(|&num| page_state.get_referred_segment(num)) - .unwrap_or(page_bitmap); + .and_then(|&num| page_state.get_referred_segment(num)); let header = generic_refinement::parse(&mut reader)?; charge_work(work_left, region_work(&header.region_info))?; - let region = generic_refinement::decode(&header, reference, scratch_buffers)?; + let reference = referred_segment.unwrap_or(page_bitmap); + let refines_page = referred_segment.is_none(); + let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; page_state.store_region(seg.header.segment_number, region.bitmap)?; } SegmentType::ImmediateGenericRefinementRegion @@ -561,12 +562,14 @@ fn decode_segments( generic_refinement::decode_into( &header, referred_segment, + false, page_bitmap, scratch_buffers, )?; } else { let reference = referred_segment.unwrap_or(page_bitmap); - let region = generic_refinement::decode(&header, reference, scratch_buffers)?; + let refines_page = referred_segment.is_none(); + let region = generic_refinement::decode(&header, reference, refines_page, scratch_buffers)?; page_bitmap.combine_region(®ion.bitmap, region.combination_operator); } page_state.page_pristine = false; From 858ce9608b17cb0c8a6b3da33428c38911b480a4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 3 Nov 2026 19:54:08 +0100 Subject: [PATCH 246/562] M2: a test pins which bitmap a JBIG2 refinement refines A region and its refinement, directly and through an intermediate refinement, decode otherwise than the same refinements of the page, and at 2, 3 to exactly the pixels they decode to at 0, 0, moved. A handler that refines the page instead, or reads the region at the page's offsets, fails it, as does dropping intermediate refinements; no test caught those. The test's helpers move to the module. A comment still said a refinement of the page reads "its own area"; it reads the page at its own location. And only a location within a region's size of 2^32 came back onto the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- vendor/README.md | 2 +- vendor/hayro-jbig2/src/bitmap.rs | 4 +- vendor/hayro-jbig2/src/lib.rs | 108 +++++++++++++++++++++++-------- 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/vendor/README.md b/vendor/README.md index db21b1407e..c4244ce0d5 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,7 +41,7 @@ Other resource categories retain their existing parent-chain lookup. |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, 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, interpolated image 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 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); (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) | (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_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `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::interpolated_minified_image_edges_snap_to_whole_pixels` (`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`; (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`) | -| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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 `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | +| 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; (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, and a refinement of the page reads the page at its own location, held to `i32::MAX` past that, which still leaves every page pixel it reads outside the page | (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`; (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`, `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are` and `decode::generic_refinement::tests::a_refinement_refines_a_region_where_it_is_and_the_page_at_its_location` | | 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; (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 | (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`; (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) | | 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 | diff --git a/vendor/hayro-jbig2/src/bitmap.rs b/vendor/hayro-jbig2/src/bitmap.rs index e9049f84bf..2e77a639b3 100644 --- a/vendor/hayro-jbig2/src/bitmap.rs +++ b/vendor/hayro-jbig2/src/bitmap.rs @@ -194,8 +194,8 @@ impl Bitmap { /// /// "Unless otherwise specified, all multi-bit values shall be treated as unsigned values" /// (5.4.4), and a region's location (7.4.1.3, 7.4.1.4) is not specified otherwise. One past - /// `i32::MAX` is past any page, where cast to a signed coordinate it read as left of the page - /// or above it, and came back onto it. + /// `i32::MAX` is past any page. Cast to a signed coordinate it read as left of the page or above + /// it, and one within a region's size of 2^32 came back onto it. pub(crate) fn combine_region(&mut self, region: &Self, operator: CombinationOperator) { let signed = |location: u32| i32::try_from(location).unwrap_or(i32::MAX); self.combine(region, signed(region.x_location), signed(region.y_location), operator); diff --git a/vendor/hayro-jbig2/src/lib.rs b/vendor/hayro-jbig2/src/lib.rs index 1ada85fd2a..b78816b1a6 100644 --- a/vendor/hayro-jbig2/src/lib.rs +++ b/vendor/hayro-jbig2/src/lib.rs @@ -816,6 +816,36 @@ mod tests { assert!(Image::new_embedded(&stream(1 << 20, 1 << 20), None).is_err()); } + /// Segment `number` of `kind` on page 1, referring to `refers_to`. + fn segment(number: u8, kind: u8, refers_to: &[u8], data: &[u8]) -> Vec { + let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; + segment.extend_from_slice(refers_to); + segment.push(1); + segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); + segment.extend_from_slice(data); + segment + } + + /// A blank page of 8 × 8: resolution unknown, flags, not striped. + fn page_of_8() -> Vec { + segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]) + } + + /// Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own `header`, then + /// arbitrary coded data. + fn region_of_4(x: u32, y: u32, operator: u8, header: &[u8]) -> Vec { + let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); + data.push(operator); + data.extend_from_slice(header); + data.extend_from_slice(&[0x5a; 16]); + data + } + + const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT + const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT + const OR: u8 = 0; + const REPLACE: u8 = 4; + /// PdfCraft patch: a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the /// page. Taken for a signed one, a region at 0xFFFFFFFE drew back onto the page two pixels left /// of it, and a refinement there was refused, and the rest of the stream with it. Each region @@ -833,42 +863,20 @@ mod tests { } fn next_line(&mut self) {} } - let segment = |number: u8, kind: u8, refers_to: &[u8], data: &[u8]| { - let mut segment = vec![0, 0, 0, number, kind, (refers_to.len() as u8) << 5]; - segment.extend_from_slice(refers_to); - segment.push(1); // the page it belongs to - segment.extend_from_slice(&(data.len() as u32).to_be_bytes()); - segment.extend_from_slice(data); - segment - }; - // Region information (7.4.1) of 4 × 4 at `x`, `y`, then the region's own header, then - // arbitrary coded data. - let region = |x: u32, y: u32, operator: u8, header: &[u8]| { - let mut data: Vec = [4u32, 4, x, y].iter().flat_map(|v| v.to_be_bytes()).collect(); - data.push(operator); - data.extend_from_slice(header); - data.extend_from_slice(&[0x5a; 16]); - data - }; - const GENERIC: &[u8] = &[0, 3, 0xFF, 0xFD, 0xFF, 2, 0xFE, 0xFE, 0xFE]; // template 0, AT - const REFINEMENT: &[u8] = &[0, 0xFF, 0xFF, 0xFF, 0xFF]; // template 0, AT - let (or, replace) = (0, 4); let ink = |x: u32, y: u32| { - // 8 × 8, resolution unknown, flags, not striped. - let page = segment(0, 48, &[], &[0, 0, 0, 8, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0x42, 0, 0]); let forms = [ // An immediate generic region. - vec![segment(1, 38, &[], ®ion(x, y, or, GENERIC))], - // An immediate refinement of the page, which reads the page in its own area (7.4.7.4). - vec![segment(1, 42, &[], ®ion(x, y, replace, REFINEMENT))], + vec![segment(1, 38, &[], ®ion_of_4(x, y, OR, GENERIC))], + // An immediate refinement of the page, which reads the page at its own location (7.4.7.4). + vec![segment(1, 42, &[], ®ion_of_4(x, y, REPLACE, REFINEMENT))], // An immediate refinement of an intermediate region at the same place (7.4.7.5). vec![ - segment(1, 36, &[], ®ion(x, y, or, GENERIC)), - segment(2, 42, &[1], ®ion(x, y, or, REFINEMENT)), + segment(1, 36, &[], ®ion_of_4(x, y, OR, GENERIC)), + segment(2, 42, &[1], ®ion_of_4(x, y, OR, REFINEMENT)), ], ]; forms.map(|form| { - let stream = [page.clone(), form.concat()].concat(); + let stream = [page_of_8(), form.concat()].concat(); let mut ink = Ink(0); Image::new_embedded(&stream, None).and_then(|image| image.decode(&mut ink)).map(|()| ink.0) }) @@ -881,6 +889,50 @@ mod tests { } } + /// PdfCraft patch: a refinement of a region refines it where it is, "GRREFERENCEDX 0, + /// GRREFERENCEDY 0" (Table 35), not the page at the refinement's location. A region and its + /// refinement, directly or through an intermediate refinement, decode otherwise than the same + /// refinements of the page, and at 2, 3 to the pixels they decode to at 0, 0, moved. + #[test] + fn a_refinement_of_a_region_decodes_the_same_wherever_they_are() { + #[derive(Default)] + struct Pixels(Vec); + impl Decoder for Pixels { + fn push_pixel(&mut self, black: bool) { + self.0.push(black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.0.extend((0..8 * chunks).map(|_| black)); + } + fn next_line(&mut self) {} + } + // The first refinement refers to the region before it, or `of_page`, to nothing: the page. + let decode = |x: u32, y: u32, through_intermediate: bool, of_page: bool| { + let first: &[u8] = if of_page { &[] } else { &[1] }; + let mut form = vec![segment(1, 36, &[], ®ion_of_4(x, y, OR, GENERIC))]; + if through_intermediate { + form.push(segment(2, 40, first, ®ion_of_4(x, y, OR, REFINEMENT))); + form.push(segment(3, 42, &[2], ®ion_of_4(x, y, OR, REFINEMENT))); + } else { + form.push(segment(2, 42, first, ®ion_of_4(x, y, OR, REFINEMENT))); + } + let stream = [page_of_8(), form.concat()].concat(); + let mut pixels = Pixels::default(); + Image::new_embedded(&stream, None).unwrap().decode(&mut pixels).unwrap(); + pixels.0 + }; + for through_intermediate in [false, true] { + let at_origin = decode(0, 0, through_intermediate, false); + assert!(at_origin.contains(&true), "draws"); + assert_ne!(at_origin, decode(0, 0, through_intermediate, true), "the page refined instead"); + for (i, &black) in decode(2, 3, through_intermediate, false).iter().enumerate() { + let (x, y) = (i % 8, i / 8); + let moved = x >= 2 && y >= 3 && at_origin[(y - 3) * 8 + x - 2]; + assert_eq!(black, moved, "{x}, {y}, through an intermediate refinement: {through_intermediate}"); + } + } + } + /// A stream and its globals are one stream of segments, so they have one count. #[test] fn the_segments_of_a_stream_and_its_globals_count_together() { From 76260bb6a5032fd9ab3cfd364add751c3f80b828 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:33:41 -0500 Subject: [PATCH 247/562] fix(xfa): scripts come only from the XDP's own packets and quote the date pattern (#5987) The template (and the saved form state) was found anywhere under the XDP root, so a template-shaped element inside imported datasets could supply the form and its event scripts. A date field's pattern went into AFDate_FormatEx/KeystrokeEx string literals unescaped. --- crates/xfa/src/parse.rs | 6 ++++-- crates/xfa/src/pdf.rs | 9 ++++++--- crates/xfa/src/pdf/common.rs | 32 ++++++++++++++++++++++++++++++++ crates/xfa/src/tests.rs | 10 ++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 9ca6a7f3cd..76409dc697 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -746,10 +746,12 @@ pub fn parse(xml: &str) -> Result<(Template, Vec), XfaError> { && n.tag_name().name() == "template" && (n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-template")) || children(*n, "subform").next().is_some()) }; + // The template is a packet of the XDP itself, a child of its root: one nested deeper is + // data (an imported dataset, say), which must not supply the form's scripts. let template = if is_template(&root) { root } else { - root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no