From f56a146b220f7e970b0e61ed21eb5f3fcf30bf90 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:39:13 +0100 Subject: [PATCH 01/21] feat(js): timers keep and fire in the document's script realm (#169) Ported from #4751 by @pyatsysh onto #5699's per-document realm. `app.setInterval` and `app.setTimeOut` (an expression or a function) are kept by the retained realm and reported in every Outcome (`timers`); the caller owns the clock and fires one by running a Job with `timer` set, in the realm that set it. `clearInterval`/`clearTimeOut` forget one. Bounds kept from #4751: at most 64 timers per document, periods of at least 10 ms. A one-off run keeps none (the functions return an inert object, as before), and changed document scripts start a realm without the old timers. Co-authored-by: Peter Yatsyshin --- crates/engine/src/js.rs | 4 ++ crates/js/src/globals.rs | 13 ++-- crates/js/src/lib.rs | 2 + crates/js/src/model.rs | 3 + crates/js/src/run.rs | 13 +++- crates/js/src/session.rs | 107 ++++++++++++++++++++++++++++++- crates/js/src/timers.rs | 132 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 crates/js/src/timers.rs diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index cb4c0caf65..f7bc3cbd5b 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -660,6 +660,7 @@ impl Scripts for JsRunner { fields: states, doc_scripts: self.doc_scripts.clone(), calculations, + timer: None, }; #[cfg(not(target_arch = "wasm32"))] let remaining = self.callback_time_left(); @@ -748,6 +749,7 @@ impl Scripts for JsRunner { fields: states, doc_scripts: self.doc_scripts.clone(), calculations: calculations.clone(), + timer: None, }; let remaining = self.callback_time_left(); let outcome = if remaining.is_zero() { @@ -1028,6 +1030,7 @@ impl Session { fields: states.clone(), doc_scripts: runner.doc_scripts, calculations, + timer: None, }, answers, ); @@ -1495,6 +1498,7 @@ impl Session { fields: states.clone(), doc_scripts: runner.doc_scripts, calculations: calculation_scripts(cos, &fields), + timer: None, }; let receiver = doc.js_runtime.lock().map_err(|_| EditError::Invalid("script runtime unavailable".into()))?.start(job).map_err(EditError::Invalid)?; diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 3dfbf3924e..5d690f3b19 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -416,11 +416,6 @@ fn js_number(v: f64) -> String { if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } } -fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - // Timers never fire in the sandbox; scripts get an object they can pass to clear*. - Ok(ObjectInitializer::new(ctx).build().into()) -} - /// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as /// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. fn color_object(ctx: &mut Context) -> JsObject { @@ -556,10 +551,10 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) .function(NativeFunction::from_fn_ptr(crate::interaction::popup_menu), js_string!("popUpMenuEx"), 0) .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .function(NativeFunction::from_fn_ptr(crate::timers::set_time_out), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(crate::timers::set_interval), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(crate::timers::clear), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(crate::timers::clear), js_string!("clearInterval"), 1) .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 6d722c00ae..482e6c8bd0 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -73,6 +73,8 @@ mod geometry; pub use geometry::{rotate_rect, unrotate_rect}; mod icons; +mod timers; +pub use timers::{MAX_TIMERS, MIN_TIMER_MS, Timer}; pub mod interaction; pub mod session; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index 70bb677d8a..e94868d993 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -382,6 +382,9 @@ pub struct Outcome { pub doc_errors: Vec, /// The script's completion value (what the console prints), unless undefined. pub result: Option, + /// The timers of a document's retained realm as they stand after the run, in the order they + /// were set (`app.setInterval`, `app.setTimeOut`); none from a one-off run. + pub timers: Vec, } /// Bounds that keep a script from hanging the viewer. diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index ececa107a3..3353fe4826 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -1,7 +1,9 @@ use super::globals::{event_object, install}; use super::host::{Host, Shared, host, text}; use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; -use super::{Arc, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string}; +use super::{ + Arc, BTreeSet, Context, HostHooks, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Realm, RefCell, Source, js_string, +}; /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; @@ -148,6 +150,7 @@ pub(crate) fn run_shared_here( reset_calls: 0, calculation_evaluations: 0, calculation_limit_exceeded: false, + call: None, }, limits, ) @@ -200,6 +203,8 @@ pub(crate) struct ScriptSetup<'a> { pub(crate) reset_calls: usize, pub(crate) calculation_evaluations: usize, pub(crate) calculation_limit_exceeded: bool, + /// A timer's function to call in place of the script (#169). + pub(crate) call: Option, } /// Create a restricted temporary Boa context and convert initialization failures into script outcomes. @@ -296,7 +301,10 @@ pub(crate) fn run_in_context( doc_errors.push((i, doc_error(i, &e))); } } - let r = ctx.eval(Source::from_bytes(script))?; + let r = match &setup.call { + Some(f) => f.call(&JsValue::undefined(), &[], ctx)?, + None => ctx.eval(Source::from_bytes(script))?, + }; if !r.is_undefined() { out.result = Some(text(&r, ctx)?); } @@ -332,6 +340,7 @@ pub(crate) fn run_in_context( } ctx.insert_data(RefCell::new(h)); } + out.timers = crate::timers::report(ctx); out } diff --git a/crates/js/src/session.rs b/crates/js/src/session.rs index 1e01674df1..c92b074adf 100644 --- a/crates/js/src/session.rs +++ b/crates/js/src/session.rs @@ -16,6 +16,10 @@ pub struct Job { pub doc_scripts: Vec, /// Target positions in this job's field snapshot and their ordered script sources. pub calculations: Vec<(usize, String)>, + /// Fire this timer of the realm (`app.setInterval`, `app.setTimeOut`) in place of `script` + /// (#169). A timer the realm no longer has runs nothing; nor does any timer of a realm that + /// has not started, or whose document scripts changed (its timers went with it). + pub timer: Option, } /// Live field instances are independent of restorable PDF object-number allocation. #[derive(Default)] @@ -129,6 +133,9 @@ impl Realm { } fn run_job(&mut self, job: Job) -> Outcome { let failed = |why: String| Outcome { rc: true, execution_refused: true, realm_untouched: true, error: Some(why), ..Default::default() }; + if let Some(id) = job.timer { + return self.fire(job, id); + } // A script too long or too deeply nested to run is that script's error, as on `main`: the // value stays and the edit's other scripts still run. The realm is not touched. if let Some(why) = crate::refuse(&job.script) { @@ -147,7 +154,10 @@ impl Realm { let initialize = self.context.is_none() || self.scripts != job.doc_scripts; if initialize { self.context = match crate::Context::builder().host_hooks(std::rc::Rc::new(crate::run::NoCodeFromStrings)).build() { - Ok(c) => Some(c), + Ok(mut c) => { + crate::timers::enable(&mut c); + Some(c) + } Err(e) => return failed(e.to_string()), }; self.scripts = job.doc_scripts.clone(); @@ -186,6 +196,7 @@ impl Realm { reset_calls: 0, calculation_evaluations: 0, calculation_limit_exceeded: false, + call: None, }, Limits::default(), ); @@ -212,6 +223,7 @@ impl Realm { reset_calls, calculation_evaluations, calculation_limit_exceeded: initial.calculation_limit_exceeded, + call: None, }, Limits::default(), ); @@ -251,6 +263,7 @@ impl Realm { reset_calls: 0, calculation_evaluations: 0, calculation_limit_exceeded: false, + call: None, }, Limits::default(), ) @@ -258,6 +271,43 @@ impl Realm { } } +impl Realm { + /// Run timer `id` that fell due, with the job's form and document: its expression, or the + /// function it was given, in the realm that set it (#169). + fn fire(&mut self, job: Job, id: u32) -> Outcome { + let idle = |timers: Vec| Outcome { rc: true, realm_untouched: true, timers, ..Default::default() }; + let Some(ctx) = self.context.as_mut().filter(|_| self.scripts == job.doc_scripts) else { return idle(Vec::new()) }; + let Some(code) = crate::timers::due(ctx, id) else { return idle(crate::timers::report(ctx)) }; + let (script, call) = match code { + crate::timers::Code::Source(source) => { + if let Some(why) = crate::refuse(&source) { + return Outcome { rc: true, realm_untouched: true, error: Some(why), timers: crate::timers::report(ctx), ..Default::default() }; + } + (source, None) + } + crate::timers::Code::Function(f) => (String::new(), Some(f)), + }; + crate::run::run_in_context( + ctx, + &script, + &job.event, + &job.doc, + job.fields, + crate::run::ScriptSetup { + doc_scripts: &[], + skipped: &[], + calculations: &job.calculations, + initialize: false, + reset_calls: 0, + calculation_evaluations: 0, + calculation_limit_exceeded: false, + call, + }, + Limits::default(), + ) + } +} + #[cfg(not(target_arch = "wasm32"))] static LIVE_WORKERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); #[cfg(not(target_arch = "wasm32"))] @@ -577,8 +627,57 @@ mod tests { fields: Arc::new(vec![FieldState::new("go", crate::FieldType::Button, vec!["old".into()])]), doc_scripts: vec!["var buffer='', init=1, opened=event.name;var held=getField('go');function digit(v){buffer+=v;return buffer;}".into()], calculations: Vec::new(), + timer: None, } } + /// A job firing timer `id` of the realm, as the viewer runs one that fell due (#169). + fn fire(id: u32) -> Job { + Job { script: String::new(), event: Event { kind: "App".into(), ..Event::doc("Timer") }, timer: Some(id), ..job("") } + } + #[test] + /// Timers a script sets stay in the document's realm and fire there: an interval until it is + /// cleared, a time-out once; the field writes a timer makes are reported like any script's. + fn timers_fire_in_the_document_realm_until_cleared() { + let mut s = ScriptSession::default(); + let set = s.run( + job("var n = 0; var t = app.setInterval(function () { n++; getField('go').value = 'frame ' + n; }, 40); app.setTimeOut('n += 100', 0);"), + &[], + ); + assert_eq!(set.error, None); + assert_eq!( + set.timers, + [crate::Timer { id: 1, period_ms: 40, repeat: true }, crate::Timer { id: 2, period_ms: crate::MIN_TIMER_MS, repeat: false }] + ); + let first = s.run(fire(1), &[]); + assert_eq!(first.error, None); + assert_eq!(first.changed.iter().map(|f| f.value.clone()).collect::>(), [vec!["frame 1".to_string()]]); + let once = s.run(fire(2), &[]); + assert_eq!(once.timers.len(), 1, "a time-out runs once"); + assert_eq!(s.run(fire(2), &[]).realm_untouched, true, "and is gone after"); + s.run(fire(1), &[]); + assert_eq!(s.run(job("n;"), &[]).result.as_deref(), Some("102")); + let cleared = s.run(job("app.clearInterval(t); app.clearInterval({}); n;"), &[]); + assert!(cleared.timers.is_empty()); + assert!(s.run(fire(1), &[]).changed.is_empty(), "a cleared timer runs nothing"); + } + #[test] + /// A document keeps at most 64 timers, a timer runs at most every 10 ms, a one-off run keeps + /// none, and changed document scripts start a realm without the old timers. + fn timers_are_bounded_and_belong_to_one_realm() { + let mut s = ScriptSession::default(); + let many = s.run( + job("var made = 0; try { for (var i = 0; i < 70; i++) { app.setInterval('1', -5); made++; } } catch (e) { made + ': ' + e.message; }"), + &[], + ); + assert_eq!(many.result.as_deref(), Some("64: a document's scripts can keep at most 64 timers")); + assert!(many.timers.iter().all(|t| t.period_ms == crate::MIN_TIMER_MS)); + let one_off = crate::run("app.setInterval('1', 50); 'set';", &Event::doc("Console"), &DocInfo::default(), &[], &[], Limits::default()); + assert_eq!(one_off.result.as_deref(), Some("set")); + assert!(one_off.timers.is_empty()); + let changed = Job { doc_scripts: vec!["var other = 1;".into()], ..fire(1) }; + let gone = s.run(changed, &[]); + assert!(gone.timers.is_empty() && gone.changed.is_empty(), "{gone:?}"); + } #[test] /// Retained Field identity, custom properties and keypad buffers persist locally while separate documents stay isolated. fn document_realm_keeps_keypad_state_and_isolates_documents() { @@ -980,6 +1079,7 @@ mod reset_budget_tests { fields: Arc::new(vec![]), doc_scripts: vec!["for(var i=0;i<64;i++)resetForm();".into()], calculations: vec![], + timer: None, }; let out = runtime.run(job.clone(), &[]); assert!(out.reset_limit_exceeded); @@ -1006,6 +1106,7 @@ mod event_identity_tests { fields: Arc::new(vec![field]), doc_scripts: vec![], calculations: vec![], + timer: None, }; let out = ScriptSession::default().run(job, &[]); assert_eq!(out.error, None); @@ -1027,7 +1128,7 @@ mod cache_capacity_tests { }) .collect(), ); - let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![]}; + let job=Job{script:"for(var i=0;i<4100;i++)getField('f'+i);var held=getField('f4099');held.custom=42;held===getField('f4099') && getField('f4099').custom===42;".into(),event:Event::doc("Console"),doc:Default::default(),fields,doc_scripts:vec![],calculations:vec![],timer:None}; let mut session = ScriptSession::default(); let out = session.run(job.clone(), &[]); assert_eq!(out.error, None); @@ -1051,6 +1152,7 @@ mod cache_lifecycle_tests { fields: Arc::new(vec![field.clone()]), doc_scripts: vec![], calculations: vec![], + timer: None, }; let mut realm = Realm::new(); assert_eq!(realm.run(job.clone()).error, None); @@ -1088,6 +1190,7 @@ mod review13_tests { fields: Arc::new(fields), doc_scripts: vec![], calculations: vec![], + timer: None, } } /// A retired accessor must not resolve a replacement even if no job observed the deletion. diff --git a/crates/js/src/timers.rs b/crates/js/src/timers.rs new file mode 100644 index 0000000000..88c7d65caf --- /dev/null +++ b/crates/js/src/timers.rs @@ -0,0 +1,132 @@ +//! `app.setInterval`, `app.setTimeOut`, `app.clearInterval` and `app.clearTimeOut` in a +//! document's retained realm (#169, ported from #4751). The realm keeps the timers its scripts +//! set and reports them in every [`crate::Outcome`]; the caller owns the clock and fires one +//! by running a [`crate::session::Job`] with its `timer` when it falls due, in the same realm. A +//! one-off run (a fresh context) keeps none: there the functions return an object and nothing +//! ever fires, as before. + +use std::cell::RefCell; +use std::collections::BTreeMap; + +use crate::host::{arg, error, text}; +use crate::{Context, JsObject, JsResult, JsValue, MAX_SCRIPT_BYTES, ObjectInitializer}; + +/// Most timers one document's scripts keep at once; setting another is an error. +pub const MAX_TIMERS: usize = 64; +/// The shortest period, in milliseconds: a timer asked to run sooner or more often runs at this. +pub const MIN_TIMER_MS: u32 = 10; + +/// A timer a script set, as the realm reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Timer { + /// The realm's name for it, never reused while the realm lives. + pub id: u32, + /// Milliseconds between runs (`setInterval`) or before the one run (`setTimeOut`), at least + /// [`MIN_TIMER_MS`]. + pub period_ms: u32, + /// `setInterval`: it runs until it is cleared. `setTimeOut`: once. + pub repeat: bool, +} + +/// What a timer runs: its expression, or a function the script gave instead. +#[derive(Clone)] +pub(crate) enum Code { + Source(String), + Function(JsObject), +} + +struct Entry { + /// The object `set*` returned, which `clear*` is given back. + handle: JsObject, + code: Code, + period_ms: u32, + repeat: bool, +} + +/// A realm's timers by id, in the order they were set (ids only grow). +#[derive(Default)] +pub(crate) struct Timers { + last: u32, + table: BTreeMap, +} + +type Shared = RefCell; + +/// Let the timers of a retained realm run: the functions keep what they are given from now on. +pub(crate) fn enable(ctx: &mut Context) { + if ctx.get_data::().is_none() { + ctx.insert_data(RefCell::new(Timers::default())); + } +} + +/// The realm's timers as they stand, in the order they were set; none in a one-off run. +pub(crate) fn report(ctx: &Context) -> Vec { + ctx.get_data::() + .map(|t| t.borrow().table.iter().map(|(id, e)| Timer { id: *id, period_ms: e.period_ms, repeat: e.repeat }).collect()) + .unwrap_or_default() +} + +/// What timer `id` runs now that it fell due; a `setTimeOut` timer is gone after this. `None` +/// when it was cleared (or the realm has no timers). +pub(crate) fn due(ctx: &Context, id: u32) -> Option { + let mut timers = ctx.get_data::()?.borrow_mut(); + let entry = timers.table.get(&id)?; + let code = entry.code.clone(); + if !entry.repeat { + timers.table.remove(&id); + } + Some(code) +} + +/// `app.setInterval(cExpr, nMilliseconds)`: run the expression (or a function) every period +/// until it is cleared. +pub(crate) fn set_interval(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + set(args, true, ctx) +} + +/// `app.setTimeOut(cExpr, nMilliseconds)`: run the expression (or a function) once, after the +/// period. +pub(crate) fn set_time_out(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + set(args, false, ctx) +} + +fn set(args: &[JsValue], repeat: bool, ctx: &mut Context) -> JsResult { + let handle = ObjectInitializer::new(ctx).build(); + if ctx.get_data::().is_none() { + return Ok(handle.into()); + } + let what = arg(args, 0); + let code = match what.as_callable() { + Some(f) => Code::Function(f.clone()), + None => { + let source = text(&what, ctx)?; + if source.len() > MAX_SCRIPT_BYTES { + return Err(error(format!("the timer's script is too long (the limit is {} KiB)", MAX_SCRIPT_BYTES / 1024))); + } + Code::Source(source) + } + }; + let ms = arg(args, 1).to_number(ctx)?; + // A missing, negative or NaN period runs as soon as allowed; the float cast saturates. + let period_ms = if ms.is_nan() { MIN_TIMER_MS } else { (ms.min(f64::from(u32::MAX)) as u32).max(MIN_TIMER_MS) }; + let Some(timers) = ctx.get_data::() else { return Ok(handle.into()) }; + let mut timers = timers.borrow_mut(); + if timers.table.len() >= MAX_TIMERS { + return Err(error(format!("a document's scripts can keep at most {MAX_TIMERS} timers"))); + } + let id = timers.last.checked_add(1).ok_or_else(|| error("no more timers can be set"))?; + timers.last = id; + timers.table.insert(id, Entry { handle: handle.clone(), code, period_ms, repeat }); + Ok(handle.into()) +} + +/// `app.clearInterval(oInterval)` and `app.clearTimeOut(oTime)`: forget the timer the object +/// stands for. Anything else, or a timer already gone, is ignored. +pub(crate) fn clear(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + if let Some(o) = arg(args, 0).as_object() + && let Some(timers) = ctx.get_data::() + { + timers.borrow_mut().table.retain(|_, e| !JsObject::equals(&e.handle, &o)); + } + Ok(JsValue::undefined()) +} From e13d0a04ca878f1f07299fbe90704744612f774b Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:40:17 +0100 Subject: [PATCH 02/21] feat(js): this.getOCGs lists the document's layers and ocg.state turns one (#169) Ported from #4751 by @pyatsysh. DocInfo carries the layers; a run reports the layers its script turned in Outcome::layers, for the viewer to show or hide as the Layers panel does (the document isn't changed). Co-authored-by: Peter Yatsyshin --- crates/js/src/globals.rs | 3 +- crates/js/src/host.rs | 2 ++ crates/js/src/lib.rs | 2 ++ crates/js/src/model.rs | 5 ++++ crates/js/src/ocg.rs | 64 ++++++++++++++++++++++++++++++++++++++++ crates/js/src/run.rs | 3 ++ crates/js/src/tests.rs | 24 +++++++++++++++ 7 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 crates/js/src/ocg.rs diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 5d690f3b19..941b70d1f3 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -484,7 +484,7 @@ fn document_info_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult< /// Install the host API once; live document properties continue reading each job's snapshot. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 12] = [ + let globals: [(&str, usize, NativeFunction); 13] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), @@ -497,6 +497,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), ("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)), + ("getOCGs", 1, NativeFunction::from_fn_ptr(crate::ocg::get_ocgs)), ]; for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 5966e63545..6134bc4afe 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -29,6 +29,8 @@ pub(crate) struct Host { pub(crate) kept: usize, /// Alerts, console lines and requests the limits left out. pub(crate) dropped: usize, + /// Layers a script turned on or off (`ocg.state`), by their places in [`DocInfo::layers`]. + pub(crate) layers_changed: BTreeSet, } impl Host { diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 482e6c8bd0..59be3c7db4 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -73,6 +73,8 @@ mod geometry; pub use geometry::{rotate_rect, unrotate_rect}; mod icons; +mod ocg; +pub use ocg::Layer; mod timers; pub use timers::{MAX_TIMERS, MIN_TIMER_MS, Timer}; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index e94868d993..bd2f025786 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -181,6 +181,8 @@ pub struct DocInfo { pub xfa: bool, pub zoom: f64, pub runtime_highlight: bool, + /// The document's layers (optional content groups), for `getOCGs`, in the viewer's order. + pub layers: Vec, } /// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s @@ -385,6 +387,9 @@ pub struct Outcome { /// The timers of a document's retained realm as they stand after the run, in the order they /// were set (`app.setInterval`, `app.setTimeOut`); none from a one-off run. pub timers: Vec, + /// Layers the script turned on or off (`ocg.state`), as they are now: their places in + /// [`DocInfo::layers`], and whether each is on. + pub layers: Vec<(usize, bool)>, } /// Bounds that keep a script from hanging the viewer. diff --git a/crates/js/src/ocg.rs b/crates/js/src/ocg.rs new file mode 100644 index 0000000000..2f94afa2da --- /dev/null +++ b/crates/js/src/ocg.rs @@ -0,0 +1,64 @@ +//! `this.getOCGs()`: the document's layers (optional content groups) as OCG objects with +//! `name`, `state` and `initState` (#169, ported from #4751). Setting `state` turns a layer on +//! or off as the Layers panel does: how the document is shown, not the document; the caller +//! reads the layers a run turned in [`crate::Outcome::layers`]. + +use crate::host::{arg, error, function, host, s}; +use crate::{Attribute, Context, JsArray, JsResult, JsValue, NativeFunction, ObjectInitializer, js_string}; + +/// A layer as `getOCGs` shows it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Layer { + pub name: String, + /// Shown now (`ocg.state`). + pub on: bool, + /// Shown as the file opens (`ocg.initState`). + pub initial: bool, +} + +/// `this.getOCGs(nPage)`: every layer of the document, whatever the page (which pages a layer +/// is on isn't known here), or `null` when it has none. +pub(crate) fn get_ocgs(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let n = host(ctx)?.borrow().doc.layers.len(); + if n == 0 { + return Ok(JsValue::null()); + } + let items: Vec = (0..n).map(|i| ocg(ctx, i)).collect(); + Ok(JsArray::from_iter(items, ctx).into()) +} + +fn layer(c: &Context, i: u32) -> JsResult { + host(c)?.borrow().doc.layers.get(i as usize).cloned().ok_or_else(|| error("the layer is no longer available")) +} + +fn ocg(ctx: &mut Context, index: usize) -> JsValue { + fn name(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult { + Ok(s(&layer(c, *i)?.name)) + } + fn state(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult { + Ok(JsValue::from(layer(c, *i)?.on)) + } + fn initial(_: &JsValue, _: &[JsValue], i: &u32, c: &mut Context) -> JsResult { + Ok(JsValue::from(layer(c, *i)?.initial)) + } + fn set_state(_: &JsValue, args: &[JsValue], i: &u32, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + let mut h = host(c)?.borrow_mut(); + let l = h.doc.layers.get_mut(*i as usize).ok_or_else(|| error("the layer is no longer available"))?; + l.on = on; + h.layers_changed.insert(*i as usize); + Ok(JsValue::undefined()) + } + let i = u32::try_from(index).unwrap_or(u32::MAX); + let name = function(ctx, NativeFunction::from_copy_closure_with_captures(name, i)); + let state = function(ctx, NativeFunction::from_copy_closure_with_captures(state, i)); + let set_state = function(ctx, NativeFunction::from_copy_closure_with_captures(set_state, i)); + let initial = function(ctx, NativeFunction::from_copy_closure_with_captures(initial, i)); + let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + ObjectInitializer::new(ctx) + .accessor(js_string!("name"), Some(name), None, rw) + .accessor(js_string!("state"), Some(state), Some(set_state), rw) + .accessor(js_string!("initState"), Some(initial), None, rw) + .build() + .into() +} diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 3353fe4826..b446b2cd77 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -276,6 +276,7 @@ pub(crate) fn run_in_context( reset_limit_exceeded: false, kept: 0, dropped: 0, + layers_changed: BTreeSet::new(), })); let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; let mut doc_errors: Vec<(usize, String)> = setup.skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); @@ -335,6 +336,8 @@ pub(crate) fn run_in_context( out.alerts = std::mem::take(&mut h.alerts); out.console = std::mem::take(&mut h.console); out.requests = std::mem::take(&mut h.requests); + let layers = std::mem::take(&mut h.layers_changed); + out.layers = layers.into_iter().filter_map(|i| h.doc.layers.get(i).map(|l| (i, l.on))).collect(); if h.dropped > 0 { out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index c79e609967..a501f463ee 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -529,6 +529,7 @@ fn app_alert_titles_are_clipped_and_charged() { reset_limit_exceeded: false, kept: 0, dropped: 0, + layers_changed: Default::default(), }; h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") }); assert_eq!(h.kept, "Saved".len() + "Order form".len()); @@ -2702,3 +2703,26 @@ fn af_simple_calculate_bounds_selector_work_before_expansion() { run("AFSimple_Calculate('SUM',new Array(4096).fill('n'));", &Event::doc("Console"), &Default::default(), &fields, &[], Default::default()); assert!(out.error.as_deref().is_some_and(|e| e.contains("calculation field lookup exceeds"))); } + +/// `getOCGs` lists the document's layers; setting `state` turns one, which the run reports, and +/// a document without layers gives `null` (#169, from #4751). +#[test] +fn get_ocgs_lists_layers_and_reports_the_ones_a_script_turns() { + let layers = + vec![crate::Layer { name: "Frame 1".into(), on: true, initial: true }, crate::Layer { name: "Frame 2".into(), on: false, initial: false }]; + let layered = DocInfo { layers, ..doc() }; + let out = run( + "var o = this.getOCGs(); var before = o.map(function (l) { return l.name + '=' + l.state + '/' + l.initState; }).join(','); \ + o[0].state = false; o[1].state = true; before + ' ' + o[1].state;", + &Event::doc("Console"), + &layered, + &[], + &[], + Default::default(), + ); + assert_eq!(out.error, None); + assert_eq!(out.result.as_deref(), Some("Frame 1=true/true,Frame 2=false/false true")); + assert_eq!(out.layers, [(0, false), (1, true)]); + let none = run("this.getOCGs() === null;", &Event::doc("Console"), &doc(), &[], &[], Default::default()); + assert_eq!(none.result.as_deref(), Some("true")); +} From a0b4c88ba8387aef5caf9afb67db0b04c46efd79 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:48:07 +0100 Subject: [PATCH 03/21] feat(forms): page_scripts reads a page's open/close actions and its annotations' page actions (#169) Co-authored-by: Peter Yatsyshin --- crates/forms/src/lib.rs | 6 ++-- crates/forms/src/read.rs | 2 +- crates/forms/src/scripting.rs | 67 +++++++++++++++++++++++++++++++++++ crates/forms/src/tests.rs | 28 +++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 89d7e2998f..fc4bf266b5 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -31,8 +31,8 @@ pub use author::{ }; pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ - FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script, - set_field_script, + FieldChange, FieldEvent, NoScripts, PageEvent, PageScript, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, + page_scripts, set_document_script, set_field_script, }; mod appearances; @@ -46,7 +46,7 @@ pub use read::selected_options; pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; #[cfg(test)] use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; -use read::{first_by_export, script, script_budget}; +use read::{first_by_export, qualified_name, script, script_budget}; pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 4a7904f620..537237254a 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -85,7 +85,7 @@ pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec { /// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, /// from the partial names (`/T`) up its `/Parent` chain. -fn qualified_name(doc: &Document, r: ObjRef) -> Option { +pub(crate) fn qualified_name(doc: &Document, r: ObjRef) -> Option { let mut parts = Vec::new(); let mut seen = std::collections::HashSet::new(); let mut cur = Some(r); diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 3d66dd0918..f6546db546 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -468,6 +468,73 @@ fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { } } +/// When a page's actions run: as it becomes the page being read, or stops being it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PageEvent { + Open, + Close, +} + +/// A script a page runs as it opens or closes, with the field whose widget carries it (`None`: +/// the page's own action, or an annotation that isn't a field's). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageScript { + pub field: Option, + pub script: String, +} + +/// Most annotations of one page whose actions are read. +const MAX_PAGE_ANNOTATIONS: usize = 10_000; + +/// The JavaScript page `page` (0-based) runs for `event`, in the order it runs: the page's own +/// action (`/AA /O` or `/C`), then each annotation's, in the page's `/Annots` order (`/AA /PO` +/// then `/PV` as the page opens, `/PC` then `/PI` as it closes). Other kinds of action, and +/// actions chained with `/Next`, are left out. +pub fn page_scripts(doc: &Document, page: usize, event: PageEvent) -> Vec { + let Some(page_ref) = crate::page_refs(doc).get(page).copied() else { return Vec::new() }; + let page_obj = doc.get(page_ref); + let Some(pd) = page_obj.as_dict() else { return Vec::new() }; + let (own, keys): (&[u8], [&[u8]; 2]) = match event { + PageEvent::Open => (b"O", [b"PO", b"PV"]), + PageEvent::Close => (b"C", [b"PC", b"PI"]), + }; + let additional = |d: &pdfcraft_cos::Dict| d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()); + let mut budget = crate::script_budget(); + let mut out = Vec::new(); + if let Some(action) = additional(pd).and_then(|aa| aa.get(own).cloned()) + && let Some(script) = javascript(doc, &action, &mut budget) + { + out.push(PageScript { field: None, script }); + } + let annots = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for item in annots.iter().take(MAX_PAGE_ANNOTATIONS) { + let annot = doc.resolve(item); + let Some(ad) = annot.as_dict() else { continue }; + let Some(aa) = additional(ad) else { continue }; + let field = match item { + Object::Ref(r) if ad.name(b"Subtype") == Some(b"Widget".as_slice()) => crate::qualified_name(doc, *r), + _ => None, + }; + for k in keys { + if let Some(action) = aa.get(k) + && let Some(script) = javascript(doc, action, &mut budget) + { + out.push(PageScript { field: field.clone(), script }); + } + } + } + out +} + +/// The script of a JavaScript action (`/S /JavaScript`); `None` for any other action. +fn javascript(doc: &Document, action: &Object, budget: &mut pdfcraft_cos::DecodeBudget) -> Option { + let a = doc.resolve(action); + if a.as_dict()?.name(b"S")? != b"JavaScript" { + return None; + } + crate::script(doc, action, budget) +} + /// Add (or replace) a document-level JavaScript named `name`. pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> { let root = doc.root().ok_or(FormError::NoForm)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index caeba9491d..7d8bdc4673 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -7317,3 +7317,31 @@ fn simple_calculation_counts_the_first_value_of_a_multiple_selection_list() { recalculate(&mut doc).unwrap(); assert_eq!(field(&fields(&doc), "total").value, ["4"]); } + +/// A page with open and close actions of its own (one script a stream), an animation's widget +/// with page actions (one of them not a script), buttons acting on mouse down, and a screen +/// annotation with a page-open action. +fn page_actions_fixture() -> Document { + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (page_open\\(\\);) >> /C << /S /JavaScript /JS 7 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R 8 0 R] >>".into(), // 3 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (anim) /Rect [0 0 100 100] /P 3 0 R /AA << /PO << /S /JavaScript /JS (start\\(\\);) >> /PV << /S /JavaScript /JS (start\\(\\);) >> /PC << /S /JavaScript /JS (stop\\(\\);) >> /PI << /S /URI /URI (https://example.com) >> >> >>".into(), // 4 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [0 0 10 10] /P 3 0 R /AA << /D << /S /JavaScript /JS (step\\(\\);) >> >> >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (both) /Rect [20 0 30 10] /P 3 0 R /AA << /D << /S /JavaScript /JS (down\\(\\);) >> /U << /S /JavaScript /JS (up\\(\\);) >> >> >>".into(), // 6 + "<< /Length 7 >>\nstream\nclose()\nendstream".into(), // 7 + "<< /Type /Annot /Subtype /Screen /Rect [0 0 1 1] /AA << /PO << /S /JavaScript /JS (screen\\(\\);) >> >> >>".into(), // 8 + ]) +} + +#[test] +fn page_scripts_are_the_pages_own_then_each_annotations_in_order() { + let doc = page_actions_fixture(); + let open = page_scripts(&doc, 0, PageEvent::Open); + let got: Vec<(Option<&str>, &str)> = open.iter().map(|s| (s.field.as_deref(), s.script.as_str())).collect(); + assert_eq!(got, [(None, "page_open();"), (Some("anim"), "start();"), (Some("anim"), "start();"), (None, "screen();")]); + let close = page_scripts(&doc, 0, PageEvent::Close); + let got: Vec<(Option<&str>, &str)> = close.iter().map(|s| (s.field.as_deref(), s.script.trim())).collect(); + assert_eq!(got, [(None, "close()"), (Some("anim"), "stop();")], "a URI action is not a script"); + assert!(page_scripts(&doc, 1, PageEvent::Open).is_empty(), "no such page"); +} From 85020ab05f812e8da791fcb9f24e0343f71adee5 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:49:37 +0100 Subject: [PATCH 04/21] feat(render): view-only annotation visibility and appearance overrides (hayro-interpret patches 104, 105) (#169) Co-authored-by: Peter Yatsyshin --- crates/render/src/raster.rs | 11 ++++ crates/render/src/raster/tests.rs | 35 +++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 56 ++++++++++++++++++--- 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index cd88021951..501fb4cb2f 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -68,6 +68,15 @@ pub struct RenderConfig { pub layers: Arc>, /// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are). pub hide_comments: bool, + /// Annotations shown or hidden for viewing in place of their Hidden and NoView flags (object + /// number, generation, shown): fields a document's page actions and timers showed or hid + /// (an animation's frames). Display only; the document never changes. + pub annotations: Arc>, + /// Annotations drawn with a form XObject as their normal appearance (object number and + /// generation of the annotation, then of the form): push buttons a document's page actions + /// and timers gave another icon (`buttonSetIcon`, an animation's frames). Display only; the + /// document never changes. + pub appearances: Arc>, /// Refuse a whole-page raster that would be reduced by the renderer's hard size caps. /// Tiled requests remain available for pages that exceed those caps. Both modes refuse /// a raster whose rounded integer dimensions exceed the final pixel-allocation budget. @@ -118,6 +127,8 @@ impl RenderConfig { ocg_overrides: self.layers.clone(), view_auto_state: !self.for_print, hide_comments: self.hide_comments, + annotation_overrides: self.annotations.clone(), + appearance_overrides: self.appearances.clone(), font_resolver: Arc::new(move |query| { japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query)) }), diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 86d655677b..a2b0b5cd6f 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -2741,6 +2741,41 @@ trailer << /Root 1 0 R >> assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay"); } +/// The viewer's overrides (patches 104 and 105) show a hidden widget, hide a shown one and draw +/// one with another form, by object; an entry of `/Annots` that isn't a dictionary is skipped. +#[test] +fn annotation_and_appearance_overrides_change_only_the_view() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Annots [42 5 0 R 6 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (a) /F 2 /Rect [0 0 50 50] /AP << /N 7 0 R >> >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (b) /Rect [50 0 100 50] /AP << /N 8 0 R >> >> endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 24 >> stream +1 0 0 rg 0 0 50 50 re f +endstream endobj +8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 24 >> stream +0 0 1 rg 0 0 50 50 re f +endstream endobj +9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 24 >> stream +0 1 0 rg 0 0 10 10 re f +endstream endobj +trailer << /Root 1 0 R >> +%%EOF"; + let render = |config: RenderConfig| { + let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), config); + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }) + }; + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + let (white, red, blue, green) = (vec![255, 255, 255, 255], vec![255, 0, 0, 255], vec![0, 0, 255, 255], vec![0, 255, 0, 255]); + let plain = render(RenderConfig::default()); + assert_eq!((px(&plain, 25, 25), px(&plain, 75, 25)), (white.clone(), blue), "the hidden widget isn't drawn"); + let flipped = render(RenderConfig { annotations: Arc::new(vec![(5, 0, true), (6, 0, false)]), ..RenderConfig::default() }); + assert_eq!((px(&flipped, 25, 25), px(&flipped, 75, 25)), (red, white.clone()), "shown and hidden by object"); + let face = render(RenderConfig { appearances: Arc::new(vec![(6, 0, 9, 0), (5, 0, 99, 0)]), ..RenderConfig::default() }); + assert_eq!((px(&face, 25, 25), px(&face, 75, 25)), (white, green), "drawn with the other form, scaled to its own rectangle"); +} + /// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and /// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted. #[test] diff --git a/vendor/README.md b/vendor/README.md index 558dbbbc12..e55aca4d70 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -55,7 +55,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; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders; (104) `InterpreterSettings::annotation_overrides`: the viewer shows or hides an annotation by object in place of its Hidden and NoView flags (scripted animation frames), and an `/Annots` entry that isn't a dictionary is skipped instead of ending the list; (105) `InterpreterSettings::appearance_overrides`: the viewer draws an annotation with another form XObject as its normal appearance, in its own `/Rect` (`buttonSetIcon` from page actions and timers) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render); (104, 105) `raster::tests::annotation_and_appearance_overrides_change_only_the_view` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most as many symbol pixels per symbol dictionary as the largest page bitmap holds, 2^28 (a fuzzed dictionary decoded for minutes; each symbol now pays for its pixels out of the stream's decoding work and is held in the retained bitmaps, and at the earlier 2^26 the soft mask of a scanned drawing, one dictionary of 7,559 symbols, was refused: #6722), and a refusal by one of the decoder's limits is told apart from damaged data (`DecodeError::is_limit`, `OverflowError::BitmapLimit`); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding (about a second at the earlier 2^26 bound; not run at 2^28), `raster::tests::a_symbol_dictionary_holds_as_many_pixels_as_a_page`, and for `is_limit` and `BitmapLimit` `error::pdfcraft_limit_tests`, `bitmap::pdfcraft_limit_tests` and `gray_scale::pdfcraft_limit_tests` (hayro-jbig2); (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365); (7) object parsing builds at most 4,194,304 objects (every array element and dictionary value at any depth, `parser::MAX_OBJECT_NODES`, the same cap as `pdfcraft_cos`) per top-level object, an object stream reads at most 1,000,000 index entries (cos's `/N` clamp), and one load builds at most 8,388,608 objects from all of its object streams together, shared across rayon workers (a decoded stream of `0 0 0 ...` inside the 256 MiB decompression limit built ~120M objects, 10+ GB, and aborted). A lookup parses only the member it wants, so it isn't skipped behind a large member, and a load parses only the members it would add: none its cross-reference table places in another stream (stale copies) and none already loaded as an ordinary object or from an earlier stream. Members the table doesn't list at all are still parsed (a rebuilt table lists no compressed objects) and share the budget, so they can spend it before listed members of the same stream; a load expands its object streams one at a time in object-number order, a stream's members in parallel only when the budget holds all of them and otherwise in index order, so a spent budget skips the same members on every load (#5083) | (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`; (7) `inspect::tests::{object_stream_members_over_the_node_cap_are_skipped, object_stream_index_entries_past_the_clamp_are_ignored, object_streams_of_one_load_share_their_node_budget, object_streams_loaded_in_parallel_share_their_node_budget, a_malformed_object_stream_member_refunds_its_reservation, a_tight_budget_builds_object_stream_members_in_index_order, a_lookup_in_an_object_stream_parses_only_the_member_it_wants}` (pdfcraft-render; `NodeBudget` and `ObjectStream::new_within` are public so these can use a small budget) | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 8beee094c5..fe8f06cac8 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -117,6 +117,15 @@ pub struct InterpreterSettings { /// `ocg_overrides`. On by default: that is the state for viewing. Off for output made for /// printing, which the View event is not. pub view_auto_state: bool, + /// PdfCraft patch (104): viewer overrides for annotations (object number, generation, shown), + /// applied in place of their Hidden and NoView flags: what a document's scripts show and + /// hide while it is read (frames of an animation), without changing the document. + pub annotation_overrides: Arc>, + /// PdfCraft patch (105): annotations the viewer draws with a form XObject as their normal + /// appearance (object number and generation of the annotation, then of the form), in their + /// own `/Rect`: a push button a document's script gave another icon (`buttonSetIcon`) while + /// it is read, without changing the document. + pub appearance_overrides: Arc>, /// PdfCraft patch: once this is `true`, interpretation stops before the next content /// operator. What was drawn is then incomplete, so only set it when the output will be /// thrown away (a viewer's render whose result nobody can receive any more). @@ -151,6 +160,8 @@ impl Default for InterpreterSettings { hide_comments: false, ocg_overrides: Arc::new(Vec::new()), view_auto_state: true, + annotation_overrides: Arc::new(Vec::new()), + appearance_overrides: Arc::new(Vec::new()), cancelled: None, preview: false, jbig2_work: hayro_syntax::MAX_PAGE_JBIG2_WORK, @@ -222,8 +233,17 @@ pub fn interpret_page<'a>( // annotations, each group in `/Annots` order, as other readers draw them: a filled // comment listed after a check box covered the box. ISO 32000-2 doesn't order the two. // Two passes over the array, so nothing is held for every entry of a long one. + // PdfCraft patch (104): each annotation with its object, so the viewer can show or hide + // it by its object (`annotation_overrides`). An entry that isn't a dictionary is + // skipped; it no longer ends the list. + let xref = context.xref; for widgets in [false, true] { - for annot in annot_arr.iter::>() { + let annots = annot_arr.raw_iter().filter_map(|item| match item { + hayro_syntax::object::MaybeRef::Ref(r) => xref.get::>(r.into()).map(|d| (d, Some(r))), + hayro_syntax::object::MaybeRef::NotRef(Object::Dict(d)) => Some((d, None)), + hayro_syntax::object::MaybeRef::NotRef(_) => None, + }); + for (annot, obj) in annots { if (annot.get::>(SUBTYPE).as_deref() == Some(b"Widget")) != widgets { continue; } @@ -231,7 +251,16 @@ pub fn interpret_page<'a>( // Annotation should be hidden (Hidden = bit 2, NoView = bit 6). // PdfCraft patch: NoView was not honoured upstream. - if flags & (2 | 32) != 0 { + // PdfCraft patch (104): the viewer's override, when it has one, decides instead. + let overridden = obj.and_then(|r| { + context + .settings + .annotation_overrides + .iter() + .find(|(num, generation, _)| *num == r.obj_number && *generation == r.gen_number) + .map(|(_, _, shown)| !*shown) + }); + if overridden.unwrap_or(flags & (2 | 32) != 0) { continue; } // PdfCraft patch (56): Invisible (bit 1) hides an annotation of a type that isn't one @@ -265,11 +294,24 @@ pub fn interpret_page<'a>( // PdfCraft patch: `/N` may be a stream or a dictionary of appearance states selected // by `/AS` (checkboxes, radio buttons, …). Upstream only handled the stream form. - let normal = annot.get::>(AP).and_then(|ap| { - ap.get::>(N).or_else(|| { - let states = ap.get::>(N)?; - let state = annot.get::>(AS)?; - states.get::>(state.as_ref()) + // PdfCraft patch (105): the viewer may draw it with another form instead + // (`appearance_overrides`). + let face = obj + .and_then(|r| { + context + .settings + .appearance_overrides + .iter() + .find(|(num, generation, _, _)| *num == r.obj_number && *generation == r.gen_number) + }) + .and_then(|&(_, _, num, generation)| xref.get::>(hayro_syntax::object::ObjectIdentifier::new(num, generation))); + let normal = face.or_else(|| { + annot.get::>(AP).and_then(|ap| { + ap.get::>(N).or_else(|| { + let states = ap.get::>(N)?; + let state = annot.get::>(AS)?; + states.get::>(state.as_ref()) + }) }) }); if let Some(apx) = normal.and_then(|o| FormXObject::new(&o)) { From 17ae6ec9cd147c4ef681ff7ab1f4c02bab54ea49 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:22:32 +0100 Subject: [PATCH 05/21] feat(engine): page actions and timers run in the document realm and change only the view (#169) show_page runs a page's close/open actions, run_timers fires due timers (at most 16 a call), next_timer and script_clock drive the viewer. Fields they show, hide or give another icon are drawn so on screen only (screen_config); values and resets are undoable edits. A timer's alerts and requests go to the console. getOCGs layer changes turn layers as the panel does. Co-authored-by: Peter Yatsyshin --- crates/engine/src/document.rs | 2 + crates/engine/src/js.rs | 16 +- crates/engine/src/layers.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/live_scripts.rs | 376 ++++++++++++++++++++++++ crates/engine/src/live_scripts_tests.rs | 221 ++++++++++++++ crates/engine/src/open.rs | 1 + 7 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/live_scripts.rs create mode 100644 crates/engine/src/live_scripts_tests.rs diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 602410922f..c98893ab9c 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -55,6 +55,8 @@ pub struct Document { pub(crate) js_runtime: js::Runtime, pub(crate) js_zoom: f64, pub(crate) js_page: usize, + /// How its page actions and timers show it while it is read (see [`Session::show_page`](crate::Session::show_page)). + pub(crate) live: crate::live_scripts::LiveView, /// Dynamic XFA forms: what laying the template out produced (pages and fields are /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). pub xfa: Option, diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index f7bc3cbd5b..fa63da67be 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -154,7 +154,7 @@ impl JsOutput { /// Adds what a field's own script produced: its alerts, console lines, errors and requests, /// except a request to print, show a side panel, open Save As, or open or save an attached /// file, which is noted on the console and dropped. - fn absorb(&mut self, o: &Outcome) { + pub(crate) fn absorb(&mut self, o: &Outcome) { self.alert(o.alerts.iter().cloned()); self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a @@ -560,6 +560,7 @@ impl JsRunner { runtime_highlight: highlight, icon_epoch: 0, icon_versions: Default::default(), + layers: Vec::new(), }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. // Keep them in the file and in Document JavaScripts, but do not initialize an @@ -589,7 +590,7 @@ impl JsRunner { } /// Index calculation sources by PDF object identity, retaining their matching snapshot positions. -fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> { +pub(crate) fn calculation_scripts(cos: &pdfcraft_cos::Document, fields: &[Field]) -> Vec<(usize, String)> { #[cfg(test)] SNAPSHOT_WORK.with(|c| { let (g, p) = c.get(); @@ -1014,8 +1015,9 @@ impl Session { let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight); runner.doc.zoom = doc.js_zoom; runner.doc.page = doc.js_page; + runner.doc.layers = crate::live_scripts::js_layers(doc); let fields = pdfcraft_forms::fields(&cos); - let states = std::sync::Arc::new(fields.iter().map(|f| field_state(&cos, f, &runner.doc)).collect::>()); + let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, &cos, f, &runner.doc)).collect::>()); let event = match target { Some(t) => pdfcraft_js::Event { will_commit: false, target_id, ..pdfcraft_js::Event::field("Mouse Up", t, "") }, None => pdfcraft_js::Event::doc(document_event.map_or("Console", DocumentScriptEvent::name)), @@ -1043,7 +1045,7 @@ impl Session { } /// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits. - fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result { + pub(crate) fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result { if o.execution_refused { o.changed.clear(); o.requests.clear(); @@ -1106,12 +1108,15 @@ impl Session { } } let changes = changes(&baseline, &o.changed, &o.formatted); + let written: Vec = changes.iter().filter(|c| c.display.is_some() || c.icon.is_some()).map(|c| c.name.clone()).collect(); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); } if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?; } + // The timers the script left, the layers it turned, and how fields show (#169). + self.after_script(id, &o, &written); Ok(o) } } @@ -1485,9 +1490,10 @@ impl Session { let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight); runner.doc.zoom = doc.js_zoom; runner.doc.page = doc.js_page; + runner.doc.layers = crate::live_scripts::js_layers(doc); let fields = pdfcraft_forms::fields(cos); validate_script_target(target, target_id, &fields)?; - let states = std::sync::Arc::new(fields.iter().map(|f| field_state(cos, f, &runner.doc)).collect::>()); + let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, cos, f, &runner.doc)).collect::>()); let job = pdfcraft_js::session::Job { script: script.into(), event: target.map_or_else( diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs index b2fab68acb..4443a52b3c 100644 --- a/crates/engine/src/layers.rs +++ b/crates/engine/src/layers.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use pdfcraft_render::{Layer, LayerOp, RenderPool}; -use crate::{Document, display_config, render_threads}; +use crate::{Document, render_threads}; /// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and /// most category names and groups it reads in all of them together (entries can share one @@ -132,7 +132,7 @@ pub(crate) fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); doc.config.layers = Arc::new(overrides); doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), crate::live_scripts::screen_config(doc)); } /// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 161b2c1d34..e737798d97 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,6 +36,7 @@ pub mod find_replace; mod history; pub mod js; mod layers; +mod live_scripts; pub mod links; pub mod model3d; mod object_clip; @@ -75,6 +76,7 @@ pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; pub use pdfcraft_edit::strip_subset_tag; +pub use live_scripts::{MAX_TIMER_RUNS, ScriptRun}; pub use rich_copy::RichCopy; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; diff --git a/crates/engine/src/live_scripts.rs b/crates/engine/src/live_scripts.rs new file mode 100644 index 0000000000..bbf89853ff --- /dev/null +++ b/crates/engine/src/live_scripts.rs @@ -0,0 +1,376 @@ +//! A document's page actions and timers while it is read (#169): page open/close actions run +//! as the reader moves between pages, `app.setInterval` / `app.setTimeOut` timers fire on the +//! viewer's clock, and what they show or hide (an animation's frames) changes only the view. +//! Everything runs in the document's script realm (#5699), shared with its buttons. + +use std::collections::BTreeMap; + +use pdfcraft_js::{Outcome, Request}; + +use crate::js::{JsOutput, Runtime, calculation_scripts, field_state}; +use crate::{DocId, Document, EditError, Session}; + +/// Most timers one call to [`Session::run_timers`] runs: a document asking for more at once +/// falls behind instead of holding up the viewer. +pub const MAX_TIMER_RUNS: usize = 16; + +/// A timer of a document's scripts, on the caller's clock. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Due { + id: u32, + period_ms: u32, + repeat: bool, + /// When it falls due, in milliseconds. + at: f64, +} + +/// How a document's page actions and timers show it while it is read. Nothing here is part of +/// the document: saving, printing and exporting never see it. +#[derive(Default)] +pub(crate) struct LiveView { + /// The page whose open actions ran last. + page: Option, + timers: Vec, + /// The realm the timers were set in: a fresh realm (an edit's reset, Revert, a full + /// save) starts without them. + timers_of: Option>>, + /// `display` page actions and timers gave fields, by name, over the document's own flags. + shown: BTreeMap, + /// Icons page actions and timers gave push buttons (`buttonSetIcon`), by name: the icon's + /// form object (`None`: no icon), over the button's own. + icons: BTreeMap>, + /// The caller's clock when it last looked, in milliseconds: a timer a button or the + /// console sets counts from here. + clock: f64, +} + +impl LiveView { + /// The timers, unless the realm that set them was replaced since. + fn timers(&mut self, runtime: &Runtime) -> &mut Vec { + if self.timers_of.as_ref().and_then(std::sync::Weak::upgrade).is_none_or(|r| !std::sync::Arc::ptr_eq(&r, runtime)) { + self.timers.clear(); + self.timers_of = None; + } + &mut self.timers + } + + /// Keep the timers realm `runtime` reported after a run: new ones count from the clock, + /// cleared ones go. + fn schedule(&mut self, runtime: &Runtime, reported: &[pdfcraft_js::Timer]) { + let clock = self.clock; + let timers = self.timers(runtime); + timers.retain(|d| reported.iter().any(|t| t.id == d.id)); + for t in reported { + if !timers.iter().any(|d| d.id == t.id) { + timers.push(Due { id: t.id, period_ms: t.period_ms, repeat: t.repeat, at: clock + f64::from(t.period_ms) }); + } + } + self.timers_of = (!self.timers.is_empty()).then(|| std::sync::Arc::downgrade(runtime)); + } +} + +/// What running a document's page actions or timers did, for the viewer. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ScriptRun { + /// Alerts, console output, requests and errors, as from any script. + pub output: JsOutput, + /// Pages showing a field the scripts showed, hid or gave another icon: draw them again. + pub pages: Vec, + /// The scripts changed the document (a field's value, or a reset): read it again. + pub edited: bool, + /// How many timers ran. + pub ran: usize, + /// Layers were turned on or off (`ocg.state`): draw every page again. + pub layers: bool, +} + +impl ScriptRun { + pub fn is_empty(&self) -> bool { + self.output.is_empty() && self.pages.is_empty() && !self.edited && !self.layers + } +} + +/// The document's layers as scripts see them (`getOCGs`), in the viewer's order. +pub(crate) fn js_layers(doc: &Document) -> Vec { + doc.info.layers.iter().map(|l| pdfcraft_js::Layer { name: l.name.clone(), on: l.visible, initial: l.initial }).collect() +} + +/// A field as scripts see it: as the document has it, but shown as page actions and timers +/// left it (its `display` and push button icon), so a button stepping an animation steps from +/// the frame on screen. +pub(crate) fn live_state(doc: &Document, cos: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, info: &pdfcraft_js::DocInfo) -> pdfcraft_js::FieldState { + let mut s = field_state(cos, f, info); + if let Some(d) = doc.live.shown.get(&f.name) { + s.display = *d; + } + if let Some(icon) = doc.live.icons.get(&f.name) { + s.icon = *icon; + } + s +} + +/// Whether a field with this `display` shows on screen. +fn on_screen(display: i32) -> bool { + display == pdfcraft_js::DISPLAY_VISIBLE || display == pdfcraft_js::DISPLAY_NO_PRINT +} + +/// A widget's object as the renderer names it, when its number fits. +fn object(w: &pdfcraft_forms::Widget) -> Option<(i32, i32)> { + Some((i32::try_from(w.obj.num).ok()?, i32::from(w.obj.generation))) +} + +/// The viewer's render configuration: the document's, with installed fonts, and the fields as +/// page actions and timers show them. Exports and printing use `doc.config`, without them. +pub(crate) fn screen_config(doc: &Document) -> pdfcraft_render::RenderConfig { + let live = &doc.live; + let annotations: Vec<(i32, i32, bool)> = doc + .form + .iter() + .filter_map(|f| live.shown.get(&f.name).map(|d| (f, on_screen(*d)))) + .flat_map(|(f, on)| f.widgets.iter().filter_map(move |w| object(w).map(|(n, g)| (n, g, on)))) + .collect(); + let appearances: Vec<(i32, i32, i32, i32)> = doc + .form + .iter() + .filter_map(|f| { + let id = live.icons.get(&f.name)?; + // No icon: drawn with an object that isn't a form, so nothing (an empty button). + let (num, generation) = id.map_or((0, 0), |id| ((id >> 16) as i32, (id & 0xffff) as i32)); + Some(f.widgets.iter().filter_map(move |w| object(w).map(|(n, g)| (n, g, num, generation)))) + }) + .flatten() + .collect(); + pdfcraft_render::RenderConfig { + annotations: std::sync::Arc::new(annotations), + appearances: std::sync::Arc::new(appearances), + ..crate::display_config(&doc.config) + } +} + +/// Draw the viewer with how fields show now. +fn use_live_view(doc: &mut Document) { + doc.renderer = pdfcraft_render::RenderPool::new(doc.display.clone(), crate::render_threads(), screen_config(doc)); +} + +/// Turn layers on or off as a script set them (`ocg.state`), as the Layers panel does: how the +/// document is shown, not the document. Returns whether any changed. +pub(crate) fn set_layers(doc: &mut Document, layers: &[(usize, bool)]) -> bool { + let mut changed = false; + for &(i, on) in layers { + if let Some(l) = doc.info.layers.get_mut(i) + && l.visible != on + { + l.visible = on; + changed = true; + } + } + if changed { + crate::layers::use_layer_choices(doc); + } + changed +} + +/// The `display` and icon changes in `o`, against the fields as they were (`states`), taken out +/// of it: shown in the view instead of written into the document. +fn take_view_changes(states: &[pdfcraft_js::FieldState], o: &mut Outcome) -> (Vec<(String, i32)>, Vec<(String, Option)>) { + let mut shown = Vec::new(); + let mut icons = Vec::new(); + for a in &mut o.changed { + let b = match a.object_id { + Some(id) => states.iter().find(|s| s.object_id == Some(id)), + None => states.iter().find(|s| s.name == a.name), + }; + let Some(b) = b else { continue }; + if a.display != b.display { + shown.push((a.name.clone(), a.display)); + a.display = b.display; + } + if a.icon_written && a.icon != b.icon { + icons.push((a.name.clone(), a.icon)); + } + a.icon = b.icon; + a.icon_written = false; + } + (shown, icons) +} + +impl Session { + /// After any script of document `id` ran (a button, the console, a page action, a timer): + /// keep the timers its realm has, turn layers as it asked, and let a `display` written + /// into the document win over how page actions and timers showed that field. + pub(crate) fn after_script(&mut self, id: DocId, o: &Outcome, written: &[String]) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return }; + let runtime = doc.js_runtime.clone(); + doc.live.schedule(&runtime, &o.timers); + let before = doc.live.shown.len(); + let icons = doc.live.icons.len(); + doc.live.shown.retain(|name, _| !written.contains(name)); + doc.live.icons.retain(|name, _| !written.contains(name)); + if doc.live.shown.len() != before || doc.live.icons.len() != icons { + use_live_view(doc); + } + set_layers(doc, &o.layers); + } + + /// Run `script` (or timer `timer`) of document `id` in its realm, as a page action or timer + /// runs: with the fields as the view shows them. Returns the fields as the script saw them. + fn run_live( + &mut self, + id: DocId, + script: &str, + event: pdfcraft_js::Event, + timer: Option, + ) -> Result<(std::sync::Arc>, Outcome), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let mut runner = crate::js::JsRunner::new(&cos, &doc.name, self.field_highlight); + runner.doc.zoom = doc.js_zoom; + runner.doc.page = doc.live.page.unwrap_or(doc.js_page); + runner.doc.layers = js_layers(doc); + let fields = pdfcraft_forms::fields(&cos); + let states: Vec<_> = fields.iter().map(|f| live_state(doc, &cos, f, &runner.doc)).collect(); + let states = std::sync::Arc::new(states); + let job = pdfcraft_js::session::Job { + script: script.into(), + event, + doc: runner.doc, + fields: states.clone(), + doc_scripts: runner.doc_scripts, + calculations: calculation_scripts(&cos, &fields), + timer, + }; + let o = doc.js_runtime.lock().map_err(|_| EditError::Invalid("document script runtime is unavailable".into()))?.run(job, &[]); + Ok((states, o)) + } + + /// Run one page action or timer of document `id` and apply what it did. Fields it showed, + /// hid or gave another icon change only how the document is shown; a value it changed, or + /// a reset, changes the document (one undoable step). A timer runs unasked and again and + /// again, so what it asks of the viewer (alerts, printing, navigation, links) goes to the + /// console instead. + fn live_step(&mut self, id: DocId, script: &str, event: pdfcraft_js::Event, timer: Option, run: &mut ScriptRun) { + let (states, mut o) = match self.run_live(id, script, event, timer) { + Ok(r) => r, + Err(e) => return run.output.errors.push(e.to_string()), + }; + let discard = o.execution_refused && !o.realm_untouched; + let (shown, icons) = take_view_changes(&states, &mut o); + if timer.is_some() { + for a in std::mem::take(&mut o.alerts) { + o.console.push(format!("A timer's alert: {}", a.text)); + } + for r in std::mem::take(&mut o.requests) { + if !matches!(r, Request::Reset(_)) { + o.console.push(format!("A timer asked for {r:?}; timers may only change fields")); + } else { + o.requests.push(r); + } + } + } + if !o.execution_refused + && let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) + && !(shown.is_empty() && icons.is_empty()) + { + for name in shown.iter().map(|(n, _)| n).chain(icons.iter().map(|(n, _)| n)) { + for p in doc.form.iter().filter(|f| f.name == *name).flat_map(|f| f.widgets.iter().filter_map(|w| w.page)) { + if !run.pages.contains(&p) { + run.pages.push(p); + } + } + } + doc.live.shown.extend(shown); + doc.live.icons.extend(icons); + use_live_view(doc); + } + let generation = self.get(id).map(Document::edit_generation); + let layers = |s: &Session| s.get(id).map(|d| d.info.layers.iter().map(|l| l.visible).collect::>()); + let layers_before = layers(self); + match self.apply_script_outcome(id, &states, o, false) { + Ok(o) => run.output.absorb(&o), + Err(e) => run.output.errors.push(e.to_string()), + } + run.edited |= self.get(id).map(Document::edit_generation) != generation; + run.layers |= layers(self) != layers_before; + if discard { + self.invalidate_javascript(id); + } + } + + /// Document `id` is read at page `page` (0-based), at `now_ms` on the caller's clock (on + /// which its timers fall due). When the page changed, the close actions of the page it was + /// at run, then `page`'s open actions (the page's own `/AA /O`, then each annotation's + /// page-open and page-visible actions, such as an animation's widget). Cheap when nothing + /// changed; call it as often as the page can change. With JavaScript off nothing runs. + pub fn show_page(&mut self, id: DocId, page: usize, now_ms: f64) -> ScriptRun { + let mut run = ScriptRun::default(); + let js_off = self.js_off; + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return run }; + doc.live.clock = now_ms; + if js_off || doc.live.page == Some(page) || doc.xfa_template.is_some() { + return run; + } + let before = doc.live.page.replace(page); + let Some(cos) = doc.editor.as_ref().map(|e| e.cos.clone()) else { return run }; + use pdfcraft_forms::PageEvent; + let mut scripts: Vec<(&str, pdfcraft_forms::PageScript)> = Vec::new(); + if let Some(p) = before { + scripts.extend(pdfcraft_forms::page_scripts(&cos, p, PageEvent::Close).into_iter().map(|s| ("Close", s))); + } + scripts.extend(pdfcraft_forms::page_scripts(&cos, page, PageEvent::Open).into_iter().map(|s| ("Open", s))); + for (name, s) in scripts { + let event = pdfcraft_js::Event { kind: "Page".into(), target: s.field, ..pdfcraft_js::Event::doc(name) }; + self.live_step(id, &s.script, event, None, &mut run); + } + run + } + + /// When document `id`'s next timer falls due, on the clock [`Session::show_page`] and + /// [`Session::run_timers`] are given; `None` without timers (or with JavaScript off). + pub fn next_timer(&mut self, id: DocId) -> Option { + if self.js_off { + return None; + } + let doc = self.docs.iter_mut().find(|d| d.id == id)?; + let runtime = doc.js_runtime.clone(); + doc.live.timers(&runtime).iter().map(|d| d.at).min_by(f64::total_cmp) + } + + /// Run document `id`'s timers that are due at `now_ms`, earliest first, at most + /// [`MAX_TIMER_RUNS`] of them. An interval that fell behind runs once and then counts its + /// period from now, rather than catching up run by run. + pub fn run_timers(&mut self, id: DocId, now_ms: f64) -> ScriptRun { + let mut run = ScriptRun::default(); + for _ in 0..MAX_TIMER_RUNS { + if self.js_off { + break; + } + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { break }; + doc.live.clock = now_ms; + let runtime = doc.js_runtime.clone(); + let timers = doc.live.timers(&runtime); + let Some(i) = timers.iter().enumerate().filter(|(_, d)| d.at <= now_ms).min_by(|a, b| a.1.at.total_cmp(&b.1.at)).map(|(i, _)| i) else { + break; + }; + let due = timers[i]; + if due.repeat { + let next = due.at + f64::from(due.period_ms); + timers[i].at = if next <= now_ms { now_ms + f64::from(due.period_ms) } else { next }; + } else { + timers.remove(i); + } + run.ran += 1; + let event = pdfcraft_js::Event { kind: "App".into(), ..pdfcraft_js::Event::doc("Timer") }; + self.live_step(id, "", event, Some(due.id), &mut run); + } + run + } + + /// The clock of document `id`'s live scripts: the time it was last given (milliseconds). + pub fn script_clock(&self, id: DocId) -> Option { + Some(self.get(id)?.live.clock) + } +} + +#[cfg(test)] +#[path = "live_scripts_tests.rs"] +mod tests; diff --git a/crates/engine/src/live_scripts_tests.rs b/crates/engine/src/live_scripts_tests.rs new file mode 100644 index 0000000000..c902b22562 --- /dev/null +++ b/crates/engine/src/live_scripts_tests.rs @@ -0,0 +1,221 @@ +use std::sync::Arc; + +use super::screen_config; +use crate::{DocId, Session}; + +/// A PDF of `objs` (object 1 the catalog), with its cross-reference table. +fn pdf(objs: &[String]) -> Vec { + 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 +} + +/// A form XObject filling 100 × 100 with colour `rgb`. +fn fill(rgb: &str) -> String { + let body = format!("{rgb} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{body}\nendstream", body.len()) +} + +/// A push button widget on page 3 0 R at [50 100 150 200]. +fn widget(name: &str, flags: u32, extra: &str) -> String { + format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T ({name}) /F {flags} /Rect [50 100 150 200] /P 3 0 R {extra} >>") +} + +/// Two pages; on the first, an animation as the LaTeX `animate` package builds one: three +/// frames as push buttons over one another (red showing, green and blue hidden), and a widget +/// whose page-open action defines the player and starts an interval and whose page-close +/// action stops it. +fn animation_pdf() -> Vec { + let open = "if (typeof on == 'undefined') { var on = 0; var t = null; } \ + function show(i) { getField('f' + on).display = display.hidden; on = i; getField('f' + on).display = display.visible; } \ + function step() { show((on + 1) % 3); } t = app.setInterval('step()', 100);"; + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R 12 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".into(), // 3 + widget("f0", 4, "/AP << /N 8 0 R >>"), // 4 + widget("f1", 6, "/AP << /N 9 0 R >>"), // 5 + widget("f2", 6, "/AP << /N 10 0 R >>"), // 6 + widget("anim", 4, "/AA << /PO << /S /JavaScript /JS 11 0 R >> /PC << /S /JavaScript /JS (app.clearInterval\\(t\\);) >> >>"), // 7 + fill("1 0 0"), // 8 + fill("0 1 0"), // 9 + fill("0 0 1"), // 10 + format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), // 11 + "<< /Type /Page /Parent 2 0 R >>".into(), // 12 + ]) +} + +const RED: [u8; 4] = [255, 0, 0, 255]; +const GREEN: [u8; 4] = [0, 255, 0, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// RGBA at PDF point (x, y) on `page` as the viewer draws it (1 px/pt, page height 300). +fn shown(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), screen_config(doc)); + let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let i = (((300 - y) * out.width + x) * 4) as usize; + out.rgba[i..i + 4].try_into().unwrap() +} + +/// RGBA at (x, y) on `page` as it prints or exports: the document itself. +fn printed(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); + let i = (((300 - y) * out.width + x) * 4) as usize; + out.rgba[i..i + 4].try_into().unwrap() +} + +#[test] +fn page_actions_start_timers_that_turn_an_animations_frames_without_changing_the_document() { + let mut s = Session::new(); + let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap(); + let bytes = s.get(id).unwrap().bytes.clone(); + assert_eq!(shown(&s, id, 0, 100, 150), RED); + assert_eq!(s.next_timer(id), None); + let run = s.show_page(id, 0, 1000.0); + assert!(run.output.errors.is_empty(), "{run:?}"); + assert_eq!(s.next_timer(id), Some(1100.0)); + assert!(s.show_page(id, 0, 1010.0).is_empty(), "the same page again runs nothing"); + assert!(s.run_timers(id, 1050.0).is_empty(), "not due yet"); + let run = s.run_timers(id, 1100.0); + assert_eq!((run.pages.as_slice(), run.edited, run.ran), ([0].as_slice(), false, 1), "{run:?}"); + assert_eq!(shown(&s, id, 0, 100, 150), GREEN); + assert_eq!(printed(&s, id, 0, 100, 150), RED, "printing and export draw the document"); + assert_eq!(s.next_timer(id), Some(1200.0)); + let doc = s.get(id).unwrap(); + assert!(Arc::ptr_eq(&doc.bytes, &bytes) && !doc.dirty && doc.undo_depth() == 0, "the frames turn in the view, not in the document"); + // An interval that fell behind runs once and counts on from now. + assert_eq!(s.run_timers(id, 1450.0).ran, 1); + assert_eq!(shown(&s, id, 0, 100, 150), BLUE); + assert_eq!(s.next_timer(id), Some(1550.0)); + // A button runs in the same realm: the page action's functions and state are there. + let o = s.run_javascript(id, "step();", Some("f0")).unwrap(); + assert_eq!(o.error, None); + let o = s.run_javascript(id, "on", None).unwrap(); + assert_eq!(o.result.as_deref(), Some("0")); + // Leaving the page runs its close action, which clears the interval. + s.show_page(id, 1, 1500.0); + assert_eq!(s.next_timer(id), None); + // With JavaScript off, page actions don't run. + s.set_javascript(false); + assert!(s.show_page(id, 0, 1600.0).is_empty()); + assert_eq!(s.next_timer(id), None); +} + +#[test] +fn a_button_that_shows_a_frame_changes_the_document_and_the_views_frame_gives_way() { + let mut s = Session::new(); + let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap(); + s.show_page(id, 0, 0.0); + s.run_timers(id, 100.0); + assert_eq!(shown(&s, id, 0, 100, 150), GREEN); + s.run_javascript(id, "step();", Some("f0")).unwrap(); + assert_eq!(shown(&s, id, 0, 100, 150), BLUE); + assert!(s.get(id).unwrap().dirty, "a button's change is the document's"); + assert_eq!(printed(&s, id, 0, 100, 150), BLUE); + s.run_timers(id, 200.0); + assert_eq!(shown(&s, id, 0, 100, 150), RED); +} + +#[test] +fn a_timer_runs_unasked_so_its_alerts_and_requests_go_to_the_console() { + let open = "app.setTimeOut('app.alert(\"hi\"); this.print(); app.launchURL(\"https://example.com\");', 50);"; + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(timer_pdf(open)), None).unwrap(); + assert!(s.show_page(id, 0, 0.0).output.errors.is_empty()); + let run = s.run_timers(id, 50.0); + assert_eq!(run.ran, 1); + assert!(run.output.alerts.is_empty() && run.output.requests.is_empty(), "{run:?}"); + assert!(run.output.console.iter().any(|l| l.contains("hi")), "{run:?}"); + assert_eq!(s.next_timer(id), None, "a time-out runs once"); +} + +#[test] +fn a_fresh_realm_has_no_timers_and_a_timer_writing_a_value_is_one_undoable_edit() { + let open = "app.setInterval('getField(\"f0\").value = \"x\";', 10);"; + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(timer_pdf(open)), None).unwrap(); + s.show_page(id, 0, 0.0); + let run = s.run_timers(id, 10.0); + assert!(run.edited && run.output.errors.is_empty(), "{run:?}"); + assert_eq!(s.get(id).unwrap().undo_depth(), 1); + assert!(s.next_timer(id).is_some()); + s.invalidate_javascript(id); + assert_eq!(s.next_timer(id), None, "the timers went with the realm"); +} + +/// One page with a text field f0 whose page-open action is `open`. +fn timer_pdf(open: &str) -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /AA << /O 5 0 R >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f0) /Rect [50 100 150 200] /P 3 0 R >>".into(), + "<< /S /JavaScript /JS 6 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), + ]) +} + +/// An animation as `animate` builds one by default (method=icon): hidden frames are buttons +/// holding the icons, and the player gives the one showing button a frame's icon. +#[test] +fn a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place() { + let open = "if (typeof on == 'undefined') { var on = 0; var fr = [getField('f0').buttonGetIcon(), getField('f1').buttonGetIcon()]; } \ + app.setInterval('on = 1 - on; getField(\"anim\").buttonSetIcon(fr[on]);', 100);"; + let out = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>".into(), + widget("anim", 4, "/AP << /N 7 0 R >> /MK << /I 7 0 R >> /AA << /PO << /S /JavaScript /JS 9 0 R >> >>"), + widget("f0", 6, "/AP << /N 7 0 R >> /MK << /I 7 0 R >>"), + widget("f1", 6, "/AP << /N 8 0 R >> /MK << /I 8 0 R >>"), + fill("1 0 0"), + fill("0 1 0"), + format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), + ]); + let mut s = Session::new(); + let id = s.open("icons.pdf", None, Arc::new(out), None).unwrap(); + assert_eq!(shown(&s, id, 0, 100, 150), RED); + assert!(s.show_page(id, 0, 0.0).output.errors.is_empty()); + let run = s.run_timers(id, 100.0); + assert_eq!((run.pages.as_slice(), run.output.errors.len()), ([0].as_slice(), 0), "{run:?}"); + assert_eq!(shown(&s, id, 0, 100, 150), GREEN); + assert_eq!(printed(&s, id, 0, 100, 150), RED); + s.run_timers(id, 200.0); + assert_eq!(shown(&s, id, 0, 100, 150), RED, "its own icon again"); + assert!(!s.get(id).unwrap().dirty); +} + +#[test] +fn get_ocgs_turns_a_layer_in_the_view() { + let open = "this.getOCGs()[0].state = false;"; + let out = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> /AA << /O << /S /JavaScript /JS 6 0 R >> >> >>".into(), + "<< /Length 41 >>\nstream\n/OC /L1 BDC 1 0 0 rg 0 0 200 300 re f EMC\nendstream".into(), + "<< /Type /OCG /Name (Red) >>".into(), + format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), + ]); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(out), None).unwrap(); + assert_eq!(shown(&s, id, 0, 100, 150), RED); + let run = s.show_page(id, 0, 0.0); + assert!(run.layers && run.output.errors.is_empty(), "{run:?}"); + assert!(!s.get(id).unwrap().info.layers[0].visible); + assert_eq!(shown(&s, id, 0, 100, 150), [255, 255, 255, 255]); + assert!(!s.get(id).unwrap().dirty); +} diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 8fd5cd992e..8897beca49 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -413,6 +413,7 @@ impl Session { js_runtime: Default::default(), js_zoom: 100.0, js_page: 0, + live: Default::default(), xfa, xfa_template: None, xfa_scripts_off: false, From d9ced6789694c7bb12f7a8c038c7c3492e46247c Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:22:32 +0100 Subject: [PATCH 06/21] feat(ui): run the reading page's actions and timers each frame; highlight skips push buttons (#169) Co-authored-by: Peter Yatsyshin --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/canvas/page_input.rs | 4 +- crates/ui-egui/src/workflows/js_ui.rs | 32 ++++++++++++++ crates/ui-egui/tests/it/view.rs | 55 +++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 3a1d678d83..eb918de147 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -722,6 +722,7 @@ impl PdfCraftApp { let out = self.session.take_js_output(id); self.handle_js(id, out); } + self.script_tick(ctx); if let Some(mut control) = self.control.take() { control.tick(ctx, self); self.control = Some(control); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index b39c16fde0..2e540b2eb2 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -1065,7 +1065,9 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf if !enabled { return; } - for field in form.iter() { + // A push button holds no value, so it is left as drawn (the frames and controls of a LaTeX + // `animate` animation are push buttons). + for field in form.iter().filter(|f| f.kind != pdfcraft_engine::FormFieldKind::PushButton) { let required = field.has(pdfcraft_engine::field_flags::REQUIRED); for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { let rect = widget.rect; diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 3547c511f2..58a62deb29 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -200,6 +200,38 @@ impl PdfCraftApp { true } + /// Each frame: the live scripts of the document being read (#169). Its page's open actions + /// run when the page changes (after the close actions of the page it was at), and its timers + /// when they fall due, such as an animation's frames (LaTeX `animate`). Only the active tab + /// runs: an animation in another tab waits until its tab shows again. + pub(crate) fn script_tick(&mut self, ctx: &egui::Context) { + let Some((i, id)) = self.active_ids() else { return }; + let now_ms = ctx.input(|input| input.time) * 1000.0; + let page = self.views[i].current; + for run in [self.session.show_page(id, page, now_ms), self.session.run_timers(id, now_ms)] { + if run.is_empty() { + continue; + } + if run.edited { + if let Some(info) = self.session.get(id).map(|d| d.info.clone()) { + self.views[i].document_changed(&info); + } + } else if run.layers { + self.views[i].invalidate_content(); + } else { + for p in &run.pages { + self.views[i].page_changed(*p); + } + } + self.handle_js(id, run.output); + ctx.request_repaint(); + } + if let Some(at) = self.session.next_timer(id) { + let wait = ((at - now_ms) / 1000.0).clamp(0.0, 3600.0); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } + /// Run a push button's JavaScript (its Mouse Up action). pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) { self.run_button_script_target(id, field, None, script); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index c56743d00b..e691ff117d 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1880,6 +1880,61 @@ fn highlight_fields_leaves_a_groups_read_only_button_alone() { assert_eq!(tinted, 1, "only the button that can be chosen is tinted"); } +/// A push button and a text field, with fields highlighted. +const BUTTON_AND_TEXT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (frame) /Rect [50 200 250 340] /P 3 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 130] /P 3 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A push button holds no value, so Highlight fields leaves it as drawn (an `animate` +/// animation's frames are push buttons); the text field beside it is tinted. +#[test] +fn highlight_fields_leaves_push_buttons_alone() { + 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("button.pdf", None, BUTTON_AND_TEXT.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("fields", "on").unwrap(); + app + }); + h.run_steps(6); + let tint = egui::Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let tinted = h.output().shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == tint)).count(); + assert_eq!(tinted, 1, "only the text field is tinted"); +} + +/// A page whose open action starts an interval writing a counter into the console. +const PAGE_TIMER: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (var n = 0; app.setInterval\\('n++', 10\\);) >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The viewer runs the open action of the page being read, and its timers as they fall due. +#[test] +fn the_viewer_runs_the_pages_open_action_and_its_timers() { + 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("timer.pdf", None, PAGE_TIMER.to_vec()).expect("opens"); + app + }); + h.run_steps(2); + let id = h.state().views[0].id; + assert!(h.state_mut().session.next_timer(id).is_some(), "the page's open action set the interval"); + std::thread::sleep(std::time::Duration::from_millis(30)); + h.run_steps(4); + let n = h.state_mut().session.run_javascript(id, "n", None).expect("runs").result; + assert!(n.as_deref().and_then(|n| n.parse::().ok()).is_some_and(|n| n >= 1), "the interval ran: {n:?}"); +} + /// A radio group's read-only button can't be chosen by a click, while the group's other button /// can. #[test] From 96bcdfec1a8d1f8e6e2479f0b6baa749b1914ae9 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:24:39 +0100 Subject: [PATCH 07/21] feat(automation): js_page and js_advance; parity and docs for page actions and timers (#169) Co-authored-by: Peter Yatsyshin --- crates/automation/src/a11y/form_tools.rs | 66 +++++++++++++++++++ crates/automation/src/dispatch.rs | 2 + crates/automation/src/tools.rs | 4 ++ crates/automation/tests/it/main.rs | 1 + crates/automation/tests/it/page_actions.rs | 77 ++++++++++++++++++++++ crates/engine/src/js.rs | 3 +- crates/engine/src/live_scripts.rs | 7 +- crates/engine/src/session/view.rs | 6 +- docs/acroform-script-workflows.md | 30 +++++++++ parity/acrobat-features.toml | 14 ++-- 10 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 crates/automation/tests/it/page_actions.rs diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 31234a28ed..30d92f884b 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -66,6 +66,59 @@ impl Automation { )) } + /// `js_page`: read the document at a page, running page actions as the viewer does (#169). + pub(crate) fn js_page(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let page = self.page(a)?; + let clock = self.session.script_clock(id).unwrap_or(0.0); + let run = self.session.show_page(id, page, clock); + // What the actions showed or hid is in the document's view settings. + self.renderers.remove(&id); + Ok(self.script_run_json(id, &run)) + } + + /// `js_advance`: move the script clock on, running the timers that fall due on the way, in + /// the order they do. + pub(crate) fn js_advance(&mut self, a: &Args) -> Result { + /// Most timer runs one call makes (a 10 ms interval for a minute). + const MAX_RUNS: usize = 6000; + let id = self.doc(a)?.id; + let ms = a.opt_num("ms")?.ok_or_else(|| ToolError::InvalidArgs("ms is required".into()))?; + if !(0.0..=3_600_000.0).contains(&ms) { + return Err(ToolError::InvalidArgs("ms must be between 0 and 3600000".into())); + } + let start = self.session.script_clock(id).unwrap_or(0.0); + let end = start + ms; + let mut total = pdfcraft_engine::ScriptRun::default(); + while total.ran < MAX_RUNS { + let Some(at) = self.session.next_timer(id).filter(|at| *at <= end) else { break }; + let run = self.session.run_timers(id, at.max(start)); + if run.ran == 0 { + break; + } + merge_runs(&mut total, run); + } + let behind = self.session.next_timer(id).is_some_and(|at| at <= end); + // The clock ends where it was sent; a timer the run cap left behind counts on from there. + let last = self.session.run_timers(id, end); + merge_runs(&mut total, last); + self.renderers.remove(&id); + let mut out = self.script_run_json(id, &total); + out["ran"] = json!(total.ran); + out["stopped_early"] = json!(behind); + Ok(out) + } + + fn script_run_json(&mut self, id: pdfcraft_engine::DocId, run: &pdfcraft_engine::ScriptRun) -> Value { + let reqs: Vec = run.output.requests.iter().map(request_json).collect(); + let alerts: Vec = run.output.alerts.iter().map(alert_json).collect(); + let pages: Vec = run.pages.iter().map(|p| p + 1).collect(); + let clock = self.session.script_clock(id).unwrap_or(0.0); + let next = self.session.next_timer(id).map(|at| (at - clock).max(0.0)); + json!({ "alerts": alerts, "console": run.output.console, "requests": reqs, "errors": run.output.errors, + "pages": pages, "edited": run.edited, "layers": run.layers, "clock_ms": clock, "next_timer_in_ms": next }) + } + pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); Ok(json!({ "scripts": list })) @@ -236,3 +289,16 @@ impl Automation { Ok(json!({ "enabled": self.session.javascript() })) } } + +/// Adds what `run` did to `total`. +fn merge_runs(total: &mut pdfcraft_engine::ScriptRun, run: pdfcraft_engine::ScriptRun) { + total.ran += run.ran; + total.edited |= run.edited; + total.layers |= run.layers; + total.output.append(run.output); + for p in run.pages { + if !total.pages.contains(&p) { + total.pages.push(p); + } + } +} diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 9644684772..8947d6a034 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -835,6 +835,8 @@ impl Automation { "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, "ocr_recognize" => self.ocr_recognize(&a)?, "js_run" => self.js_run(&a)?, + "js_page" => self.js_page(&a)?, + "js_advance" => self.js_advance(&a)?, "js_document_scripts" => self.js_document_scripts(&a)?, "js_set_document_script" => self.js_set_document_script(&a)?, "js_enabled" => self.js_enabled(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 83362aa019..152e10398b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1421,6 +1421,10 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." }, "field_id": {"type":"integer","minimum":0,"maximum":281474976710655u64,"description":"Optional exact field identity from form_fields; requires matching field name and is scoped to this document."}, "answers": {"type":"array","maxItems":32,"items":{"type":["string","null"],"maxLength":4096},"description":"Ordered popup, response and alert answers; strings are limited to 4096 UTF-8 bytes at runtime; null cancels a popup or response, and selects an alert default (OK, Cancel or No, by button set)."} }), &["doc", "script"], )), + t("js_page", "Run page actions", "Read the document at `page`, as the viewer does when that page becomes the one being read: the actions the page it was at runs as it closes, then this page's open actions (its own, and its annotations' page-open and page-visible actions, which start an animation made with the LaTeX animate package), in the document's JavaScript context, which buttons, the console and timers share. Fields the actions show or hide change only how the document is shown, and page_render draws them so; values they change are applied as one undoable step. Returns the actions' alerts, console output, requests and errors, the pages showing a field they showed or hid, and when the next timer falls due on the script clock (js_advance moves it).") + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), + t("js_advance", "Advance the script clock", "Move the document's script clock on by `ms` milliseconds and run the timers its scripts set (app.setInterval, app.setTimeOut) as they fall due on the way, in order, as the viewer runs them over time: an animation shows its next frames. Fields timers show or hide change only how the document is shown, and page_render draws them so. A timer's alerts and requests go to its console output instead. Returns how many timers ran, their console output and errors, the pages showing a field they showed or hid, the clock, and when the next timer falls due.") + .with(schema(json!({ "doc": doc(), "ms": { "type": "number", "minimum": 0, "maximum": 3600000 } }), &["doc", "ms"])), t("js_document_scripts", "Document JavaScripts", "List the document-level JavaScripts (name and source), which define functions field scripts use.") .ro() .cmd("tools.document_js") diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 7a5a6c1bae..42fd5459a3 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -15,6 +15,7 @@ mod hostile_args; mod list_selection; mod model_3d; mod model_render; +mod page_actions; mod optimize_content_boundaries; mod portfolio_info; mod redact_after_stray_q_form; diff --git a/crates/automation/tests/it/page_actions.rs b/crates/automation/tests/it/page_actions.rs new file mode 100644 index 0000000000..7cdc62b8d9 --- /dev/null +++ b/crates/automation/tests/it/page_actions.rs @@ -0,0 +1,77 @@ +//! Page actions and timers through the tool table (#169): `js_page` reads a page as the viewer +//! does, `js_advance` moves the script clock on, and `page_render` draws what they show. + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// Two pages; on the first, two frames over one another (red showing, green hidden) and a +/// page-open action flipping them every 100 ms, with a page-close action stopping it. +fn animation_pdf() -> Vec { + let fill = |rgb: &str| { + let body = format!("{rgb} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{body}\nendstream", body.len()) + }; + let open = "var on = 0; function flip() { getField('f' + on).display = display.hidden; on = 1 - on; getField('f' + on).display = display.visible; } \ + var t = app.setInterval('flip()', 100);"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 10 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] /AA << /O << /S /JavaScript /JS 8 0 R >> /C << /S /JavaScript /JS 9 0 R >> >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (f0) /F 4 /Rect [0 0 200 300] /P 3 0 R /AP << /N 6 0 R >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (f1) /F 6 /Rect [0 0 200 300] /P 3 0 R /AP << /N 7 0 R >> >>".into(), + fill("1 0 0"), + fill("0 1 0"), + format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), + "<< /Length 20 >>\nstream\napp.clearInterval(t)\nendstream".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + 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 +} + +#[test] +fn js_page_and_js_advance_run_an_animation_that_page_render_draws() { + let dir = std::env::temp_dir().join(format!("pdfcraft-page-actions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("anim.pdf"), animation_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({ "path": "anim.pdf" }))["doc"].as_u64().unwrap(); + let centre = |a: &mut Automation| { + let (w, h, px) = a.render_pixels(id, 1, 1.0).unwrap(); + let i = ((h / 2 * w + w / 2) * 4) as usize; + px[i..i + 3].to_vec() + }; + assert_eq!(centre(&mut a), [255, 0, 0]); + let out = tool(&mut a, "js_page", json!({ "doc": id, "page": 1 })); + assert_eq!((out["errors"].clone(), out["next_timer_in_ms"].clone()), (json!([]), json!(100.0)), "{out}"); + let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 150 })); + assert_eq!((out["ran"].clone(), out["pages"].clone(), out["edited"].clone()), (json!(1), json!([1]), json!(false)), "{out}"); + assert_eq!(out["next_timer_in_ms"], json!(50.0)); + assert_eq!(centre(&mut a), [0, 255, 0], "page_render draws the frame the timer showed"); + let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 1000 })); + assert_eq!(out["ran"], json!(10), "{out}"); + assert_eq!(centre(&mut a), [0, 255, 0]); + assert!(a.call("js_advance", &json!({ "doc": id, "ms": 3_600_001 })).is_err()); + let out = tool(&mut a, "js_page", json!({ "doc": id, "page": 2 })); + assert_eq!(out["next_timer_in_ms"], Value::Null, "the page's close action cleared the interval: {out}"); + let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap(); + assert!(!doc.dirty && doc.undo_depth() == 0, "the frames turned in the view only"); +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index fa63da67be..fb431966fd 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -187,7 +187,8 @@ impl JsOutput { } } - pub(crate) fn append(&mut self, other: JsOutput) { + /// Adds what other scripts produced, telling a document script's error once. + pub fn append(&mut self, other: JsOutput) { self.alert(other.alerts); self.say(other.console); self.requests.extend(other.requests); diff --git a/crates/engine/src/live_scripts.rs b/crates/engine/src/live_scripts.rs index bbf89853ff..0b70771b5a 100644 --- a/crates/engine/src/live_scripts.rs +++ b/crates/engine/src/live_scripts.rs @@ -122,6 +122,11 @@ fn object(w: &pdfcraft_forms::Widget) -> Option<(i32, i32)> { /// The viewer's render configuration: the document's, with installed fonts, and the fields as /// page actions and timers show them. Exports and printing use `doc.config`, without them. pub(crate) fn screen_config(doc: &Document) -> pdfcraft_render::RenderConfig { + with_live_view(doc, crate::display_config(&doc.config)) +} + +/// `config` with the fields as page actions and timers show them. +pub(crate) fn with_live_view(doc: &Document, config: pdfcraft_render::RenderConfig) -> pdfcraft_render::RenderConfig { let live = &doc.live; let annotations: Vec<(i32, i32, bool)> = doc .form @@ -143,7 +148,7 @@ pub(crate) fn screen_config(doc: &Document) -> pdfcraft_render::RenderConfig { pdfcraft_render::RenderConfig { annotations: std::sync::Arc::new(annotations), appearances: std::sync::Arc::new(appearances), - ..crate::display_config(&doc.config) + ..config } } diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 7232f64631..a519fb7e76 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -336,9 +336,11 @@ impl Session { } /// How the document's pages are drawn for viewing: its password and view settings (layer - /// visibility, hidden comments, reading colours). `None` when no such document is open. + /// visibility, hidden comments, reading colours), and the fields as its page actions and + /// timers show them (#169). `None` when no such document is open. pub fn render_config(&self, id: DocId) -> Option { - Some(self.get(id)?.config.clone()) + let doc = self.get(id)?; + Some(crate::live_scripts::with_live_view(doc, doc.config.clone())) } /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment diff --git a/docs/acroform-script-workflows.md b/docs/acroform-script-workflows.md index 200a59966a..5d4059f43e 100644 --- a/docs/acroform-script-workflows.md +++ b/docs/acroform-script-workflows.md @@ -83,8 +83,38 @@ host path cancels unanswered popups and response dialogs; no web-interaction par This work does not add timer execution, PDF annotations/layers to the JS object model, or the remaining document action triggers. +## Page actions, timers and layers + +When the page being read changes (the active tab's current page), the close actions of the +page it was at run, then the new page's open actions: the page's own `/AA /O` and `/C`, and +each annotation's `/PO`, `/PV` (opening) and `/PC`, `/PI` (closing), in `/Annots` order. They +run in the document realm, so the functions and variables they define are there for buttons, +the console and timers. This is how LaTeX `animate` animations start and stop. + +`app.setInterval`, `app.setTimeOut`, `app.clearInterval` and `app.clearTimeOut` keep up to 64 +timers per realm, each at least 10 ms apart. The viewer fires those that are due each frame (at +most 16 per call; an interval that fell behind runs once and then counts from now). A timer +runs unasked, so its alerts and requests go to the console. A fresh realm (after an edit +resets it, Revert, or a full save) has no timers. + +Fields that page actions and timers show, hide or give another icon (`display`, +`buttonSetIcon`) change only how the document is shown, through view-only render overrides +(hayro-interpret patches 104 and 105). They aren't saved, printed or exported, and they don't +mark the document as changed. Buttons and the console see the fields as shown, and a `display` +or icon a button writes into the document takes over from the view's. A value or a reset from +a page action or timer is one undoable edit. + +`this.getOCGs()` lists the document's layers (`name`, `state`, `initState`); setting +`ocg.state` turns a layer as the Layers panel does. + ## Automation +`js_page` reads the document at a page and runs its page actions as the viewer does, and +`js_advance` moves the script clock on by `ms` (up to one hour) and runs the timers that fall +due on the way (at most 6,000 runs per call). Both return the scripts' output, the pages to +draw again, and when the next timer is due; `page_render` draws the fields as the scripts show +them. + The existing `js_run` accepts `answers`, an optional array of up to 32 strings or null values: ```json diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a5e052341a..f481fc1f11 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3295,11 +3295,11 @@ title = "Page open/close actions" area = "D" tier = "P2" milestone = "M6" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["js_page"] +evidence = ["crates/forms/src/tests.rs::page_scripts_are_the_pages_own_then_each_annotations_in_order", "crates/engine/src/live_scripts_tests.rs::page_actions_start_timers_that_turn_an_animations_frames_without_changing_the_document", "crates/automation/tests/it/page_actions.rs::js_page_and_js_advance_run_an_animation_that_page_render_draws"] +notes = "JavaScript page actions run in the document's script realm when the page being read changes (the active tab's current page): the page's own open and close actions (/AA /O, /C) and its annotations' page-open, page-visible, page-close and page-invisible actions (/PO, /PV, /PC, /PI), which start and stop LaTeX animate animations. Fields they show, hide or give another icon change only how the document is shown (not saved, printed or exported); values they change are one undoable edit. Not yet: non-JavaScript page actions, actions chained with /Next, and the page-visible/invisible distinction for pages scrolled into view without becoming current." [[feature]] id = "organize.page-templates" @@ -5362,9 +5362,9 @@ tier = "P0" milestone = "M6" status = "partial" commands = ["tools.js_console"] -tools = ["js_run"] -evidence = ["crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] -notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access and remaining document action triggers are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope." +tools = ["js_run", "js_advance"] +evidence = ["crates/automation/tests/it/timesheet_compat.rs::timesheet_keypad_geometry_icons_calculation_and_persistence", "crates/automation/tests/it/timesheet_compat.rs::popup_and_manual_response_resume_the_same_script_and_validate_choices", "crates/js/src/session.rs::document_realm_keeps_keypad_state_and_isolates_documents", "crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes", "crates/js/src/session.rs::timers_fire_in_the_document_realm_until_cleared", "crates/engine/src/live_scripts_tests.rs::a_timer_giving_a_button_another_icon_draws_that_icon_in_its_place", "crates/engine/src/live_scripts_tests.rs::get_ocgs_turns_a_layer_in_the_view"] +notes = "Document-scoped native Boa realms shared by button, console and field events; synchronous calculateNow in CO order; normal captions/icons; rect and appearance/tooltip writeback; effective page boxes; native explicit-button popup/response/alert continuation and viewer requests, including opening or saving attachments from form buttons. Headless js_run has deterministic ordered answers. Partial: web prompt UI, timers, annotation/layer APIs, non-normal button faces, widget-indexed Field access and remaining document action triggers are not implemented. See docs/acroform-script-workflows.md for lifecycle, limits and validation scope. app.setInterval/setTimeOut/clearInterval/clearTimeOut (at most 64 timers, 10 ms minimum) fire in the document realm on the viewer clock (js_advance headless); timer alerts and requests go to the console. this.getOCGs() with ocg.name/state/initState turns layers as the Layers panel does." [[feature]] id = "form.js-console" From 65ea96da4c72487928237c504809885bb2f30f48 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:37:07 +0100 Subject: [PATCH 08/21] style: fmt and a type alias for clippy Co-authored-by: Peter Yatsyshin --- crates/automation/tests/it/main.rs | 2 +- crates/engine/src/js.rs | 8 +++++++- crates/engine/src/lib.rs | 4 ++-- crates/engine/src/live_scripts.rs | 18 +++++++++++------- crates/engine/src/live_scripts_tests.rs | 3 ++- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 42fd5459a3..be655a10a2 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -15,8 +15,8 @@ mod hostile_args; mod list_selection; mod model_3d; mod model_render; -mod page_actions; mod optimize_content_boundaries; +mod page_actions; mod portfolio_info; mod redact_after_stray_q_form; mod redact_text_after_q; diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index fb431966fd..d0fb11529a 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -1046,7 +1046,13 @@ impl Session { } /// Convert the worker snapshot delta and reset requests into permission-checked atomic document edits. - pub(crate) fn apply_script_outcome(&mut self, id: DocId, states: &[pdfcraft_js::FieldState], mut o: Outcome, opening: bool) -> Result { + pub(crate) fn apply_script_outcome( + &mut self, + id: DocId, + states: &[pdfcraft_js::FieldState], + mut o: Outcome, + opening: bool, + ) -> Result { if o.execution_refused { o.changed.clear(); o.requests.clear(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e737798d97..4b9a3cd7ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -36,8 +36,8 @@ pub mod find_replace; mod history; pub mod js; mod layers; -mod live_scripts; pub mod links; +mod live_scripts; pub mod model3d; mod object_clip; pub mod ocr; @@ -75,8 +75,8 @@ use std::sync::Arc; pub use links::{ MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, }; -pub use pdfcraft_edit::strip_subset_tag; pub use live_scripts::{MAX_TIMER_RUNS, ScriptRun}; +pub use pdfcraft_edit::strip_subset_tag; pub use rich_copy::RichCopy; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; diff --git a/crates/engine/src/live_scripts.rs b/crates/engine/src/live_scripts.rs index 0b70771b5a..1657d9deaa 100644 --- a/crates/engine/src/live_scripts.rs +++ b/crates/engine/src/live_scripts.rs @@ -98,7 +98,12 @@ pub(crate) fn js_layers(doc: &Document) -> Vec { /// A field as scripts see it: as the document has it, but shown as page actions and timers /// left it (its `display` and push button icon), so a button stepping an animation steps from /// the frame on screen. -pub(crate) fn live_state(doc: &Document, cos: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, info: &pdfcraft_js::DocInfo) -> pdfcraft_js::FieldState { +pub(crate) fn live_state( + doc: &Document, + cos: &pdfcraft_cos::Document, + f: &pdfcraft_forms::Field, + info: &pdfcraft_js::DocInfo, +) -> pdfcraft_js::FieldState { let mut s = field_state(cos, f, info); if let Some(d) = doc.live.shown.get(&f.name) { s.display = *d; @@ -145,11 +150,7 @@ pub(crate) fn with_live_view(doc: &Document, config: pdfcraft_render::RenderConf }) .flatten() .collect(); - pdfcraft_render::RenderConfig { - annotations: std::sync::Arc::new(annotations), - appearances: std::sync::Arc::new(appearances), - ..config - } + pdfcraft_render::RenderConfig { annotations: std::sync::Arc::new(annotations), appearances: std::sync::Arc::new(appearances), ..config } } /// Draw the viewer with how fields show now. @@ -175,9 +176,12 @@ pub(crate) fn set_layers(doc: &mut Document, layers: &[(usize, bool)]) -> bool { changed } +/// Push buttons given another icon, by name: the icon's form object (`None`: no icon). +type IconChanges = Vec<(String, Option)>; + /// The `display` and icon changes in `o`, against the fields as they were (`states`), taken out /// of it: shown in the view instead of written into the document. -fn take_view_changes(states: &[pdfcraft_js::FieldState], o: &mut Outcome) -> (Vec<(String, i32)>, Vec<(String, Option)>) { +fn take_view_changes(states: &[pdfcraft_js::FieldState], o: &mut Outcome) -> (Vec<(String, i32)>, IconChanges) { let mut shown = Vec::new(); let mut icons = Vec::new(); for a in &mut o.changed { diff --git a/crates/engine/src/live_scripts_tests.rs b/crates/engine/src/live_scripts_tests.rs index c902b22562..2087e1dbb8 100644 --- a/crates/engine/src/live_scripts_tests.rs +++ b/crates/engine/src/live_scripts_tests.rs @@ -205,7 +205,8 @@ fn get_ocgs_turns_a_layer_in_the_view() { let out = pdf(&[ "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), - "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> /AA << /O << /S /JavaScript /JS 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> /AA << /O << /S /JavaScript /JS 6 0 R >> >> >>" + .into(), "<< /Length 41 >>\nstream\n/OC /L1 BDC 1 0 0 rg 0 0 200 300 re f EMC\nendstream".into(), "<< /Type /OCG /Name (Red) >>".into(), format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), From 6805790536c4e8c5d624fd34ec15312afd56c911 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:46:10 +0100 Subject: [PATCH 09/21] test(js): assert! for a bool (clippy) Co-authored-by: Peter Yatsyshin --- crates/js/src/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/js/src/session.rs b/crates/js/src/session.rs index c92b074adf..a97b84f370 100644 --- a/crates/js/src/session.rs +++ b/crates/js/src/session.rs @@ -653,7 +653,7 @@ mod tests { assert_eq!(first.changed.iter().map(|f| f.value.clone()).collect::>(), [vec!["frame 1".to_string()]]); let once = s.run(fire(2), &[]); assert_eq!(once.timers.len(), 1, "a time-out runs once"); - assert_eq!(s.run(fire(2), &[]).realm_untouched, true, "and is gone after"); + assert!(s.run(fire(2), &[]).realm_untouched, "and is gone after"); s.run(fire(1), &[]); assert_eq!(s.run(job("n;"), &[]).result.as_deref(), Some("102")); let cleared = s.run(job("app.clearInterval(t); app.clearInterval({}); n;"), &[]); From 0fba511e9778cdee725e7e78102cf2793022db6b Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:11:32 +0100 Subject: [PATCH 10/21] forms: a push button with only a mouse-down action takes the press Widget.mouse_down records an /AA /D action, so a push button acting only on mouse down (the LaTeX animate package's controls) is pressable and its press runs the action as Mouse Down through the widget trigger dispatch, instead of saying it has no action. Co-authored-by: Peter Yatsyshin --- crates/engine/src/js.rs | 1 + crates/forms/src/author/lifecycle.rs | 1 + crates/forms/src/lib.rs | 3 +++ crates/forms/src/read.rs | 1 + crates/ui-egui/src/canvas/sign_tab_tests.rs | 1 + crates/ui-egui/src/tools/forms_ui.rs | 8 +++++-- crates/ui-egui/tests/it/field_triggers.rs | 24 ++++++++++++++++++--- 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index a6fbea8abe..348052aff4 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -2025,6 +2025,7 @@ mod widget_budget_review_tests { rotation: 0, caption: None, mouse_up_script: None, + mouse_down: false, list: None, round: false, }; diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index 50d552ccdb..c079b40f13 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -173,6 +173,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel rotation: 0, caption: None, mouse_up_script: None, + mouse_down: false, list: None, round: true, }; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 96d6ea2a6e..0c71c4077f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -140,6 +140,9 @@ pub struct Widget { pub caption: Option, /// Text widget JavaScript to run on a mouse-up click. pub mouse_up_script: Option, + /// The widget has a mouse-down action (`/AA /D`): a press runs it (the LaTeX `animate` + /// package's controls act on mouse down), so a push button with only that still takes a press. + pub mouse_down: bool, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, /// Drawn round: a radio button, or a check box laid out for a round XFA check button diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 537237254a..49d7e91562 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -733,6 +733,7 @@ fn walk( } else { None }, + mouse_down: wd.get(b"AA").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_dict).is_some_and(|aa| aa.contains(b"D")), list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), }) diff --git a/crates/ui-egui/src/canvas/sign_tab_tests.rs b/crates/ui-egui/src/canvas/sign_tab_tests.rs index c2d5c20748..79789eddcf 100644 --- a/crates/ui-egui/src/canvas/sign_tab_tests.rs +++ b/crates/ui-egui/src/canvas/sign_tab_tests.rs @@ -58,6 +58,7 @@ fn field(name: &str, page: usize, rect: [f64; 4]) -> FormField { tooltip: None, widgets: vec![FormWidget { mouse_up_script: None, + mouse_down: false, obj: pdfcraft_cos::ObjRef::new(4, 0), page: Some(page), rect, diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 94aafda3f7..6a5abcecf2 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -432,11 +432,13 @@ pub(crate) fn tab_under<'a>(tabs: &[(&'a str, SignTab)], p: egui::Pos2) -> Optio tabs.iter().rev().find(|(_, tab)| tab.rect.contains(p)).map(|(name, _)| *name) } -/// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action, or an +/// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action (a click +/// or mouse-down one), or an /// empty signature field (signed by clicking it, Use a certificate). A radio button of a group can /// be read-only on its own. fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool { - let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); + // A button acting only on mouse down (`/AA /D`) takes the press too, which runs it (#4751). + let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && (f.button.is_some() || w.mouse_down); allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature) } @@ -551,6 +553,8 @@ pub(crate) fn page_input( _ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())), FormFieldKind::PushButton => match &f.button { Some(a) => view.forms.button = Some((f.name.clone(), pdfcraft_engine::js::field_object_id(f.obj), a.clone())), + // Its press already ran its mouse-down action. + None if w.mouse_down => {} None => view.forms.notice = Some(FormNotice::NoAction(f.name.clone())), }, FormFieldKind::Text if let Some(script) = &w.mouse_up_script => { diff --git a/crates/ui-egui/tests/it/field_triggers.rs b/crates/ui-egui/tests/it/field_triggers.rs index f648b7a445..d57397e72e 100644 --- a/crates/ui-egui/tests/it/field_triggers.rs +++ b/crates/ui-egui/tests/it/field_triggers.rs @@ -1,6 +1,7 @@ //! A form field widget's pointer scripts in the app (#269, ported from #344): the pointer //! moving onto and off a widget runs its Mouse Enter and Mouse Exit JavaScript, and a click on a -//! check box runs its Mouse Up JavaScript after the click stored the new value (#247). +//! check box runs its Mouse Up JavaScript after the click stored the new value (#247). A push +//! button with only a mouse-down action runs it when pressed (#4751). use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; @@ -9,15 +10,16 @@ use pdfcraft_ui_egui::PdfCraftApp; /// `strike` (a check box) shows or hides `note` from its Mouse Up script by its own new value. fn form() -> Vec { let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", - "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 8 0 R 10 0 R] >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (log) /V () /Rect [20 350 280 370] /P 3 0 R /F 4 >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hot) /V () /Rect [20 250 280 290] /P 3 0 R /F 4 /AA << /E << /S /JavaScript /JS (getField\\('log'\\).value += 'in;';) >> /X << /S /JavaScript /JS (getField\\('log'\\).value += 'out;';) >> >> >>", "<< /Type /Annot /Subtype /Widget /FT /Btn /T (strike) /V /Off /AS /Off /Rect [20 300 50 330] /P 3 0 R /F 4 /MK << /CA (4) >> /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> /A << /S /JavaScript /JS (getField\\('note'\\).display = event.target.value == 'Off' ? display.hidden : display.visible; getField\\('log'\\).value += 'up:' + event.target.value + ';';) >> >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 300 280 330] /P 3 0 R /F 6 >>", "<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [20 200 120 230] /P 3 0 R /F 4 /MK << /CA (Step) >> /AA << /D << /S /JavaScript /JS (getField\\('log'\\).value += event.name + ';';) >> >> >>", ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -102,3 +104,19 @@ fn a_check_box_mouse_up_script_sees_the_value_its_click_stored() { crate::support::wait_until(&mut h, "unchecked and its Mouse Up ran", |app| field(app, "log").value.join(",") == "up:Yes;up:Off;"); assert!(field(h.state(), "note").widgets[0].hidden, "hidden again"); } + +/// A push button with only a mouse-down action (`/AA /D`, as the LaTeX `animate` package's +/// controls have) takes the press and runs it as Mouse Down, once per click (#4751). +#[test] +fn a_push_button_with_only_a_mouse_down_action_runs_it_on_press() { + let mut h = harness(); + let step = centre(&h, "step"); + h.hover_at(step); + h.run_steps(1); + h.drag_at(step); + h.run_steps(1); + h.drop_at(step); + crate::support::wait_until(&mut h, "Mouse Down ran", |app| field(app, "log").value.join(",") == "Mouse Down;"); + h.run_steps(4); + assert_eq!(log(&h), "Mouse Down;", "the click runs nothing more"); +} From 80d7de6698d3f1fac1abda606428e771057ad6b7 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:25:18 +0100 Subject: [PATCH 11/21] test(ui): keep the mouse-down button inside the window Co-authored-by: Peter Yatsyshin --- crates/ui-egui/tests/it/field_triggers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/field_triggers.rs b/crates/ui-egui/tests/it/field_triggers.rs index d57397e72e..1144560561 100644 --- a/crates/ui-egui/tests/it/field_triggers.rs +++ b/crates/ui-egui/tests/it/field_triggers.rs @@ -19,7 +19,7 @@ fn form() -> Vec { "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", "<< /Type /Annot /Subtype /Widget /FT /Tx /T (note) /V (struck) /Rect [60 300 280 330] /P 3 0 R /F 6 >>", "<< /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 0 >>\nstream\n\nendstream", - "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [20 200 120 230] /P 3 0 R /F 4 /MK << /CA (Step) >> /AA << /D << /S /JavaScript /JS (getField\\('log'\\).value += event.name + ';';) >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [20 232 120 248] /P 3 0 R /F 4 /MK << /CA (Step) >> /AA << /D << /S /JavaScript /JS (getField\\('log'\\).value += event.name + ';';) >> >> >>", ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); From 223c1f02d48a342b72c50df5688e11782fe2a286 Mon Sep 17 00:00:00 2001 From: FredApps Date: Wed, 11 Nov 2026 15:53:02 +0100 Subject: [PATCH 12/21] fix(scripts): review fixes for page actions and timers - A page action or timer whose edits are refused shows nothing it did: view changes go in only after apply_script_outcome accepts them. - js_advance runs at most its 6,000-run cap; the last call only moves the clock (Session::run_timers_at_most). - Field callbacks see the document's layers (getOCGs), also after a batch rebuilds the runner. - The viewer's hit test uses what scripts show (shown_by_scripts): a frame they showed takes presses, one they hid doesn't. - Print previews draw without the live annotation and appearance overrides. - An appearance override naming no form (a cleared icon) draws nothing instead of the button's own face (hayro patch 105). - Page actions and timers wait while a dialog or prompt is open. - A timer editing the focused field refreshes its untouched draft. --- crates/automation/src/a11y/form_tools.rs | 7 +- crates/automation/tests/it/page_actions.rs | 25 +++++ crates/engine/src/js.rs | 5 +- crates/engine/src/live_scripts.rs | 70 +++++++++---- crates/engine/src/live_scripts_tests.rs | 69 ++++++++++++ crates/engine/src/session/editing.rs | 1 + crates/render/src/raster.rs | 8 +- crates/render/src/raster/tests.rs | 19 +++- crates/ui-egui/src/canvas/page_input.rs | 6 +- crates/ui-egui/src/tools/forms_ui.rs | 32 ++++-- crates/ui-egui/src/workflows/js_ui.rs | 17 +++ crates/ui-egui/tests/it/view.rs | 110 ++++++++++++++++++++ vendor/README.md | 2 +- vendor/hayro-interpret/src/interpret/mod.rs | 30 +++--- 14 files changed, 350 insertions(+), 51 deletions(-) diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index a849ec3e64..1277bf5b64 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -108,15 +108,16 @@ impl Automation { let mut total = pdfcraft_engine::ScriptRun::default(); while total.ran < MAX_RUNS { let Some(at) = self.session.next_timer(id).filter(|at| *at <= end) else { break }; - let run = self.session.run_timers(id, at.max(start)); + let run = self.session.run_timers_at_most(id, at.max(start), MAX_RUNS - total.ran); if run.ran == 0 { break; } merge_runs(&mut total, run); } let behind = self.session.next_timer(id).is_some_and(|at| at <= end); - // The clock ends where it was sent; a timer the run cap left behind counts on from there. - let last = self.session.run_timers(id, end); + // The clock ends where it was sent; a timer the run cap left behind counts on from there, + // and runs at the next call, not past the cap. + let last = self.session.run_timers_at_most(id, end, MAX_RUNS - total.ran); merge_runs(&mut total, last); self.renderers.remove(&id); let mut out = self.script_run_json(id, &total); diff --git a/crates/automation/tests/it/page_actions.rs b/crates/automation/tests/it/page_actions.rs index 7cdc62b8d9..e2ab46913b 100644 --- a/crates/automation/tests/it/page_actions.rs +++ b/crates/automation/tests/it/page_actions.rs @@ -75,3 +75,28 @@ fn js_page_and_js_advance_run_an_animation_that_page_render_draws() { let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap(); assert!(!doc.dirty && doc.undo_depth() == 0, "the frames turned in the view only"); } + +/// A page whose open action starts a 10 ms interval. +const INTERVAL: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (var n = 0; app.setInterval\\('n++', 10\\);) >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// `js_advance` runs at most 6,000 timers, even with one still due where the clock ends: that +/// one runs at the next call. +#[test] +fn js_advance_runs_no_more_timers_than_its_cap() { + let dir = std::env::temp_dir().join(format!("pdfcraft-js-advance-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("interval.pdf"), INTERVAL).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({ "path": "interval.pdf" }))["doc"].as_u64().unwrap(); + tool(&mut a, "js_page", json!({ "doc": id, "page": 1 })); + let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 60_010 })); + assert_eq!((out["ran"].clone(), out["stopped_early"].clone()), (json!(6000), json!(true)), "{out:?}"); + let out = tool(&mut a, "js_advance", json!({ "doc": id, "ms": 0 })); + assert_eq!(out["ran"], json!(1), "{out:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 348052aff4..39a53d3bb4 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -536,6 +536,7 @@ impl JsRunner { let mut current = Self::new(cos, &self.doc.file_name, self.doc.runtime_highlight); current.doc.zoom = self.doc.zoom; current.doc.page = self.doc.page; + current.doc.layers = std::mem::take(&mut self.doc.layers); self.doc = current.doc; self.doc_scripts = current.doc_scripts; self.calculations = None; @@ -1194,7 +1195,7 @@ impl Session { let mut runner = JsRunner::new(&cos, &doc.name, self.field_highlight); runner.doc.zoom = doc.js_zoom; runner.doc.page = doc.js_page; - runner.doc.layers = crate::live_scripts::js_layers(doc); + runner.doc.layers = crate::live_scripts::js_layers(&doc.info); let fields = pdfcraft_forms::fields(&cos); let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, &cos, f, &runner.doc)).collect::>()); let event = match target { @@ -1675,7 +1676,7 @@ impl Session { let mut runner = JsRunner::new(cos, &doc.name, self.field_highlight); runner.doc.zoom = doc.js_zoom; runner.doc.page = doc.js_page; - runner.doc.layers = crate::live_scripts::js_layers(doc); + runner.doc.layers = crate::live_scripts::js_layers(&doc.info); let fields = pdfcraft_forms::fields(cos); validate_script_target(target, target_id, &fields)?; let states = std::sync::Arc::new(fields.iter().map(|f| crate::live_scripts::live_state(doc, cos, f, &runner.doc)).collect::>()); diff --git a/crates/engine/src/live_scripts.rs b/crates/engine/src/live_scripts.rs index 1657d9deaa..b69f7d85d2 100644 --- a/crates/engine/src/live_scripts.rs +++ b/crates/engine/src/live_scripts.rs @@ -91,8 +91,8 @@ impl ScriptRun { } /// The document's layers as scripts see them (`getOCGs`), in the viewer's order. -pub(crate) fn js_layers(doc: &Document) -> Vec { - doc.info.layers.iter().map(|l| pdfcraft_js::Layer { name: l.name.clone(), on: l.visible, initial: l.initial }).collect() +pub(crate) fn js_layers(info: &pdfcraft_render::DocInfo) -> Vec { + info.layers.iter().map(|l| pdfcraft_js::Layer { name: l.name.clone(), on: l.visible, initial: l.initial }).collect() } /// A field as scripts see it: as the document has it, but shown as page actions and timers @@ -235,7 +235,7 @@ impl Session { let mut runner = crate::js::JsRunner::new(&cos, &doc.name, self.field_highlight); runner.doc.zoom = doc.js_zoom; runner.doc.page = doc.live.page.unwrap_or(doc.js_page); - runner.doc.layers = js_layers(doc); + runner.doc.layers = js_layers(&doc.info); let fields = pdfcraft_forms::fields(&cos); let states: Vec<_> = fields.iter().map(|f| live_state(doc, &cos, f, &runner.doc)).collect(); let states = std::sync::Arc::new(states); @@ -276,26 +276,18 @@ impl Session { } } } - if !o.execution_refused - && let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) - && !(shown.is_empty() && icons.is_empty()) - { - for name in shown.iter().map(|(n, _)| n).chain(icons.iter().map(|(n, _)| n)) { - for p in doc.form.iter().filter(|f| f.name == *name).flat_map(|f| f.widgets.iter().filter_map(|w| w.page)) { - if !run.pages.contains(&p) { - run.pages.push(p); - } - } - } - doc.live.shown.extend(shown); - doc.live.icons.extend(icons); - use_live_view(doc); - } + let refused = o.execution_refused; let generation = self.get(id).map(Document::edit_generation); let layers = |s: &Session| s.get(id).map(|d| d.info.layers.iter().map(|l| l.visible).collect::>()); let layers_before = layers(self); match self.apply_script_outcome(id, &states, o, false) { - Ok(o) => run.output.absorb(&o), + Ok(o) => { + run.output.absorb(&o); + // Only now: a script whose edits are refused shows nothing it did either. + if !refused { + self.show_view_changes(id, shown, icons, run); + } + } Err(e) => run.output.errors.push(e.to_string()), } run.edited |= self.get(id).map(Document::edit_generation) != generation; @@ -305,6 +297,25 @@ impl Session { } } + /// Show the fields a page action or timer of document `id` showed or hid (`shown`) or gave + /// another icon (`icons`), and add the pages they are on to `run`. + fn show_view_changes(&mut self, id: DocId, shown: Vec<(String, i32)>, icons: IconChanges, run: &mut ScriptRun) { + if shown.is_empty() && icons.is_empty() { + return; + } + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return }; + for name in shown.iter().map(|(n, _)| n).chain(icons.iter().map(|(n, _)| n)) { + for p in doc.form.iter().filter(|f| f.name == *name).flat_map(|f| f.widgets.iter().filter_map(|w| w.page)) { + if !run.pages.contains(&p) { + run.pages.push(p); + } + } + } + doc.live.shown.extend(shown); + doc.live.icons.extend(icons); + use_live_view(doc); + } + /// Document `id` is read at page `page` (0-based), at `now_ms` on the caller's clock (on /// which its timers fall due). When the page changed, the close actions of the page it was /// at run, then `page`'s open actions (the page's own `/AA /O`, then each annotation's @@ -348,8 +359,18 @@ impl Session { /// [`MAX_TIMER_RUNS`] of them. An interval that fell behind runs once and then counts its /// period from now, rather than catching up run by run. pub fn run_timers(&mut self, id: DocId, now_ms: f64) -> ScriptRun { + self.run_timers_at_most(id, now_ms, MAX_TIMER_RUNS) + } + + /// [`Session::run_timers`], running at most `max` timers (and never more than + /// [`MAX_TIMER_RUNS`]). With `max` 0 it only moves the clock on to `now_ms`: the timers still + /// due run at the next call. + pub fn run_timers_at_most(&mut self, id: DocId, now_ms: f64, max: usize) -> ScriptRun { let mut run = ScriptRun::default(); - for _ in 0..MAX_TIMER_RUNS { + if let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) { + doc.live.clock = now_ms; + } + for _ in 0..max.min(MAX_TIMER_RUNS) { if self.js_off { break; } @@ -380,6 +401,15 @@ impl Session { } } +impl Document { + /// Whether the document's page actions and timers show field `name` on screen (`Some`), over + /// its widgets' own Hidden and NoView flags; `None` when they left it as the document has it. + /// For the viewer's hit test: a frame they showed takes presses, and one they hid doesn't. + pub fn shown_by_scripts(&self, name: &str) -> Option { + self.live.shown.get(name).map(|d| on_screen(*d)) + } +} + #[cfg(test)] #[path = "live_scripts_tests.rs"] mod tests; diff --git a/crates/engine/src/live_scripts_tests.rs b/crates/engine/src/live_scripts_tests.rs index 2087e1dbb8..bee016b19b 100644 --- a/crates/engine/src/live_scripts_tests.rs +++ b/crates/engine/src/live_scripts_tests.rs @@ -220,3 +220,72 @@ fn get_ocgs_turns_a_layer_in_the_view() { assert_eq!(shown(&s, id, 0, 100, 150), [255, 255, 255, 255]); assert!(!s.get(id).unwrap().dirty); } + +/// A page action whose document edit is refused (here: past the reset limit) shows nothing it +/// did either. The frame it showed stays hidden. +#[test] +fn a_page_action_whose_edit_is_refused_shows_nothing() { + let open = "getField('f1').display = display.visible; for (var i = 0; i < 70; i++) this.resetForm();"; + let out = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] /AA << /O << /S /JavaScript /JS 8 0 R >> >> >>".into(), + widget("f0", 4, "/AP << /N 6 0 R >>"), + widget("f1", 6, "/AP << /N 7 0 R >>"), + fill("1 0 0"), + fill("0 1 0"), + format!("<< /Length {} >>\nstream\n{open}\nendstream", open.len()), + ]); + let mut s = Session::new(); + let id = s.open("refused.pdf", None, Arc::new(out), None).unwrap(); + let run = s.show_page(id, 0, 0.0); + assert!(!run.output.errors.is_empty(), "the edit was refused: {run:?}"); + assert!(run.pages.is_empty(), "{run:?}"); + assert_eq!(shown(&s, id, 0, 100, 150), RED, "the frame it showed stays hidden"); +} + +/// A field's calculation reads the document's layers (`getOCGs`), as page actions and buttons do. +#[test] +fn field_calculations_read_the_documents_layers() { + let calc = "var l = this.getOCGs(); event.value = l ? String(l.length) : 'none';"; + let out = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /CO [5 0 R] >> /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>" + .into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Rect [10 10 90 30] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (n) /Rect [10 40 90 60] /P 3 0 R /AA << /C << /S /JavaScript /JS 7 0 R >> >> >>".into(), + "<< /Type /OCG /Name (Red) >>".into(), + format!("<< /Length {} >>\nstream\n{calc}\nendstream", calc.len()), + ]); + let mut s = Session::new(); + let id = s.open("calc.pdf", None, Arc::new(out), None).unwrap(); + s.apply(id, crate::Edit::SetFieldValue { name: "a".into(), value: crate::FieldValue::Text("1".into()) }).unwrap(); + let n = s.get(id).unwrap().form.iter().find(|f| f.name == "n").unwrap().value.clone(); + assert_eq!(n, ["1"], "the calculation saw the document's one layer"); +} + +/// The viewer's hit test asks which fields the scripts show (`shown_by_scripts`): a frame they +/// showed, and one they hid. +#[test] +fn the_view_says_which_fields_the_scripts_show() { + let mut s = Session::new(); + let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap(); + assert_eq!(s.get(id).unwrap().shown_by_scripts("f1"), None); + s.show_page(id, 0, 0.0); + s.run_timers(id, 100.0); + let doc = s.get(id).unwrap(); + assert_eq!((doc.shown_by_scripts("f0"), doc.shown_by_scripts("f1"), doc.shown_by_scripts("anim")), (Some(false), Some(true), None)); +} + +/// With a cap of none, timers due only move the clock on; they run at the next call. +#[test] +fn a_cap_of_no_timer_runs_only_moves_the_clock() { + let mut s = Session::new(); + let id = s.open("animation.pdf", None, Arc::new(animation_pdf()), None).unwrap(); + s.show_page(id, 0, 0.0); + let run = s.run_timers_at_most(id, 100.0, 0); + assert_eq!(run.ran, 0); + assert_eq!((s.script_clock(id), s.next_timer(id)), (Some(100.0), Some(100.0))); + assert_eq!(s.run_timers_at_most(id, 100.0, 1).ran, 1); +} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 4a6f372fb4..2d805e92a9 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -181,6 +181,7 @@ impl Session { runner.runtime = doc.js_runtime.clone(); runner.doc.zoom = doc.js_zoom; runner.doc.page = doc.js_page; + runner.doc.layers = crate::live_scripts::js_layers(&doc.info); cx.js = Some(runner); } cx.renamed_fields.clear(); diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 501fb4cb2f..370010ee47 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -388,7 +388,8 @@ fn render_page<'a>( ) -> Result { // Print previews share the viewer pool, but must use the output font policy and the layers // as they print (`print_layers`). Keep the caller's cancellation/warning hooks and annotation - // settings. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the + // settings, except what a document's scripts show while it is read (`annotations`, + // `appearances`), which never prints. A fresh cache is essential: hayro keys cached fonts by PDF dictionary, not by the // font resolver, so the viewer cache could supply an installed face even after changing the // resolver (and a cached missing font from print preview could blank the viewer on switching // back); nothing drawn with the viewer's layers is reused either. @@ -396,10 +397,13 @@ fn render_page<'a>( let print_cache; let print = req.tag & PRINT_TAG != 0; let print_layers = config.print_layers.as_ref().filter(|_| print); - let (settings, cache) = if (config.system_fonts && print) || print_layers.is_some() { + let live = !settings.annotation_overrides.is_empty() || !settings.appearance_overrides.is_empty(); + let (settings, cache) = if print && (config.system_fonts || print_layers.is_some() || live) { print_settings = InterpreterSettings { font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver, ocg_overrides: print_layers.cloned().unwrap_or_else(|| settings.ocg_overrides.clone()), + annotation_overrides: Arc::default(), + appearance_overrides: Arc::default(), ..settings.clone() }; print_cache = RenderCache::new(); diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index a2b0b5cd6f..029a0608be 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -2773,7 +2773,24 @@ trailer << /Root 1 0 R >> let flipped = render(RenderConfig { annotations: Arc::new(vec![(5, 0, true), (6, 0, false)]), ..RenderConfig::default() }); assert_eq!((px(&flipped, 25, 25), px(&flipped, 75, 25)), (red, white.clone()), "shown and hidden by object"); let face = render(RenderConfig { appearances: Arc::new(vec![(6, 0, 9, 0), (5, 0, 99, 0)]), ..RenderConfig::default() }); - assert_eq!((px(&face, 25, 25), px(&face, 75, 25)), (white, green), "drawn with the other form, scaled to its own rectangle"); + assert_eq!((px(&face, 25, 25), px(&face, 75, 25)), (white.clone(), green), "drawn with the other form, scaled to its own rectangle"); + let cleared = render(RenderConfig { appearances: Arc::new(vec![(6, 0, 0, 0)]), ..RenderConfig::default() }); + assert_eq!(px(&cleared, 75, 25), white, "an override naming no form draws nothing (a cleared icon), not the button's own face"); + // A print preview draws the page as it prints, without what the scripts show. + let config = RenderConfig { + annotations: Arc::new(vec![(5, 0, true), (6, 0, false)]), + appearances: Arc::new(vec![(6, 0, 9, 0)]), + ..RenderConfig::default() + }; + let preview = PageRenderer::new(Arc::new(pdf.to_vec()), config).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: PRINT_TAG, + ..Default::default() + }); + assert_eq!((px(&preview, 25, 25), px(&preview, 75, 25)), (vec![255, 255, 255, 255], vec![0, 0, 255, 255]), "the print preview"); } /// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 20d4617917..a5d5893e8f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -101,6 +101,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut tool, pcx: &pcx, form, + doc, can_fill, can_modify, editing_content, @@ -486,6 +487,8 @@ struct PageHits<'a> { tool: QuickTool, pcx: &'a comments::PageCx<'a>, form: &'a [pdfcraft_engine::FormField], + /// Its document: what its scripts show while it is read (`Document::shown_by_scripts`). + doc: &'a pdfcraft_engine::Document, can_fill: bool, can_modify: bool, /// Edit a PDF is open: added text and pictures can be picked up. @@ -576,6 +579,7 @@ impl PageHits<'_> { tool, pcx, form, + doc, can_fill, can_modify, editing_content, @@ -611,7 +615,7 @@ impl PageHits<'_> { let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, tabs, can_fill, p) + crate::forms_ui::hit(xf, info, page, doc, tabs, can_fill, p) } else { None }; diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 6a5abcecf2..e3c0cf0233 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -421,9 +421,17 @@ fn toggle_edit(f: &FormField, wi: usize) -> Option { Some(Edit::SetFieldValue { name: f.name.clone(), value }) } -/// The first shown widget of `f` on `page` under the pointer at `p`, with its index. -fn widget_at<'a>(xf: &PageXform, info: &DocInfo, page: usize, f: &'a FormField, p: egui::Pos2) -> Option<(usize, &'a FormWidget)> { - f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) +/// The first shown widget of `f` on `page` under the pointer at `p`, with its index. `shown`: +/// whether the document's scripts show `f` while it is read, over its widgets' own flags. +fn widget_at<'a>( + xf: &PageXform, + info: &DocInfo, + page: usize, + f: &'a FormField, + shown: Option, + p: egui::Pos2, +) -> Option<(usize, &'a FormWidget)> { + f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && shown.unwrap_or(!w.hidden) && widget_rect(xf, info, page, w.rect).contains(p)) } /// The field whose sign tab is under `p`, of `tabs` in the order they are painted: where tabs @@ -446,16 +454,26 @@ fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool { /// press. It is the first widget under the pointer in the form's order, except on a sign tab /// (`tabs`, with the signature field each one signs), which is drawn over every field and so is /// on its own field. Over the page's text too: a check box drawn with a glyph, or set on printed -/// text, is a button, not text to select. -pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], tabs: &[(&str, SignTab)], allowed: bool, p: Pos2) -> Option { +/// text, is a button, not text to select. The fields are `doc`'s, shown as its page actions and +/// timers show them ([`pdfcraft_engine::Document::shown_by_scripts`]). +pub(crate) fn hit( + xf: &PageXform, + info: &DocInfo, + page: usize, + doc: &pdfcraft_engine::Document, + tabs: &[(&str, SignTab)], + allowed: bool, + p: Pos2, +) -> Option { if !xf.rect.contains(p) { return None; } let under = |only: Option<&str>| { - form.iter() + doc.form + .iter() .enumerate() .filter(|(_, f)| only.is_none_or(|name| f.name == name && f.kind == FormFieldKind::Signature)) - .find_map(|(fi, f)| widget_at(xf, info, page, f, p).map(|(wi, w)| (fi, f, wi, w))) + .find_map(|(fi, f)| widget_at(xf, info, page, f, doc.shown_by_scripts(&f.name), p).map(|(wi, w)| (fi, f, wi, w))) }; // A tab lies inside its field's widget, so that widget is under the pointer too. let (field, f, widget, w) = tab_under(tabs, p).and_then(|name| under(Some(name))).or_else(|| under(None))?; diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 303aa61a41..c70fc4cc62 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -205,9 +205,20 @@ impl PdfCraftApp { /// when they fall due, such as an animation's frames (LaTeX `animate`). Only the active tab /// runs: an animation in another tab waits until its tab shows again. pub(crate) fn script_tick(&mut self, ctx: &egui::Context) { + // Nothing acts on the document under a dialog or prompt (`modal_open`): page actions and + // timers wait, and an interval that fell behind runs once when it closes. + if self.modal_open() { + return; + } let Some((i, id)) = self.active_ids() else { return }; let now_ms = ctx.input(|input| input.time) * 1000.0; let page = self.views[i].current; + // The field being edited as it was, so a timer changing it shows in its editor while a + // draft the user already typed into is kept (as `run_field_triggers` does). + let before: Option<(String, Vec)> = + self.views[i].forms.focus.as_ref().and_then(|e| { + crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone())) + }); for run in [self.session.show_page(id, page, now_ms), self.session.run_timers(id, now_ms)] { if run.is_empty() { continue; @@ -216,6 +227,12 @@ impl PdfCraftApp { if let Some(info) = self.session.get(id).map(|d| d.info.clone()) { self.views[i].document_changed(&info); } + if let Some(before) = &before + && let Some(draft) = self.views[i].forms.focus.as_mut() + && let Some(f) = self.session.get(id).and_then(|d| crate::forms_ui::focused_field(draft, &d.form)) + { + crate::forms_ui::refresh_draft(draft, (&before.0, &before.1), (crate::forms_ui::draft_text(f), &f.value)); + } } else if run.layers { self.views[i].invalidate_content(); } else { diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index e691ff117d..e9fee18b00 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1935,6 +1935,116 @@ fn the_viewer_runs_the_pages_open_action_and_its_timers() { assert!(n.as_deref().and_then(|n| n.parse::().ok()).is_some_and(|n| n >= 1), "the interval ran: {n:?}"); } +/// Under a dialog nothing acts on the document: the page's open action waits until it closes. +#[test] +fn page_actions_and_timers_wait_under_a_dialog() { + 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("timer.pdf", None, PAGE_TIMER.to_vec()).expect("opens"); + app.dialog = Some(pdfcraft_ui_egui::Dialog::Shortcuts); + app + }); + h.run_steps(3); + let id = h.state().views[0].id; + assert!(h.state_mut().session.next_timer(id).is_none(), "the open action waits under the dialog"); + h.state_mut().dialog = None; + h.run_steps(2); + assert!(h.state_mut().session.next_timer(id).is_some(), "and runs once it closes"); +} + +/// A text field, with a default appearance for its editor. +const FIELD_TIMER: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (clock) /Rect [50 100 250 130] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Press field `name`'s first widget, wherever the document has it. +fn press_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let p = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center() + }; + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +/// A time-out writing the field being edited shows in its editor: leaving the field then keeps the +/// timer's value instead of writing the old one back. +#[test] +fn a_timer_writing_the_focused_field_shows_in_its_editor() { + 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("clock.pdf", None, FIELD_TIMER.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + press_field(&mut h, "clock"); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| (f.name.as_str(), f.text.as_str())), Some(("clock", ""))); + // Set only now, with the field being edited: the harness's clock runs ahead of the test's. + let id = h.state().views[0].id; + let set = h.state_mut().session.run_javascript(id, "app.setTimeOut('getField(\"clock\").value = \"tick\";', 50);", None).expect("runs"); + assert_eq!(set.error, None); + let value = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "clock").unwrap().value.clone() + }; + for _ in 0..600 { + if value(&h) == ["tick"] { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + h.run_steps(1); + } + assert_eq!(value(&h), ["tick"], "the time-out wrote the field"); + let focus = h.state().views[0].forms.focus.as_ref().map(|f| (f.name.clone(), f.text.clone())); + assert_eq!(focus, Some(("clock".into(), "tick".into())), "the editor shows the time-out's value"); +} + +/// Two push buttons: "shown" hidden in the document and "gone" not; the page's open action shows +/// the first and hides the second. Each one's mouse-up action says it was pressed. +const SHOWN_BY_SCRIPT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] /AA << /O << /S /JavaScript /JS (var pressed = ''; getField\\('shown'\\).display = display.visible; getField\\('gone'\\).display = display.hidden;) >> >> >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (shown) /F 6 /Rect [50 200 250 240] /P 3 0 R /AA << /U << /S /JavaScript /JS (pressed += 'shown';) >> >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (gone) /F 4 /Rect [50 100 250 140] /P 3 0 R /AA << /U << /S /JavaScript /JS (pressed += 'gone';) >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A button the page's open action showed takes presses, and one it hid takes none, whatever +/// their flags in the document say. +#[test] +fn buttons_take_presses_as_the_scripts_show_them() { + 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("frames.pdf", None, SHOWN_BY_SCRIPT.to_vec()).expect("opens"); + app + }); + h.run_steps(2); + press_field(&mut h, "shown"); + press_field(&mut h, "gone"); + let id = h.state().views[0].id; + let pressed = h.state_mut().session.run_javascript(id, "pressed", None).expect("runs").result; + assert_eq!(pressed.as_deref(), Some("shown")); +} + /// A radio group's read-only button can't be chosen by a click, while the group's other button /// can. #[test] diff --git a/vendor/README.md b/vendor/README.md index e55aca4d70..4cc90971c3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -55,7 +55,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; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders; (104) `InterpreterSettings::annotation_overrides`: the viewer shows or hides an annotation by object in place of its Hidden and NoView flags (scripted animation frames), and an `/Annots` entry that isn't a dictionary is skipped instead of ending the list; (105) `InterpreterSettings::appearance_overrides`: the viewer draws an annotation with another form XObject as its normal appearance, in its own `/Rect` (`buttonSetIcon` from page actions and timers) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render); (104, 105) `raster::tests::annotation_and_appearance_overrides_change_only_the_view` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders; (104) `InterpreterSettings::annotation_overrides`: the viewer shows or hides an annotation by object in place of its Hidden and NoView flags (scripted animation frames), and an `/Annots` entry that isn't a dictionary is skipped instead of ending the list; (105) `InterpreterSettings::appearance_overrides`: the viewer draws an annotation with another form XObject as its normal appearance, in its own `/Rect` (nothing, when the override names an object that isn't a form: a cleared icon) (`buttonSetIcon` from page actions and timers) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render); (104, 105) `raster::tests::annotation_and_appearance_overrides_change_only_the_view` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most as many symbol pixels per symbol dictionary as the largest page bitmap holds, 2^28 (a fuzzed dictionary decoded for minutes; each symbol now pays for its pixels out of the stream's decoding work and is held in the retained bitmaps, and at the earlier 2^26 the soft mask of a scanned drawing, one dictionary of 7,559 symbols, was refused: #6722), and a refusal by one of the decoder's limits is told apart from damaged data (`DecodeError::is_limit`, `OverflowError::BitmapLimit`); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding (about a second at the earlier 2^26 bound; not run at 2^28), `raster::tests::a_symbol_dictionary_holds_as_many_pixels_as_a_page`, and for `is_limit` and `BitmapLimit` `error::pdfcraft_limit_tests`, `bitmap::pdfcraft_limit_tests` and `gray_scale::pdfcraft_limit_tests` (hayro-jbig2); (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365); (7) object parsing builds at most 4,194,304 objects (every array element and dictionary value at any depth, `parser::MAX_OBJECT_NODES`, the same cap as `pdfcraft_cos`) per top-level object, an object stream reads at most 1,000,000 index entries (cos's `/N` clamp), and one load builds at most 8,388,608 objects from all of its object streams together, shared across rayon workers (a decoded stream of `0 0 0 ...` inside the 256 MiB decompression limit built ~120M objects, 10+ GB, and aborted). A lookup parses only the member it wants, so it isn't skipped behind a large member, and a load parses only the members it would add: none its cross-reference table places in another stream (stale copies) and none already loaded as an ordinary object or from an earlier stream. Members the table doesn't list at all are still parsed (a rebuilt table lists no compressed objects) and share the budget, so they can spend it before listed members of the same stream; a load expands its object streams one at a time in object-number order, a stream's members in parallel only when the budget holds all of them and otherwise in index order, so a spent budget skips the same members on every load (#5083) | (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`; (7) `inspect::tests::{object_stream_members_over_the_node_cap_are_skipped, object_stream_index_entries_past_the_clamp_are_ignored, object_streams_of_one_load_share_their_node_budget, object_streams_loaded_in_parallel_share_their_node_budget, a_malformed_object_stream_member_refunds_its_reservation, a_tight_budget_builds_object_stream_members_in_index_order, a_lookup_in_an_object_stream_parses_only_the_member_it_wants}` (pdfcraft-render; `NodeBudget` and `ObjectStream::new_within` are public so these can use a small budget) | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index fe8f06cac8..daabc6e3ce 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -124,7 +124,8 @@ pub struct InterpreterSettings { /// PdfCraft patch (105): annotations the viewer draws with a form XObject as their normal /// appearance (object number and generation of the annotation, then of the form), in their /// own `/Rect`: a push button a document's script gave another icon (`buttonSetIcon`) while - /// it is read, without changing the document. + /// it is read, without changing the document. An override naming an object that isn't a form + /// draws nothing (the script cleared the icon). pub appearance_overrides: Arc>, /// PdfCraft patch: once this is `true`, interpretation stops before the next content /// operator. What was drawn is then incomplete, so only set it when the output will be @@ -296,24 +297,25 @@ pub fn interpret_page<'a>( // by `/AS` (checkboxes, radio buttons, …). Upstream only handled the stream form. // PdfCraft patch (105): the viewer may draw it with another form instead // (`appearance_overrides`). - let face = obj - .and_then(|r| { - context - .settings - .appearance_overrides - .iter() - .find(|(num, generation, _, _)| *num == r.obj_number && *generation == r.gen_number) - }) - .and_then(|&(_, _, num, generation)| xref.get::>(hayro_syntax::object::ObjectIdentifier::new(num, generation))); - let normal = face.or_else(|| { - annot.get::>(AP).and_then(|ap| { + // An override naming an object that isn't a form draws nothing: a script cleared the + // button's icon. + let face = obj.and_then(|r| { + context + .settings + .appearance_overrides + .iter() + .find(|(num, generation, _, _)| *num == r.obj_number && *generation == r.gen_number) + }); + let normal = match face { + Some(&(_, _, num, generation)) => xref.get::>(hayro_syntax::object::ObjectIdentifier::new(num, generation)), + None => annot.get::>(AP).and_then(|ap| { ap.get::>(N).or_else(|| { let states = ap.get::>(N)?; let state = annot.get::>(AS)?; states.get::>(state.as_ref()) }) - }) - }); + }), + }; if let Some(apx) = normal.and_then(|o| FormXObject::new(&o)) { let Some(rect) = annot.get::(RECT) else { continue; From ee081d033e48e6dcf386afd818fece4f39297d21 Mon Sep 17 00:00:00 2001 From: FredApps Date: Wed, 11 Nov 2026 19:48:17 +0100 Subject: [PATCH 13/21] fix(scripts): keep timers past a refused script; page actions before timers A script refused before it runs (too long, or nested too deeply) left the realm untouched but reported no timers. The viewer keeps only the timers a run reports, so a button or console script over the limit stopped every page timer. The realm now reports the timers it still has on that path. The viewer built the page actions' run and the timers' run before it handled either, so a due timer ran before what the page's open action asked for: on a page that goes on to another, before that page's close action could stop it. The timers now run after the page actions, and only if those left the page, the tab and the dialogs as they were; otherwise they wait a frame. The focused field's editor text is taken before each run, not once for both. Tests: a_script_refused_before_it_runs_reports_the_realms_timers (js), a_script_refused_before_it_runs_keeps_the_pages_timers (engine), timers_wait_for_the_page_a_page_action_goes_to (ui). Each fails without its fix. --- crates/engine/src/live_scripts_tests.rs | 12 +++++++++ crates/js/src/session.rs | 15 ++++++++++- crates/ui-egui/src/workflows/js_ui.rs | 17 +++++++++---- crates/ui-egui/tests/it/view.rs | 34 +++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/live_scripts_tests.rs b/crates/engine/src/live_scripts_tests.rs index bee016b19b..2e92ec8ee7 100644 --- a/crates/engine/src/live_scripts_tests.rs +++ b/crates/engine/src/live_scripts_tests.rs @@ -289,3 +289,15 @@ fn a_cap_of_no_timer_runs_only_moves_the_clock() { assert_eq!((s.script_clock(id), s.next_timer(id)), (Some(100.0), Some(100.0))); assert_eq!(s.run_timers_at_most(id, 100.0, 1).ran, 1); } + +/// A script refused before it runs (too long) leaves the page's timers running. +#[test] +fn a_script_refused_before_it_runs_keeps_the_pages_timers() { + let mut s = Session::new(); + let id = s.open("t.pdf", None, Arc::new(timer_pdf("app.setInterval('1', 10);")), None).unwrap(); + s.show_page(id, 0, 0.0); + assert_eq!(s.next_timer(id), Some(10.0)); + let refused = s.run_javascript(id, &"1;".repeat(256 * 1024), None).expect("runs"); + assert!(refused.error.is_some(), "the script is refused"); + assert_eq!(s.next_timer(id), Some(10.0), "and the interval still runs"); +} diff --git a/crates/js/src/session.rs b/crates/js/src/session.rs index a97b84f370..8235706d3d 100644 --- a/crates/js/src/session.rs +++ b/crates/js/src/session.rs @@ -137,7 +137,8 @@ impl Realm { return self.fire(job, id); } // A script too long or too deeply nested to run is that script's error, as on `main`: the - // value stays and the edit's other scripts still run. The realm is not touched. + // value stays and the edit's other scripts still run. The realm is not touched, so its + // timers are as they were: they are reported, as after any run, or the viewer drops them. if let Some(why) = crate::refuse(&job.script) { return Outcome { rc: true, @@ -145,6 +146,7 @@ impl Realm { value: job.event.value.clone(), change: job.event.change.clone(), error: Some(why), + timers: self.context.as_ref().map(crate::timers::report).unwrap_or_default(), ..Default::default() }; } @@ -679,6 +681,17 @@ mod tests { assert!(gone.timers.is_empty() && gone.changed.is_empty(), "{gone:?}"); } #[test] + /// A script refused before it runs (too long) leaves the realm as it was and reports the + /// timers it still has: the viewer keeps only the timers a run reports. + fn a_script_refused_before_it_runs_reports_the_realms_timers() { + let mut s = ScriptSession::default(); + let set = s.run(job("app.setInterval('1', 40);"), &[]); + assert_eq!(set.timers.len(), 1); + let refused = s.run(job(&"1;".repeat(crate::MAX_SCRIPT_BYTES)), &[]); + assert!(refused.realm_untouched && !refused.execution_refused && refused.error.is_some(), "{:?}", refused.error); + assert_eq!(refused.timers, set.timers); + } + #[test] /// Retained Field identity, custom properties and keypad buffers persist locally while separate documents stay isolated. fn document_realm_keeps_keypad_state_and_isolates_documents() { let mut a = ScriptSession::default(); diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index c70fc4cc62..a8d28d7c3a 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -213,13 +213,20 @@ impl PdfCraftApp { let Some((i, id)) = self.active_ids() else { return }; let now_ms = ctx.input(|input| input.time) * 1000.0; let page = self.views[i].current; - // The field being edited as it was, so a timer changing it shows in its editor while a - // draft the user already typed into is kept (as `run_field_triggers` does). - let before: Option<(String, Vec)> = - self.views[i].forms.focus.as_ref().and_then(|e| { + // The page's actions, then its timers, if what the actions asked for left the viewer as it + // was: a page they went to runs its close and open actions (next frame) before any timer, + // and a dialog they opened holds the timers too. + for timers in [false, true] { + if timers && (self.modal_open() || self.active_ids() != Some((i, id)) || self.views[i].current != page) { + ctx.request_repaint(); + break; + } + // The field being edited as it was, so a script changing it shows in its editor while + // a draft the user already typed into is kept (as `run_field_triggers` does). + let before: Option<(String, Vec)> = self.views[i].forms.focus.as_ref().and_then(|e| { crate::forms_ui::focused_field(e, &self.session.get(id)?.form).map(|f| (crate::forms_ui::draft_text(f), f.value.clone())) }); - for run in [self.session.show_page(id, page, now_ms), self.session.run_timers(id, now_ms)] { + let run = if timers { self.session.run_timers(id, now_ms) } else { self.session.show_page(id, page, now_ms) }; if run.is_empty() { continue; } diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index e9fee18b00..196818e462 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1953,6 +1953,40 @@ fn page_actions_and_timers_wait_under_a_dialog() { assert!(h.state_mut().session.next_timer(id).is_some(), "and runs once it closes"); } +/// Three pages: the first's open action starts an interval counting `n`; the second's open +/// action goes on to the third, and its close action stops the interval. +const PAGE_HOP: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (var n = 0; var t = app.setInterval\\('n++', 10\\);) >> >> >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (this.pageNum = 2;) >> /C << /S /JavaScript /JS (app.clearInterval\\(t\\);) >> >> >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A page whose open action goes to another page runs no timer before its close action: the +/// interval that close action stops doesn't run in between. +#[test] +fn timers_wait_for_the_page_a_page_action_goes_to() { + 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("hop.pdf", None, PAGE_HOP.to_vec()).expect("opens"); + app + }); + h.run_steps(2); + let id = h.state().views[0].id; + assert!(h.state_mut().session.next_timer(id).is_some(), "the first page's open action set the interval"); + let count = |h: &mut Harness<'static, PdfCraftApp>| h.state_mut().session.run_javascript(id, "n", None).expect("runs").result; + let before = count(&mut h); + std::thread::sleep(std::time::Duration::from_millis(30)); + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 2, "the second page's open action went on"); + assert_eq!(h.state_mut().session.next_timer(id), None, "and its close action stopped the interval"); + assert_eq!(count(&mut h), before, "which didn't run in between"); +} + /// A text field, with a default appearance for its editor. const FIELD_TIMER: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >> endobj From 9f763636fd46ce487173925c63799cc87774fc24 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:16:31 -0500 Subject: [PATCH 14/21] fix(forms,xfa): find every password field before a save skips scrubbing it (#5864) * fix(forms,xfa): find every password field before a save skips scrubbing it The quick Password-flag check in pdfcraft-forms stopped silently at its depth limit, and visited the tree in an order where a field the full field walk reaches could be cut off on a long branch first. It now answers true (read every field) whenever it cannot look as far as the field walk, and also walks below fields adopted from page widgets. XFA password classification walked AcroForm fields only 32 levels deep, shallower than editing and dataset synchronization, and read the datasets packet of an XFA array without the prefixes its preamble declares, so a typed password could stay in the saved datasets. It now walks as deep as the field walk, refuses to clear (and so to save) when the tree is deeper than it can check, and reads the packet the way the datasets writer does. * address review: check every page widget's /Parent chain for the password flag * address review: password scan follows the parents of nameless /Fields entries; field-walk completeness checked before the datasets packet is read * address review: the field walk's completeness is decided after every root; the subtree scan starts at the last dictionary of the parent chain * address review: a skipped visit does not replace the reach a field was classified with --- crates/forms/src/tests.rs | 85 ++++++++++++++++++++++++++++++++ crates/forms/src/values.rs | 74 +++++++++++++++++++++------ crates/xfa/src/data/passwords.rs | 80 +++++++++++++++++++++--------- crates/xfa/src/data/path.rs | 27 ++++++++++ crates/xfa/src/tests.rs | 68 +++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 36 deletions(-) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index a2a87f3a73..2ce02626c9 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -5601,6 +5601,75 @@ fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() { assert!(!has_password_values(&doc)); } +/// A field reachable both near the top of the tree and at the end of a long branch: the field +/// walk reaches it near the top and reads the password field below it, so the quick check must +/// not stop at the long branch's depth limit and conclude there is none. The password field's +/// widget is on the page but names no `/Parent`, so the page widgets alone don't find it either. +#[test] +fn a_password_field_past_the_depth_limit_on_one_branch_is_still_found() { + let chain = 64; + let x = 6 + chain; // the shared field + let y = x + 1; // the password field + let w = y + 1; // its widget + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{w} 0 R] >>"), + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (short) /Kids [{x} 0 R] >>"), + ]; + for d in 0..chain { + let next = if d + 1 == chain { x } else { 7 + d }; + objs.push(format!("<< /T (n{d}) /Kids [{next} 0 R] >>")); + } + objs.push(format!("<< /T (shared) /Kids [{y} 0 R] >>")); + objs.push(format!("<< /FT /Tx /T (pin) /Ff 8192 /V (deepsecret) /Kids [{w} 0 R] >>")); + objs.push("<< /Type /Annot /Subtype /Widget /Rect [50 600 150 620] /P 3 0 R >>".into()); + let mut doc = document(&objs); + assert!(fields(&doc).iter().any(|f| f.has(flags::PASSWORD)), "the field walk reads the password field"); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// A password field below an unlisted field whose other widget is on the page: the field walk +/// adopts the whole unlisted field from that widget, so the quick check looks below it too. +#[test] +fn a_password_field_below_an_adopted_page_field_is_found() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /T (grp) /Kids [6 0 R 7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret8) /Parent 5 0 R /Rect [0 0 0 0] >>".into(), + ]); + assert!(fields(&doc).iter().any(|f| f.has(flags::PASSWORD)), "the field walk adopts the password field"); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// A nameless widget listed in `/Fields` and on no page, whose `/Parent` is an unlisted password +/// field: the field walk adopts the parent, so the quick check follows the entry's `/Parent` too. +#[test] +fn a_password_parent_of_a_nameless_fields_entry_is_found() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /T (pin) /FT /Tx /Ff 8192 /V (secret8) /Kids [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [0 0 0 0] >>".into(), + ]); + assert!(fields(&doc).iter().any(|f| f.has(flags::PASSWORD) && f.value.iter().any(|v| v == "secret8")), "the field walk reads the parent"); + assert!(has_password_flag(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); +} + /// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut /// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. #[test] @@ -6802,6 +6871,22 @@ fn locked_button_export_edits_leave_the_document_unchanged() { } } +/// A widget reached from `/Fields` as an unnamed kid of a plain field, whose own `/Parent` is an +/// unlisted password field: the quick check must still read that `/Parent` chain. +#[test] +fn a_page_widget_seen_under_fields_still_has_its_parent_chain_checked() { + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /T (grp) /Kids [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 7 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), + "<< /FT /Tx /T (pin) /Ff 8192 /V (secret9) /Kids [6 0 R] >>".into(), + ]); + assert!(has_password_flag(&doc)); +} + /// A radio button that is read-only on its own (its own `/Ff`) can't be picked, and a selected one /// can't be switched away from, though the group as a whole is writable. #[test] diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index b60d8aa140..3600695c03 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -758,29 +758,69 @@ fn password_fields(doc: &Document) -> Vec { /// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save /// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like /// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be -/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. +/// missing from `/Fields`, together with the whole tree above and below them that [`fields`] +/// adopts. Each dictionary is visited once. +/// +/// It must never answer `false` for a form whose [`fields`] include a password field, or a typed +/// password would be saved. So where it can't look as far as [`fields`] may (a branch deeper than +/// the field walk's limit, which depends on the order the tree is visited in, or a `/Parent` +/// chain too long to follow), it answers `true` and the caller reads every field. pub(crate) fn has_password_flag(doc: &Document) -> bool { + const MAX_FIELD_DEPTH: usize = 64; let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); - let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); - let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let kids_of = + |r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); let mut seen = std::collections::HashSet::new(); - while let Some((o, depth)) = stack.pop() { - let Object::Ref(r) = o else { continue }; - if depth > 64 || !seen.insert(r) { - continue; + // Whether a dictionary below `start` sets the flag, or the walk was cut short. + let subtree = |start: Vec, seen: &mut std::collections::HashSet| { + let mut stack: Vec<(Object, usize)> = start.into_iter().map(|o| (o, 0)).collect(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if !seen.insert(r) { + continue; + } + if depth > MAX_FIELD_DEPTH || sets_it(r) { + return true; + } + stack.extend(kids_of(r).into_iter().map(|k| (k, depth + 1))); } - if sets_it(r) { + false + }; + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let top_refs: Vec = top.iter().filter_map(Object::as_ref).collect(); + if subtree(top, &mut seen) { + return true; + } + // A `/Fields` entry may itself name a field above it through `/Parent` (one `fields` adopts when + // the entry has no name of its own), whether or not a page lists it. + for r in top_refs { + let chain = field_chain(doc, r); + if chain.len() >= MAX_FIELD_DEPTH || chain.iter().copied().any(sets_it) { + return true; + } + if subtree(vec![Object::Ref(field_root(doc, &chain, r))], &mut seen) { return true; } - let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, depth + 1))); } - // Widgets on the pages, and the fields above them. - page_refs(doc).into_iter().any(|p| { + // Widgets on the pages, the fields above them, and the fields below the topmost one. + for p in page_refs(doc) { let annots = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); - annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) - }) + // Every widget's `/Parent` chain is read, even one already reached from `/Fields`: the + // field walk can adopt it under a different parent there. Each chain is at most + // MAX_FIELD_DEPTH long, and `subtree` returns at once for a root it has seen. + for r in annots.iter().filter_map(Object::as_ref) { + let chain = field_chain(doc, r); + if chain.len() >= MAX_FIELD_DEPTH || chain.iter().copied().any(sets_it) { + return true; + } + let root = field_root(doc, &chain, r); + if subtree(vec![Object::Ref(root)], &mut seen) { + return true; + } + } + } + false } /// Whether any text field with the Password flag holds a value in `doc`. @@ -806,6 +846,12 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) Ok(()) } +/// The topmost dictionary of `chain` (from [`field_chain`]): a `/Parent` that is not a dictionary +/// ends the field tree, so the walk below it starts at the last one that is. +fn field_root(doc: &Document, chain: &[ObjRef], r: ObjRef) -> ObjRef { + chain.iter().rev().copied().find(|c| doc.get(*c).as_dict().is_some()).unwrap_or(r) +} + /// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. fn field_chain(doc: &Document, r: ObjRef) -> Vec { let mut chain = vec![r]; diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs index 0f98a3f2b4..896d513f2c 100644 --- a/crates/xfa/src/data/passwords.rs +++ b/crates/xfa/src/data/passwords.rs @@ -1,26 +1,38 @@ use super::import::datasets_packet; use super::merge::Op; -use super::path::{data_path, parse_datasets}; +use super::path::{data_path, parse_datasets_packet}; use super::values::{Want, path_of, text_of, with_template}; use super::write::{datasets_stream_ref, rewrite}; use super::{DataNode, DataPath, Document, MAX_DEPTH, Node, ObjRef, Object, SomIndices, Template, Ui, XfaError}; +/// How deep the AcroForm field tree is walked for password fields: as deep as the form's own +/// field walk (`pdfcraft_forms::fields`), which is what editing and dataset synchronization see. +const MAX_ACRO_DEPTH: usize = 64; + /// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, /// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm -/// dictionary. -fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { +/// dictionary. `None` when the walk was cut short at [`MAX_ACRO_DEPTH`]: which branch is cut +/// depends on the order the tree is visited in, so a field the form's own walk reaches may have +/// been missed, and the caller can't tell that no password field is left. +fn acro_password_fields(doc: &Document) -> Option> { const PASSWORD_FLAG: i64 = 1 << 13; - let Some(root) = doc.root() else { return Vec::new() }; + let Some(root) = doc.root() else { return Some(Vec::new()) }; let catalog = doc.get(root); - let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Some(Vec::new()) }; let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { - return Vec::new(); + return Some(Vec::new()); }; struct AcroWalk<'a> { doc: &'a Document, - seen: std::collections::HashSet, + /// Each field reached, with what it was reached with: its parent's name, type, flags and + /// default, which decide what it classifies as. + seen: std::collections::HashMap, + /// Fields past the depth limit, with what they were reached with. One reached again from + /// another root in the same way was classified there; any other is not fully read. + cut: Vec<(ObjRef, Reach)>, out: Vec<(String, String)>, } + type Reach = (String, Option>, i64, Option); impl AcroWalk<'_> { fn walk( &mut self, @@ -31,12 +43,19 @@ fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { inherited_dv: Option<&str>, depth: usize, ) { - if depth > 32 { + if depth > MAX_ACRO_DEPTH { + let reach: Reach = (parent_name.to_string(), inherited_ft.map(<[u8]>::to_vec), inherited_ff, inherited_dv.map(str::to_string)); + self.cut.extend(items.iter().filter_map(Object::as_ref).map(|r| (r, reach.clone()))); return; } for item in items { let Some(r) = item.as_ref() else { continue }; - if !self.seen.insert(r) { + let reach: Reach = (parent_name.to_string(), inherited_ft.map(<[u8]>::to_vec), inherited_ff, inherited_dv.map(str::to_string)); + // The reach a field was classified with stays: a later visit that skips it classified + // nothing, so it must not stand in for the cut-off one. + if let std::collections::hash_map::Entry::Vacant(slot) = self.seen.entry(r) { + slot.insert(reach); + } else { continue; } let obj = self.doc.get(r); @@ -65,20 +84,35 @@ fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { } } } - let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; + let mut w = AcroWalk { doc, seen: std::collections::HashMap::new(), cut: Vec::new(), out: Vec::new() }; w.walk(&top, "", None, 0, None, 0); - w.out + // Decided once every root has been visited: a branch cut deep down may be reached shallower + // from a later root, and is then read in full. + let incomplete = w.cut.iter().any(|(r, reach)| w.seen.get(r) != Some(reach)); + (!incomplete).then_some(w.out) } /// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their /// defaults (or empty when they have no default). -fn password_dataset_wants(doc: &Document) -> Vec { +/// +/// The packet is read as the datasets writer reads it ([`parse_datasets_packet`]): a packet the +/// writer can't read holds no value it wrote, so there is nothing typed in it to clear. An error +/// when the password fields can't all be found (a field tree deeper than the field walk reaches): +/// the save is refused rather than written with a typed password left in the data. +fn password_dataset_wants(doc: &Document) -> Result, XfaError> { if datasets_stream_ref(doc).is_none() { - return Vec::new(); + return Ok(Vec::new()); } - let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; - let Some(data) = parse_datasets(&packet) else { return Vec::new() }; - with_template(doc, None, |tpl| { + // Checked before the packet is read: a packet that can't be parsed leaves nothing to clear, but + // a form whose fields can't all be found is refused whatever the packet holds. + let Some(acro_fields) = acro_password_fields(doc) else { + return Err(XfaError::Malformed( + "the form's fields nest too deeply to find every password field, so typed passwords could not be removed from its XFA data".into(), + )); + }; + let Ok(Some(packet)) = datasets_packet(doc) else { return Ok(Vec::new()) }; + let Some(data) = parse_datasets_packet(&packet) else { return Ok(Vec::new()) }; + Ok(with_template(doc, None, |tpl| { let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); let mut seen = std::collections::HashSet::new(); if let Some(t) = tpl { @@ -153,7 +187,7 @@ fn password_dataset_wants(doc: &Document) -> Vec { } } } - for (som, default) in acro_password_fields(doc) { + for (som, default) in acro_fields { let path = path_of(tpl, &som); if !path.is_empty() && seen.insert(path.clone()) { candidates.push((som, path, default)); @@ -166,27 +200,29 @@ fn password_dataset_wants(doc: &Document) -> Vec { (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) }) .collect() - }) + })) } /// Whether any XFA password field (`` or an AcroForm text field with the Password -/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). +/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). A form whose +/// password fields can't all be found counts as holding some, so that clearing them +/// ([`clear_password_values`]) reports why the document can't be written without them. pub fn has_password_values(doc: &Document) -> bool { - !password_dataset_wants(doc).is_empty() + !password_dataset_wants(doc).is_ok_and(|w| w.is_empty()) } /// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default /// where the field has one, else emptying the element), replacing the datasets stream in place /// (#3944). Returns how many password fields were cleared in the datasets. pub fn clear_password_values(doc: &mut Document) -> Result { - let wants = password_dataset_wants(doc); + let wants = password_dataset_wants(doc)?; if wants.is_empty() { return Ok(0); } let count = wants.len(); let reuse = datasets_stream_ref(doc); let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; - if !report.written || !password_dataset_wants(doc).is_empty() { + if !report.written || !password_dataset_wants(doc)?.is_empty() { let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); return Err(XfaError::Malformed(reason.to_string())); } diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index 23c5e5ba5d..7fdba77071 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -24,6 +24,33 @@ pub fn parse_datasets(xdp: &str) -> Option { Some(to_node(data, 0)) } +/// The `xfa:data` element of a datasets packet as the datasets writer reads it before changing it: +/// a packet of an array may use the `xfa` and `xdp` prefixes its XDP envelope declares, so it is +/// read inside an element declaring them when it doesn't parse alone, and the datasets element in +/// the XFA data namespace is preferred over another with the same local name. Reading the packet +/// any other way could miss the values the writer put in it. +pub(super) fn parse_datasets_packet(text: &str) -> Option { + if crate::xml_nesting_depth(text) > crate::MAX_XML_DEPTH { + return None; + } + let opts = || roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: MAX_NODES, ..Default::default() }; + let wrapped; + let doc = match roxmltree::Document::parse_with_options(text, opts()) { + Ok(d) => d, + Err(_) => { + wrapped = format!("{}{text}{}", super::merge::WRAP_OPEN, super::merge::WRAP_CLOSE); + roxmltree::Document::parse_with_options(&wrapped, opts()).ok()? + } + }; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let datasets = doc + .descendants() + .find(|n| is(n, "datasets") && n.tag_name().namespace() == Some(super::XFA_DATA_NS)) + .or_else(|| doc.descendants().find(|n| is(n, "datasets")))?; + let data = datasets.children().find(|n| is(n, "data"))?; + Some(to_node(data, 0)) +} + /// The data path a SOM expression names: `form[0].#subform[0].name[1]` → `[(form, 0), (name, 1)]`. /// Unnamed containers (`#subform`) have no data node. pub fn som_to_path(som: &str) -> DataPath { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 27e6522f26..c1abcce190 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -7114,6 +7114,74 @@ fn a_container_is_as_wide_as_a_turned_draw_stands() { } } +/// The static fixture with its text field (`form1[0].page1[0].name[0]`) flagged Password and +/// `levels` unnamed fields between `form1[0]` and `page1[0]`, which add no SOM segment. +fn static_password_doc(levels: usize, data: &str) -> Document { + let mut doc = Document::open(Arc::new(static_shell(data))).unwrap(); + doc.update_dict(pdfcraft_cos::ObjRef::new(8, 0), |d| d.set(b"Ff".to_vec(), Object::Int(1 << 13))).unwrap(); + let mut next = pdfcraft_cos::ObjRef::new(7, 0); + for _ in 0..levels { + let mut d = Dict::new(); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(next)])); + next = doc.add(Object::Dict(d)); + } + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(next)]))).unwrap(); + doc +} + +/// #3944: a Password-flagged AcroForm field of a static XFA form nested deeper than 32 levels (as +/// deep as the form's own field walk reaches) has its typed value cleared from the datasets. +#[test] +fn a_deeply_nested_acroform_password_field_is_cleared_from_the_datasets() { + let data = "hunter2"; + let mut doc = static_password_doc(40, data); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); + assert!(!String::from_utf8_lossy(&xfa_bytes(&doc)).contains("hunter2")); +} + +/// A field tree deeper than the walk reaches can't be shown to hold no password field: clearing +/// refuses (so the save does) rather than leave a typed password in the datasets. +#[test] +fn a_field_tree_too_deep_to_check_for_passwords_refuses_to_clear() { + let mut doc = static_password_doc(80, "hunter2"); + assert!(has_password_values(&doc)); + assert!(clear_password_values(&mut doc).is_err()); + // A shallow form without a typed value has nothing to clear. + let mut doc = static_password_doc(0, ""); + assert!(!has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); +} + +/// A datasets packet of an XFA array may use the `xfa` prefix its preamble declares: the typed +/// password in it is still found and cleared, as the datasets writer reads that packet too. +#[test] +fn a_password_in_a_datasets_packet_using_the_preamble_prefix_is_cleared() { + let mut doc = static_password_doc(0, ""); + let pre = r#""#; + let tpl = r#""#; + let ds = "hunter2"; + let parts: Vec = [("preamble", pre), ("template", tpl), ("datasets", ds), ("postamble", "")] + .into_iter() + .flat_map(|(name, text)| { + let r = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), text.as_bytes()))); + [Object::String(pdfcraft_cos::PdfString::literal(name.as_bytes().to_vec())), Object::Ref(r)] + }) + .collect(); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + let mut acro = c.get(b"AcroForm").and_then(Object::as_dict).cloned().unwrap(); + acro.set(b"XFA".to_vec(), Object::Array(parts)); + c.set(b"AcroForm".to_vec(), Object::Dict(acro)); + }) + .unwrap(); + assert!(crate::data::parse_datasets(ds).is_none(), "the packet doesn't parse alone"); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + /// Every sibling a SOM index skips owns a copy of the element name: the in-memory builder refuses /// a path whose copies would pass the name-byte bound the writer holds itself to, and creates /// nothing for it. From 80e26e674d1aa33078e83c8139875047f3289eb6 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:16:48 -0500 Subject: [PATCH 15/21] fix(redact): remove a stroked path by the box its stroke paints (#5876) * fix(redact): remove a stroked path by the box its stroke paints Path redaction tested a path's construction box against the marks. A horizontal or vertical stroke has a zero-area box, which overlaps nothing, so its drawing commands stayed beneath the overlay; a wide stroke whose points lie beside a mark but whose ink reaches into it stayed too. The interpreter now tracks the line width, miter limit and join style (`w`, `M`, `j`, and `LW`, `ML`, `LJ` through `gs`) and, for a stroking operator, grows the box by half the width (at least one unit), times the miter limit when the path has mitred joins, scaled by the CTM's largest stretch, before the hit test. The bound is conservative: a stroke near a mark may now go where before it stayed. * address review: pad hairlines by half a unit, rectangles for their corners, square caps by sqrt 2 * review: test a kept clip against the path as constructed * address review: a line state set between a path and its stroke no longer lets the stroke skip the redaction test * address review: line-state operators read the operand before the operator --- crates/redact/src/interp.rs | 69 +++++++++++++++++++++++++++++++++++-- crates/redact/src/tests.rs | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 80117f451d..12636dc88f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -93,6 +93,12 @@ struct Gs { scale: f64, leading: f64, rise: f64, + /// The stroke parameters that widen a painted path beyond its construction points. + line_width: f64, + miter_limit: f64, + line_join: i64, + /// `J` / ExtGState `/LC`: a projecting square cap (2) reaches past the endpoint's corner. + line_cap: i64, /// The tiling or shading pattern selected as the fill colour (`/Name scn`), and as the stroke /// colour (`/Name SCN`). Part of the graphics state, so `q`/`Q` save and restore them with /// everything else. A pattern's cell is a content stream of its own and this pass does not @@ -394,6 +400,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< scale: 1.0, leading: 0.0, rise: 0.0, + line_width: 1.0, + miter_limit: 10.0, + line_join: 0, + line_cap: 0, fill_pattern: None, stroke_pattern: None, type3_inherits: false, @@ -438,6 +448,7 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], } if !walk.path.is_empty() { done.ops.append(&mut walk.path); + done.ops.append(&mut walk.pending); } processed.push((done.ops, done.changed)); } @@ -505,6 +516,10 @@ struct Walk { path: Vec, path_box: Option<[f64; 4]>, clip: bool, + /// Line-state operators (`w`, `M`, `j`, `J`, `d`, `gs`) met while a path is open: they apply to + /// the stroke that ends it, so the path stays whole for the hit test, and they are written + /// after it, kept even when the path goes. + pending: Vec, /// Painting operators written again under a clip that cuts the regions out: unchanged as /// operators, but what they paint is no longer all there. clipped: HashSet, @@ -526,6 +541,7 @@ impl Walk { path: Vec::new(), path_box: None, clip: false, + pending: Vec::new(), clipped: HashSet::new(), out: Output::default(), } @@ -542,14 +558,20 @@ impl Walk { self.paint_path(scope, op, piece); return; } + if !self.path.is_empty() && matches!(o, b"w" | b"M" | b"j" | b"J" | b"d" | b"gs") { + self.graphics_state(doc, scope, &op); + self.pending.push(op); + return; + } if !self.path.is_empty() { // A path not ended by a painting operator: pass it through. piece.ops.append(&mut self.path); + piece.ops.append(&mut self.pending); self.path_box = None; self.clip = false; } let kept = match o { - b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" | b"w" | b"M" | b"j" | b"J" => { self.graphics_state(doc, scope, &op); Some(op) } @@ -627,11 +649,35 @@ impl Walk { self.path.push(op); } + /// The box a stroke of the current path can paint, grown from its construction box `b` by + /// what the pen adds: half the line width (a width of 0 is one device pixel, taken as one + /// unit) scaled by the CTM's largest stretch, and never less than half a unit of page space + /// (a renderer paints a hairline, or a line a CTM shrinks, about a device pixel wide); times + /// the miter limit when the path has joins and they are mitred (a rectangle has four), or + /// √2 for a projecting square cap (its outer corner). Caps and joins never reach further, so + /// a hit test on it is conservative, and a horizontal or vertical line no longer has a + /// zero-area box. + fn stroke_box(&self, b: [f64; 4]) -> [f64; 4] { + let m = self.gs.ctm.0; + let scale = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2] + m[3] * m[3]).sqrt(); + let joins = self.path.iter().filter(|op| matches!(op.op.as_slice(), b"l" | b"c" | b"v" | b"y" | b"h" | b"re")).count() >= 2 + || self.path.iter().any(|op| op.op.as_slice() == b"re"); + let miter = if joins && self.gs.line_join == 0 { self.gs.miter_limit.max(1.0) } else { 1.0 }; + let reach = if self.gs.line_cap == 2 { miter.max(std::f64::consts::SQRT_2) } else { miter }; + let pad = (self.gs.line_width.max(1.0) / 2.0 * scale).max(0.5) * reach; + [b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad] + } + /// A painting operator ends the path; one under a region is removed (or kept as a clip). fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let strokes = matches!(op.op.as_slice(), b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*"); + // A `W`/`W*` makes the path as constructed the clip; the stroke expansion only describes + // what the painting itself covers. + let path_box = self.path_box.take(); + let bbox = path_box.map(|b| if strokes { self.stroke_box(b) } else { b }); let body = std::mem::take(&mut self.path); - let bbox = self.path_box.take(); let clips = std::mem::replace(&mut self.clip, false); + let pending = std::mem::take(&mut self.pending); let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); if !hit { // The paint survives, so the pattern it draws with is still in use. `n` paints @@ -641,25 +687,30 @@ impl Walk { let fills = matches!(o, b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"); uses_patterns(&mut self.out, &self.gs, fills, strokes); piece.ops.extend(body); + piece.ops.extend(pending); piece.ops.push(op); return; } let b = bbox.unwrap_or_default(); piece.changed = true; scope.report.paths_removed += 1; - if scope.covered(b) && clips { + if scope.covered(path_box.unwrap_or(b)) && clips { // Everything this clip lets through is under a mark, so an empty clip hides the same // content. The original outline is confidential and must not stay in the stream. + piece.ops.extend(pending); for o in ["re", "W", "n"] { let operands = if o == "re" { vec![num(0.0); 4] } else { vec![] }; piece.ops.push(Op::new(o, operands)); } + } else { + piece.ops.extend(pending); } // Partial vector redaction must not retain underlying drawing commands // behind clipping (F-008). Refuse partial clip retention and remove the path. } fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + let last = op.nums::<1>().map(|[v]| v); match op.op.as_slice() { b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)), b"Q" => { @@ -673,12 +724,24 @@ impl Walk { } } b"Tf" => self.select_font(doc, scope, op), + // The operand before the operator, as the renderer reads it: `1 12 w` is a width of 12. + b"w" => self.gs.line_width = last.unwrap_or(self.gs.line_width), + b"M" => self.gs.miter_limit = last.unwrap_or(self.gs.miter_limit), + b"j" => self.gs.line_join = last.map_or(self.gs.line_join, |j| j as i64), + b"J" => self.gs.line_cap = last.map_or(self.gs.line_cap, |j| j as i64), _ => { if let Some((font, vertical, size)) = op.last_name().and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); // As for `Tf`: a Type 3 font without /Resources paints through this scope's. self.gs.type3_inherits = op.last_name().is_some_and(|n| extgstate_type3_inherits(doc, &self.ext_gstates, n)); } + if let Some(state) = op.name(0).and_then(|n| self.ext_gstates.get(n)).and_then(|s| doc.resolve(s).as_dict().cloned()) { + let num = |key: &[u8]| state.get(key).and_then(|v| doc.resolve(v).as_f64()); + self.gs.line_width = num(b"LW").unwrap_or(self.gs.line_width); + self.gs.miter_limit = num(b"ML").unwrap_or(self.gs.miter_limit); + self.gs.line_join = num(b"LJ").map_or(self.gs.line_join, |j| j as i64); + self.gs.line_cap = num(b"LC").map_or(self.gs.line_cap, |j| j as i64); + } } } } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 08125165b5..43b6caaadc 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -837,6 +837,22 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +/// A horizontal stroke's construction box has no area, so it overlapped nothing and stayed under +/// the mark. The painted stroke is as wide as the line: a 12 pt line centred 5 pt above the mark +/// reaches into it and goes; a 1 pt line there does not and stays. +#[test] +fn a_stroke_is_removed_by_its_painted_width() { + let src = b"12 w 10 60 m 100 60 l S 1 w 10 60 m 100 60 l S 4 w 10 50 m 100 50 l S"; + let mut doc = one_page(src, "", vec![]); + mark(&mut doc, 0, &[[40.0, 45.0, 60.0, 55.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 2, "{r:?}"); + let c = content(&doc, 0); + assert!(!c.contains("12 w\n10 60 m"), "the wide line reaches the mark: {c}"); + assert!(c.contains("1 w\n10 60 m\n100 60 l\nS"), "the thin line beside it stays: {c}"); + assert!(!c.contains("10 50 m"), "the line through the mark goes: {c}"); +} + /// A tiling pattern's cell is a content stream of its own and can draw text. Redacting a shape /// filled with such a pattern removed the fill from the page but left the pattern in the page's /// resources, so its cell -- with the text that was under the mark -- stayed in the saved file @@ -6674,6 +6690,59 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() { } } +/// A hairline under a shrinking CTM still paints about a device pixel: its pad must not shrink +/// with the CTM, or a mark on that ink misses it. +#[test] +fn a_hairline_under_a_shrinking_ctm_is_padded_by_half_a_unit() { + // Device line from (10, 10) to (50, 10); the mark lies on its painted pixel, above its axis. + let mut doc = one_page(b"0.01 0 0 0.01 0 0 cm 0 w 1000 1000 m 5000 1000 l S", "", vec![]); + mark(&mut doc, 0, &[[20.0, 10.2, 30.0, 10.4]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); +} + +/// One `re` has four mitred corners: it is padded by the miter limit like any joined path. +#[test] +fn a_rectangle_stroke_is_padded_for_its_mitred_corners() { + let mut doc = one_page(b"2 w 100 100 50 50 re S", "", vec![]); + // Past half the line width from the rectangle, inside the miter reach. + mark(&mut doc, 0, &[[155.0, 120.0, 160.0, 130.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); +} + +/// A projecting square cap's outer corner reaches lw/2 x sqrt(2) from the endpoint. Under a shear +/// it maps past a pad of lw/2 times the CTM's stretch (the review's example: corner at x = 12). +#[test] +fn a_square_cap_corner_under_a_shear_is_inside_the_pad() { + let mut doc = one_page(b"1 0 1 1 0 0 cm 2 J 2 w 0 0 m 10 0 l S", "", vec![]); + mark(&mut doc, 0, &[[11.8, 0.8, 11.9, 0.95]], ""); + assert_eq!(apply(&mut doc, None).unwrap().paths_removed, 1); +} + +/// A line width set between the path and its `S` still applies to that stroke, so the stroke is +/// tested at the width it is painted with; the width itself stays, the path goes. +#[test] +fn a_width_set_between_the_path_and_its_stroke_does_not_hide_it_from_the_test() { + let mut doc = one_page(b"10 60 m 100 60 l 12 w S 5 5 m 9 9 l S", "", vec![]); + mark(&mut doc, 0, &[[40.0, 45.0, 60.0, 55.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 1, "{r:?}"); + let c = content(&doc, 0); + assert!(!c.contains("10 60 m") && !c.contains("100 60 l"), "the wide line reaches the mark: {c}"); + assert!(c.contains("12 w"), "the width stays: {c}"); + assert!(c.contains("5 5 m"), "the line elsewhere stays: {c}"); +} + +/// The renderer takes a line-state operator's operand from the end: `1 12 w` strokes 12 points wide, +/// so the stroke is tested at that width. +#[test] +fn a_surplus_operand_before_the_line_width_does_not_hide_the_stroke() { + let mut doc = one_page(b"1 12 w 10 60 m 100 60 l S", "", vec![]); + mark(&mut doc, 0, &[[40.0, 45.0, 60.0, 55.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 1, "{r:?}"); + assert!(!content(&doc, 0).contains("100 60 l")); +} + /// One page drawing `SECRET` in the `/OC /L` block, with layer 7 0 R in the default /// configuration `config` (objects 8 and up are `extra`). fn layer_state_page(config: Vec<(&str, Object)>, resource: &str, extra: Vec>) -> Document { From 1d2af9907ba772b368f6b54811359805fa5428e9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:38:09 -0500 Subject: [PATCH 16/21] Fix Flatpak updates and bump to 0.21.1 (#7020) * fix(flatpak): allow release checks and self-update helpers * test: keep Flatpak permission parsing within strict Clippy rules * chore: bump version to 0.21.1 for the Flatpak update fix --- Cargo.lock | 78 +++++++++---------- Cargo.toml | 2 +- crates/ui-egui/tests/flatpak_updates.rs | 27 +++++++ .../ai.storyteller.pdfcraft.bundle.yml | 2 + .../linux/flatpak/ai.storyteller.pdfcraft.yml | 2 + 5 files changed, 71 insertions(+), 40 deletions(-) create mode 100644 crates/ui-egui/tests/flatpak_updates.rs diff --git a/Cargo.lock b/Cargo.lock index cecedc12c9..0e4c910427 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4264,7 +4264,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "craft-update", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.21.0" +version = "0.21.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4300,7 +4300,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.21.0" +version = "0.21.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4312,7 +4312,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.21.0" +version = "0.21.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4323,7 +4323,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.21.0" +version = "0.21.1" dependencies = [ "base64 0.23.1", "flate2", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.21.0" +version = "0.21.1" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4365,11 +4365,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.21.0" +version = "0.21.1" [[package]] name = "pdfcraft-content" -version = "0.21.0" +version = "0.21.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4377,7 +4377,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "hayro-syntax", @@ -4394,7 +4394,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.21.0" +version = "0.21.1" dependencies = [ "encoding_rs", "hayro-ccitt", @@ -4414,7 +4414,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.21.0" +version = "0.21.1" dependencies = [ "aes", "md-5", @@ -4427,7 +4427,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.21.0" +version = "0.21.1" dependencies = [ "hayro-syntax", "icu_segmenter", @@ -4446,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.21.0" +version = "0.21.1" dependencies = [ "chrono", "craft-docpath", @@ -4490,14 +4490,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.21.0" +version = "0.21.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.21.0" +version = "0.21.1" dependencies = [ "fax", "flate2", @@ -4510,7 +4510,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.21.0" +version = "0.21.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4520,7 +4520,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.21.0" +version = "0.21.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4533,11 +4533,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.21.0" +version = "0.21.1" [[package]] name = "pdfcraft-js" -version = "0.21.0" +version = "0.21.1" dependencies = [ "boa_engine", "chrono", @@ -4545,7 +4545,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.21.0" +version = "0.21.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4558,14 +4558,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.21.0" +version = "0.21.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.21.0" +version = "0.21.1" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4580,7 +4580,7 @@ dependencies = [ [[package]] name = "pdfcraft-ocr-worker" -version = "0.21.0" +version = "0.21.1" dependencies = [ "js-sys", "pdfcraft-ocr", @@ -4590,7 +4590,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.21.0" +version = "0.21.1" dependencies = [ "image", "pdfcraft-annot", @@ -4607,11 +4607,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.21.0" +version = "0.21.1" [[package]] name = "pdfcraft-organize" -version = "0.21.0" +version = "0.21.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4622,7 +4622,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.21.0" +version = "0.21.1" dependencies = [ "async-channel", "futures-lite", @@ -4633,7 +4633,7 @@ dependencies = [ [[package]] name = "pdfcraft-prc" -version = "0.21.0" +version = "0.21.1" dependencies = [ "flate2", "proptest", @@ -4642,7 +4642,7 @@ dependencies = [ [[package]] name = "pdfcraft-prc-render" -version = "0.21.0" +version = "0.21.1" dependencies = [ "flate2", "pdfcraft-prc", @@ -4651,7 +4651,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.21.0" +version = "0.21.1" dependencies = [ "flate2", "pdfcraft-content", @@ -4662,7 +4662,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.21.0" +version = "0.21.1" dependencies = [ "craft-print", "log", @@ -4677,7 +4677,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.21.0" +version = "0.21.1" dependencies = [ "aho-corasick", "pdfcraft-annot", @@ -4701,7 +4701,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.21.0" +version = "0.21.1" dependencies = [ "bytemuck", "flate2", @@ -4725,7 +4725,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.21.0" +version = "0.21.1" dependencies = [ "aes", "aws-lc-rs", @@ -4764,11 +4764,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.21.0" +version = "0.21.1" [[package]] name = "pdfcraft-ui-egui" -version = "0.21.0" +version = "0.21.1" dependencies = [ "arboard", "base64 0.23.1", @@ -4827,7 +4827,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.21.0" +version = "0.21.1" dependencies = [ "craft-web-api", "craft-web-host", @@ -4842,7 +4842,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.21.0" +version = "0.21.1" dependencies = [ "base64 0.23.1", "image", @@ -4856,7 +4856,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.21.0" +version = "0.21.1" dependencies = [ "base64 0.23.1", "pdfcraft-annot", diff --git a/Cargo.toml b/Cargo.toml index 9cd5b3b7c3..752f580475 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.21.0" +version = "0.21.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/crates/ui-egui/tests/flatpak_updates.rs b/crates/ui-egui/tests/flatpak_updates.rs new file mode 100644 index 0000000000..536613f08a --- /dev/null +++ b/crates/ui-egui/tests/flatpak_updates.rs @@ -0,0 +1,27 @@ +//! Release and source Flatpaks must allow release checks and host update helpers. +fn finish_args(manifest: &str) -> Vec<&str> { + manifest + .split("finish-args:") + .nth(1) + .unwrap_or_default() + .lines() + .take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#')) + .filter_map(|line| line.trim().strip_prefix("- ")) + .map(|arg| arg.trim_matches(['\'', '"'])) + .collect() +} + +#[test] +fn flatpak_update_permissions() { + let manifests = [ + include_str!("../../../packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml"), + include_str!("../../../packaging/linux/flatpak/ai.storyteller.pdfcraft.yml"), + ]; + let args = manifests.map(finish_args); + assert_eq!(args[0], args[1], "source and release permissions must agree"); + for permissions in args { + for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] { + assert!(permissions.contains(&required), "missing update permission: {required}"); + } + } +} diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index d781ad6c7c..8d2fb49192 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -17,6 +17,8 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: pdfcraft finish-args: + # The shared updater inspects and updates this installation via flatpak-spawn --host. + - --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index fba62fe382..2c5d6d88c6 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -18,6 +18,8 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: pdfcraft finish-args: + # The shared updater inspects and updates this installation via flatpak-spawn --host. + - --talk-name=org.freedesktop.Flatpak - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland From d749fd8062655c333c0733d8456bd8d4c9925a12 Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 00:28:18 -0500 Subject: [PATCH 17/21] Print colour management: the app converts printed pictures into a printer profile (#6269) (#6853) * Print: app-managed colour converts printed pictures into a printer profile (#6269) doc_print takes color_management (printer, the default, or app). With app, color_profile (an ICC file or an installed profile's name), rendering_intent and black_point_compensation convert each rendered page picture from sRGB into the printer's RGB or grey profile before it is sent. app prints the pages as pictures; CMYK and non-destination profiles are refused. print_color_profiles lists the installed RGB and grey profiles. The conversion is craft-cms at craft-print's rev, in pdfcraft_print::colour, hooked into print_raster::rasterize. Raster carries the colour and is Clone rather than Copy. Co-Authored-By: Claude Opus 5.5 * Print colour: look for installed profiles inside the read root when confined With automation roots, print_color_profiles and doc_print's color_profile name lookup read the system's colour folders, outside the roots. They now look in the read root instead (as text_font_files does), check each file against it before reading it, and are refused without read authority. Under --root they look in the root. Co-Authored-By: Claude Opus 5.5 * automation: keep colour profile discovery inside --root, links followed Under --root alone, print_color_profiles and the color_profile name lookup listed the root's .icc files without checking each against the root, so a link inside it could read a profile outside. Each discovered file now goes through the same --root resolution as any other read (links followed) before it is opened. colour_profile_links_out_of_the_root_are_not_read makes such a link and checks it is neither listed nor found by name; it fails without the check (the link is listed). Co-Authored-By: Claude Opus 5.5 * print: as_chunks for the grey fill; area print keeps managed colour Clippy 1.99 (`chunks_exact_to_as_chunks`) flags the constant-width `chunks_exact_mut(4)` in the grey conversion; use `as_chunks_mut::<4>()`. main's print-area path built `Raster` without the new `colour` field; build it through `PrintDraft::raster()` so a printed area is colour managed like a printed page. Co-Authored-By: Claude Opus 5.5 * Lock craft-cms at the craft-libs commit craft-print now uses Co-Authored-By: Claude Opus 5.5 * fix(print): read only regular files as printer profiles, and no more than the cap A FIFO or device named `.icc`/`.icm` in a profile folder was listed and read, and opening a FIFO for reading waits for a writer. The listing now keeps regular files only (links followed), `PrinterProfile::read` refuses anything else before opening it, and the read goes through a limit one byte past `MAX_PROFILE_BYTES`, so a file that grew after it was measured is refused rather than read whole. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 11 + Cargo.toml | 2 + crates/automation/Cargo.toml | 2 + crates/automation/src/dispatch.rs | 1 + crates/automation/src/lib.rs | 1 + crates/automation/src/print_colour.rs | 90 +++++ crates/automation/src/printing.rs | 33 +- crates/automation/src/tools.rs | 9 +- crates/automation/tests/it/automation.rs | 75 ++++ .../automation/tests/it/automation_roots.rs | 24 ++ crates/engine/Cargo.toml | 2 + crates/engine/src/print_raster.rs | 8 +- crates/engine/src/session/files.rs | 5 +- crates/engine/src/tests.rs | 59 ++- crates/print/Cargo.toml | 1 + crates/print/src/colour.rs | 338 ++++++++++++++++++ crates/print/src/lib.rs | 1 + crates/print/src/settings.rs | 9 +- .../ui-egui/src/workflows/print_ui/draft.rs | 6 +- parity/acrobat-features.toml | 12 + 20 files changed, 662 insertions(+), 27 deletions(-) create mode 100644 crates/automation/src/print_colour.rs create mode 100644 crates/print/src/colour.rs diff --git a/Cargo.lock b/Cargo.lock index 0e4c910427..e0968ff2c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1184,6 +1184,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-cms" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=60ede99985ef1e5df87a2aca66ed538eb23dec99#60ede99985ef1e5df87a2aca66ed538eb23dec99" +dependencies = [ + "rayon", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -4326,6 +4334,7 @@ name = "pdfcraft-automation" version = "0.21.1" dependencies = [ "base64 0.23.1", + "craft-cms", "flate2", "image", "pdfcraft-cos", @@ -4449,6 +4458,7 @@ name = "pdfcraft-engine" version = "0.21.1" dependencies = [ "chrono", + "craft-cms", "craft-docpath", "getrandom 0.4.3", "icu_properties", @@ -4664,6 +4674,7 @@ dependencies = [ name = "pdfcraft-print" version = "0.21.1" dependencies = [ + "craft-cms", "craft-print", "log", "pdfcraft-content", diff --git a/Cargo.toml b/Cargo.toml index 752f580475..18fa9ed37b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1 craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" } +# ICC transforms for app-managed print colour; at craft-print's commit, so one craft-libs checkout. +craft-cms = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index a825b1fe52..60a5d0fa06 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -28,6 +28,8 @@ default = ["mcp"] mcp = ["dep:base64"] [dev-dependencies] +# Synthetic printer profiles for the app-managed print colour tests. +craft-cms = { workspace = true } pdfcraft-fonts = { workspace = true } png = "0.18" flate2.workspace = true diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 4a91942fe4..8de3ff3c27 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -726,6 +726,7 @@ impl Automation { "redact_clear" => self.redact_clear(&a)?, "doc_hidden_info" => self.doc_hidden_info(&a)?, "printers" => self.printers()?, + "print_color_profiles" => self.print_color_profiles()?, "printer_options" => self.printer_options(&a)?, "printer_papers" => self.printer_papers(&a)?, "link_list" => self.link_list(&a)?, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7fa63d1a1d..5c49b25b23 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -30,6 +30,7 @@ pub mod mcp; mod measure; mod model3d; mod model_render; +mod print_colour; mod printing; mod reading; mod redact; diff --git a/crates/automation/src/print_colour.rs b/crates/automation/src/print_colour.rs new file mode 100644 index 0000000000..22279fbd8b --- /dev/null +++ b/crates/automation/src/print_colour.rs @@ -0,0 +1,90 @@ +//! App-managed print colour for `doc_print` (`color_management` app) and the installed printer +//! profiles it can name (`print_color_profiles`). + +use pdfcraft_engine::print::colour::{self, Intent, ManagedColour, PrinterProfile}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError, failed}; + +/// An invalid-arguments error saying `m`. +fn bad(m: impl Into) -> ToolError { + ToolError::InvalidArgs(m.into()) +} + +impl Automation { + /// `doc_print`'s colour handling: `None` for `printer` (the default), else the profile, intent + /// and black point compensation the pictures are converted with. + pub(crate) fn managed_colour(&self, a: &Args) -> Result> { + let app = match a.opt_str("color_management")?.unwrap_or("printer") { + "printer" => false, + "app" => true, + m => return Err(bad(format!("unknown color_management {m:?}: printer (the printer manages colour) or app"))), + }; + let asked = ["color_profile", "rendering_intent", "black_point_compensation"].into_iter().find(|k| a.get(k).is_some()); + if !app { + return match asked { + Some(k) => Err(bad(format!("{k} needs color_management app"))), + None => Ok(None), + }; + } + let name = a + .opt_str("color_profile")? + .ok_or_else(|| bad("color_management app needs color_profile: an ICC file or a name from print_color_profiles"))?; + let profile = self.printer_profile(name)?; + let intent = match a.opt_str("rendering_intent")? { + None => Intent::RelativeColorimetric, + Some(i) => { + Intent::parse(i).ok_or_else(|| bad(format!("unknown rendering_intent {i:?}: perceptual, relative, saturation or absolute")))? + } + }; + let black_point = a.opt_bool("black_point_compensation")?.unwrap_or(true); + Ok(Some(ManagedColour { profile, intent, black_point })) + } + + /// The profile `name` names: an ICC file, else an installed profile's name or file name. A + /// file is tried first so naming one doesn't read every installed profile. + fn printer_profile(&self, name: &str) -> Result { + let file = self.resolve_for(name, crate::file_access::Access::Read, true); + if let Ok(path) = &file + && path.is_file() + { + return PrinterProfile::read(path).map_err(bad); + } + let installed = match self.installed_profiles() { + Ok(list) => list, + // Confined without read authority: say why the file was refused, if it was. + Err(e) => return Err(file.err().unwrap_or(e)), + }; + if let Some(p) = colour::find_installed(&installed, name) { + return PrinterProfile::read(&p.path).map_err(failed); + } + file?; + Err(bad(format!("color_profile {name:?} is neither an installed profile (print_color_profiles) nor an ICC file"))) + } + + /// The profiles that can be named: the system's, or, when file access is confined, those in + /// the read root or `--root` (as `text_font_files` does), each file checked against it, links + /// followed, before it is read. + fn installed_profiles(&self) -> Result> { + let Some(roots) = &self.roots else { + let Some(root) = &self.root else { return Ok(colour::installed_profiles()) }; + let inside = |path: &std::path::Path| self.resolve(&path.to_string_lossy(), false).is_ok(); + return Ok(colour::installed_profiles_where(std::slice::from_ref(root), &inside)); + }; + let read = roots + .read_root() + .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed colour profiles"))?; + let folders = vec![self.resolve(&read.to_string_lossy(), false)?]; + let inside = |path: &std::path::Path| roots.check(&path.to_string_lossy(), crate::file_access::Access::Read).is_ok(); + Ok(colour::installed_profiles_where(&folders, &inside)) + } + + pub(crate) fn print_color_profiles(&self) -> Result { + let list: Vec = self + .installed_profiles()? + .into_iter() + .map(|p| json!({ "name": p.name, "path": p.path.to_string_lossy(), "color_space": if p.gray { "gray" } else { "rgb" } })) + .collect(); + Ok(json!({ "count": list.len(), "profiles": list })) + } +} diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 3d7a76865a..d099d4dc01 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -118,7 +118,7 @@ fn spool_job( title: String, duplex: Option, media: Option<&str>, - raster: Option, + raster: Option<&print::Raster>, sheets: &[print::Sheet], ) -> Result { let options = match a.get("options") { @@ -338,12 +338,19 @@ impl Automation { let (paper, media) = resolve_paper(a.opt_str("paper")?, resolved_printer.as_deref())?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. - let raster = if a.opt_bool("as_image")?.unwrap_or(false) { + // App-managed colour converts those pictures into the printer's profile, so it prints + // as image whether or not as_image is given. + let colour = self.managed_colour(a)?; + let as_image = match a.opt_bool("as_image")? { + Some(false) if colour.is_some() => return Err(bad("color_management app prints the pages as images: leave out as_image false")), + asked => asked.unwrap_or(false) || colour.is_some(), + }; + let raster = if as_image { let dpi = a.opt_num("dpi")?.unwrap_or(300.0); if !(72.0..=600.0).contains(&dpi) { return Err(bad("dpi must be between 72 and 600")); } - Some(print::Raster { dpi, grayscale: a.opt_bool("grayscale")?.unwrap_or(false) }) + Some(print::Raster { dpi, grayscale: a.opt_bool("grayscale")?.unwrap_or(false), colour }) } else { None }; @@ -367,7 +374,15 @@ impl Automation { (Some(p), None) => Destination::File(self.resolve(p, true)?), (None, Some(printer)) => { let actual_printer = resolved_printer.as_deref().unwrap_or(printer); - Destination::Printer(spool_job(a, actual_printer, self.doc(a)?.name.clone(), duplex, media.as_deref(), settings.raster, &laid_out)?) + Destination::Printer(spool_job( + a, + actual_printer, + self.doc(a)?.name.clone(), + duplex, + media.as_deref(), + settings.raster.as_ref(), + &laid_out, + )?) } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), }; @@ -379,6 +394,10 @@ impl Automation { }; let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() }); + if let Some(c) = settings.raster.as_ref().and_then(|r| r.colour.as_ref()) { + out["color_management"] = + json!({ "profile": c.profile.name(), "rendering_intent": c.intent.id(), "black_point_compensation": c.black_point }); + } if !scripts.is_empty() { out["will_print"] = crate::a11y::form_tools::js_output_json(&scripts); } @@ -542,10 +561,10 @@ mod tests { /// grayscale, which Microsoft Print to PDF keeps no setting for and the job was refused. #[test] fn grey_pictures_dont_ask_the_driver_for_grayscale() { - let grey = print::Raster { dpi: 300.0, grayscale: true }; - let colour = print::Raster { dpi: 300.0, grayscale: false }; + let grey = print::Raster { dpi: 300.0, grayscale: true, colour: None }; + let colour = print::Raster { dpi: 300.0, grayscale: false, colour: None }; let job = |args: Value, raster: Option| { - spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster, &[]).unwrap().grayscale + spool_job(&Args(&args), "Office", String::new(), None, Some("Letter"), raster.as_ref(), &[]).unwrap().grayscale }; assert!(job(json!({ "grayscale": true }), None), "vector pages: the driver makes them grey"); assert!(!job(json!({ "grayscale": true }), Some(grey)), "grey pictures: nothing for the driver to do"); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fa29d07634..9cecf1654c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -751,6 +751,9 @@ pub fn tools() -> Vec { .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t("printers", "List printers", "The printers the system's print spooler knows (CUPS on macOS and Linux), with the default marked.").ro().with(schema(json!({}), &[])), + t("print_color_profiles", "List printer colour profiles", "The RGB and grey ICC profiles installed on this computer that doc_print can convert the pages into (color_management app): name, path and color_space. Windows: the system colour folder; macOS: the ColorSync folders; Linux: the shared and per-user icc folders. CMYK profiles are left out: the printed pictures are RGB.") + .ro() + .with(schema(json!({}), &[])), t("printer_options", "List printer options", "A printer driver's own job options (CUPS: from its PPD), such as the paper tray, paper type or finishing: key, label, group, default and choices. Pass chosen values to doc_print as options. Empty on Windows, where the driver's preferences window holds them.") .ro() .with(schema(json!({ "printer": { "type": "string" } }), &["printer"])), @@ -764,7 +767,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, US #10 Envelope, a driver paper name from printer_papers, or dimensions \"x\"; when printer is given and paper is omitted, uses the printer's default paper if available; use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, US #10 Envelope, a driver paper name from printer_papers, or dimensions \"x\"; when printer is given and paper is omitted, uses the printer's default paper if available; use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS). color_management app (Acrobat's Color Handling, the app managing colours) prints the pages as images converted into color_profile with rendering_intent and black_point_compensation; turn the driver's own colour management off for that job, or the printer converts them again.", ) .with(schema( json!({ @@ -798,6 +801,10 @@ pub fn tools() -> Vec { "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, + "color_management": { "type": "string", "enum": ["printer", "app"], "description": "Who converts the colours: printer (default, the driver does) or app, which prints the pages as images (dpi, grayscale apply) converted from sRGB into color_profile." }, + "color_profile": { "type": "string", "description": "For color_management app: a name or file name from print_color_profiles, or the path of an RGB or grey ICC printer profile." }, + "rendering_intent": { "type": "string", "enum": ["perceptual", "relative", "saturation", "absolute"], "description": "For color_management app: how colours the printer can't reach are mapped (default relative colorimetric)." }, + "black_point_compensation": { "type": "boolean", "description": "For color_management app: map sRGB black onto the printer's darkest black, so shadows stay apart instead of clipping (default true; no effect with absolute)." }, }), &["doc"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index db139844a7..f749ed2953 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -12485,6 +12485,81 @@ fn doc_print_as_image() { } } +/// #6269: color_management app prints converted pictures and says how; the profile must be an +/// RGB or grey one, and the colour arguments need app. +#[test] +fn doc_print_with_app_managed_colour() { + let dir = workdir("print-colour"); + std::fs::write(dir.join("wide.icc"), craft_cms::Builtin::AdobeRgbCompat.profile().to_bytes().as_slice()).unwrap(); + std::fs::write(dir.join("press.icc"), craft_cms::Builtin::CoatedCmyk.profile().to_bytes().as_slice()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let args = |extra: Value| { + let mut v = json!({ "doc": doc, "pages": "1", "dpi": 72, "path": "managed.pdf" }); + v.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + v + }; + let r = ok(&mut a, "doc_print", args(json!({ "color_management": "app", "color_profile": "wide.icc", "rendering_intent": "perceptual" }))); + assert_eq!(r["color_management"]["rendering_intent"], "perceptual", "{r}"); + assert_eq!(r["color_management"]["black_point_compensation"], true, "{r}"); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("managed.pdf")).unwrap())).unwrap(); + let pictures = + out.scan_objects().filter(|(_, o)| matches!(&**o, pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image"))).count(); + assert_eq!(pictures, 1, "app-managed colour prints the page as a picture"); + assert!(ok(&mut a, "doc_print", args(json!({})))["color_management"].is_null(), "the printer manages colour by default"); + for (extra, says) in [ + (json!({ "color_management": "app", "color_profile": "press.icc" }), "RGB or grey"), + (json!({ "color_management": "app" }), "color_profile"), + (json!({ "color_management": "app", "color_profile": "missing.icc" }), "print_color_profiles"), + (json!({ "color_profile": "wide.icc" }), "color_management app"), + (json!({ "color_management": "app", "color_profile": "wide.icc", "rendering_intent": "vivid" }), "rendering_intent"), + (json!({ "color_management": "app", "color_profile": "wide.icc", "as_image": false }), "as_image"), + (json!({ "color_management": "driver" }), "color_management"), + ] { + match a.call("doc_print", &args(extra.clone())) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains(says), "{extra}: {m}"), + other => panic!("{extra}: {other:?}"), + } + } + let listed = ok(&mut a, "print_color_profiles", json!({})); + assert_eq!(listed["count"].as_u64(), listed["profiles"].as_array().map(|p| p.len() as u64), "{listed}"); + // Under a root the profiles listed are the root's RGB and grey ones. + assert_eq!(listed["count"], 1, "{listed}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Under `--root`, a profile link inside the root that leads out of it is neither listed nor +/// found by name. +#[test] +fn colour_profile_links_out_of_the_root_are_not_read() { + let dir = workdir("print-colour-link"); + let outside = dir.with_extension("outside"); + let _ = std::fs::remove_dir_all(&outside); + std::fs::create_dir_all(&outside).unwrap(); + let profile = craft_cms::Builtin::AdobeRgbCompat.profile().to_bytes(); + std::fs::write(dir.join("wide.icc"), profile.as_slice()).unwrap(); + std::fs::write(outside.join("other.icc"), profile.as_slice()).unwrap(); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(outside.join("other.icc"), dir.join("leak.icc")).is_ok(); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(outside.join("other.icc"), dir.join("leak.icc")).is_ok(); + #[cfg(not(any(unix, windows)))] + let linked = false; + // Windows needs Developer Mode or elevation to make links. + if !linked { + eprintln!("skipped: symbolic links can't be made here"); + return; + } + let mut a = auto(&dir); + let listed = ok(&mut a, "print_color_profiles", json!({})); + assert_eq!(listed["count"], 1, "only the profile inside the root: {listed}"); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let print = json!({ "doc": doc, "pages": "1", "dpi": 72, "path": "m.pdf", "color_management": "app", "color_profile": "leak" }); + assert!(a.call("doc_print", &print).is_err(), "a link out of the root isn't found by name"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&outside); +} + /// Without a root, `write_output` still writes through a staging file (#2166): the folder is /// made, nothing is left beside the file, and a folder at the path is refused. #[test] diff --git a/crates/automation/tests/it/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs index e65c582a73..ecc65c742c 100644 --- a/crates/automation/tests/it/automation_roots.rs +++ b/crates/automation/tests/it/automation_roots.rs @@ -200,6 +200,30 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ok(&mut server, "text_font_files", json!({"directories": [s(&base.join("in"))]})); } +/// The installed colour profiles are outside the read root: confined, `print_color_profiles` +/// lists and `doc_print` names only the profiles inside it, and without read authority none. +#[test] +fn colour_profiles_are_looked_for_inside_the_read_root() { + let (mut server, base) = confined("profiles", true, true); + let profile = craft_cms::Builtin::AdobeRgbCompat.profile().to_bytes(); + std::fs::write(base.join("in/wide.icc"), profile.as_slice()).unwrap(); + std::fs::write(base.join("outside/other.icc"), profile.as_slice()).unwrap(); + let listed = ok(&mut server, "print_color_profiles", json!({})); + let paths: Vec<&str> = listed["profiles"].as_array().unwrap().iter().filter_map(|p| p["path"].as_str()).collect(); + assert_eq!(paths.len(), 1, "{listed}"); + assert!(paths[0].ends_with("wide.icc"), "{listed}"); + ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); + let print = + |name: &str| json!({"doc": 1, "pages": "1", "dpi": 72, "path": s(&base.join("out/m.pdf")), "color_management": "app", "color_profile": name}); + ok(&mut server, "doc_print", print("wide")); + let (error, text) = call(&mut server, "doc_print", print("other")); + assert!(error, "a profile outside the read root isn't found by name: {text}"); + refused(&mut server, "doc_print", print(&s(&base.join("outside/other.icc"))), "outside the read root"); + + let (mut unread, _) = confined("profiles-unread", false, true); + refused(&mut unread, "print_color_profiles", json!({}), "read authority is absent"); +} + #[test] fn tools_that_write_a_path_are_held_to_the_write_root() { let (mut server, base) = confined("writes", true, true); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f5b924c921..4278dbb95f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -58,6 +58,8 @@ getrandom = "0.4" getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] +# Synthetic printer profiles for the app-managed print colour tests. +craft-cms = { workspace = true } pdfcraft-testkit = { workspace = true } # JBIG2 fixtures (`encode_jbig2_generic`). pdfcraft-filters = { workspace = true } diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index a6076d5b05..7a3bbc40d5 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -3,7 +3,8 @@ //! shading or transparency. The pages are rendered the way the viewer shows them (reading //! colours off, the print's own colours) with only what the print's content choice keeps, made //! into an image-only document, and that document is imposed on the paper like any other. The -//! print is no longer searchable text. +//! print is no longer searchable text. With app-managed colour the pictures are converted into +//! the printer's ICC profile ([`pdfcraft_print::colour`]). use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_create::ImageResolution; @@ -51,6 +52,11 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont } let mut rgba = r.rgba.to_vec(); over_white(&mut rgba, raster.grayscale); + // App-managed colour: the picture's values become the printer's, so it is placed as + // device colour the driver passes on unconverted. + if let Some(colour) = &raster.colour { + colour.convert(&mut rgba).map_err(|e| format!("page {}: {e}", page + 1))?; + } images.push((format!("page-{}.png", page + 1), encode_png(r.width, r.height, &rgba)?)); } let dpi = used.unwrap_or(dpi); diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 2f2774ed22..85a6d24532 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -236,8 +236,9 @@ impl Session { // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages // always go as pictures, at no more than the low resolution. let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), + dpi: settings.raster.as_ref().map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.as_ref().is_some_and(|r| r.grayscale), + colour: settings.raster.as_ref().and_then(|r| r.colour.clone()), }); // Print as image: the pages used become pictures first, then the picture document is // imposed in their place (its page k is the k-th distinct source page used). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 91b00df4cc..bf1a4ebd01 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1935,8 +1935,8 @@ fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); let width = f64::from(s.get(low).unwrap().info.pages[0].width); let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; - for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] { - let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap(); + for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false, colour: None })] { + let printed = s.print_pdf(low, &print::Settings { raster: raster.clone(), ..actual.clone() }).unwrap(); let mut s2 = Session::new(); let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap(); assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left"); @@ -3934,8 +3934,8 @@ fn printing_applies_the_print_auto_state() { } // Hidden in the panel, View entry (no Print recommendation) leaves the preview too. assert!(printed_blue[1] * 4 < printed_blue[0] * 3, "the preview follows the panel: {printed_blue:?} blue"); - for raster in [None, Some(print::Raster { dpi: 72.0, grayscale: false })] { - let bytes = Arc::new(s.print_pdf(id, &print::Settings { pages: vec![0], raster, ..Default::default() }).unwrap()); + for raster in [None, Some(print::Raster { dpi: 72.0, grayscale: false, colour: None })] { + let bytes = Arc::new(s.print_pdf(id, &print::Settings { pages: vec![0], raster: raster.clone(), ..Default::default() }).unwrap()); let mut out = Session::new(); let printed = out.open("print.pdf", None, bytes.clone(), None).unwrap(); let (red, green, blue) = colours(&render_page(&out, printed)); @@ -5839,7 +5839,7 @@ fn assert_signature_artwork_survives_trust(mut cos: pdfcraft_cos::Document, expe } let mut settings = print::Settings { pages: vec![0], paper: (200.0, 200.0), ..Default::default() }; let original_vector_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); - settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false }); + settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false, colour: None }); let original_raster_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); session.set_trusted_certificates(vec![anchor.clone()]); let doc = session.get(id).unwrap(); @@ -6204,7 +6204,7 @@ fn named_layer_states_reach_exports_and_both_print_modes() { assert!(text.contains("Signature valid") && !text.contains("Not Verified"), "{text:?}"); let mut settings = print::Settings { pages: vec![0], paper: (400.0, 300.0), ..Default::default() }; let (_, _, vector) = printed_pixels(s.print_pdf(id, &settings).unwrap()); - settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false }); + settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false, colour: None }); let (_, _, raster) = printed_pixels(s.print_pdf(id, &settings).unwrap()); for (mode, pixels) in [("vector", &vector), ("raster", &raster)] { assert!(pixels_near(pixels, VALID_GREEN) > 500, "{mode} print draws the valid state"); @@ -11319,7 +11319,7 @@ fn printing_as_image_sends_pictures_instead_of_pages() { let settings = print::Settings { pages: vec![1, 2, 1], layout: print::Layout::Size(print::SizeMode::Actual), - raster: Some(print::Raster { dpi: 100.0, grayscale: false }), + raster: Some(print::Raster { dpi: 100.0, grayscale: false, colour: None }), ..Default::default() }; let bytes = s.print_pdf(id, &settings).unwrap(); @@ -11341,7 +11341,8 @@ fn printing_as_image_sends_pictures_instead_of_pages() { assert!((w as f64 - page_size.0 * 100.0 / 72.0).abs() <= 1.0, "{w} px for {} pt", page_size.0); assert!(images.iter().all(|d| d.name(b"ColorSpace") == Some(b"DeviceRGB")), "{images:?}"); // Grayscale renders the pictures grey (the printer is not merely asked for monochrome). - let grey = s.print_pdf(id, &print::Settings { raster: Some(print::Raster { dpi: 72.0, grayscale: true }), ..settings.clone() }).unwrap(); + let grey = + s.print_pdf(id, &print::Settings { raster: Some(print::Raster { dpi: 72.0, grayscale: true, colour: None }), ..settings.clone() }).unwrap(); let mut s3 = Session::new(); let id3 = s3.open("grey.pdf", None, Arc::new(grey), None).unwrap(); let mut ex = crate::export::Exporter::new(s3.get(id3).unwrap()); @@ -11350,6 +11351,44 @@ fn printing_as_image_sends_pictures_instead_of_pages() { assert!(img.pixels().all(|p| p[0] == p[1] && p[1] == p[2]), "every pixel grey"); } +/// #6269: with app-managed colour the printed pictures carry the printer profile's values, not +/// sRGB's: a pure green page converted into a wider-gamut RGB profile gains red; without it the +/// pictures stay sRGB. +#[test] +fn app_managed_colour_converts_the_printed_pictures() { + let pdf = pdfcraft_testkit::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 100 100] /Contents 4 0 R >>") + .stream("", "0 1 0 rg 0 0 100 100 re f") + .build(); + let mut s = Session::new(); + let id = s.open("green.pdf", None, Arc::new(pdf), None).unwrap(); + let wide = craft_cms::Builtin::AdobeRgbCompat.profile().to_bytes(); + let colour = print::colour::ManagedColour { + profile: print::colour::PrinterProfile::from_bytes(&wide).unwrap(), + intent: print::colour::Intent::RelativeColorimetric, + black_point: true, + }; + let middle = |colour: Option| { + let settings = print::Settings { + pages: vec![0], + layout: print::Layout::Size(print::SizeMode::Actual), + raster: Some(print::Raster { dpi: 72.0, grayscale: false, colour }), + ..Default::default() + }; + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(s.print_pdf(id, &settings).unwrap()), None).unwrap(); + let png = crate::export::Exporter::new(out.get(printed).unwrap()).png(0, 72.0).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + img.get_pixel(img.width() / 2, img.height() / 2).0 + }; + let srgb = middle(None); + assert!(srgb[0] < 10 && srgb[1] > 245, "sRGB green: {srgb:?}"); + let converted = middle(Some(colour)); + assert!(converted[0] > 100 && converted[1] > 240, "the profile's values for sRGB green: {converted:?}"); +} + /// A one-page PDF: a JPEG logo (DCTDecode) at the top and a big text title below it. fn jpeg_cover_pdf(logo_y: i32) -> Vec { let mut jpeg = Vec::new(); @@ -13160,7 +13199,7 @@ fn buttons_and_combs_left_to_the_viewer_are_displayed() { } assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell"); // Print as Image: the page as a picture, at 72 dpi so it is the size of the page here. - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false, colour: None }; let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); let printed = render(Arc::new(pictures)); assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints"); @@ -13330,7 +13369,7 @@ fn need_appearances_buttons_are_displayed_from_their_captions() { let mut s = Session::new(); let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false, colour: None }; let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); let printed = render(Arc::new(pictures)); for (page, how) in [(&shown, "shown"), (&printed, "printed")] { diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 9d349b6568..bec4e283a7 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-cms = { workspace = true } craft-print = { workspace = true } log = { workspace = true } pdfcraft-cos = { workspace = true } diff --git a/crates/print/src/colour.rs b/crates/print/src/colour.rs new file mode 100644 index 0000000000..5f13de3352 --- /dev/null +++ b/crates/print/src/colour.rs @@ -0,0 +1,338 @@ +//! App-managed colour for printing (Acrobat's Color Handling ▸ "app manages colours"): printed +//! pages, rendered in sRGB, are converted into the printer's ICC profile with a rendering intent +//! and optional black point compensation before the driver sees them. The default leaves colour +//! to the printer, as before. +//! +//! The pages go as converted pictures, so the driver's own colour management should be off for +//! the job; otherwise the printer converts them a second time. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub use craft_cms::Intent; +use craft_cms::{Builtin, ColorSpace, Profile, TransformOptions}; + +/// The largest profile read from disk. Printer profiles with large tables are a few megabytes. +pub const MAX_PROFILE_BYTES: u64 = 32 << 20; + +/// How many files the installed-profile listing looks at, at most. +const MAX_LISTED: usize = 2000; + +/// An ICC profile pages can be printed into: an RGB or grey profile that can be a destination. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterProfile { + profile: Arc, + name: String, +} + +impl PrinterProfile { + /// `bytes` as a printer profile. Refused when they are not an ICC profile, when it has no + /// path from the PCS to the device, or when its device space is neither RGB nor grey (the + /// pictures a print carries are RGB). + pub fn from_bytes(bytes: &[u8]) -> Result { + let profile = Profile::parse(bytes).map_err(|e| format!("not a usable ICC profile: {e}"))?; + if !matches!(profile.color_space, ColorSpace::Rgb | ColorSpace::Gray) { + return Err(format!("a {:?} profile can't be printed into: choose an RGB or grey printer profile", profile.color_space)); + } + if !profile.can_be_destination() { + return Err("the profile only describes a source: it has no table to convert colours into it".into()); + } + let name = profile.description.trim().to_string(); + Ok(PrinterProfile { profile: Arc::new(profile), name }) + } + + /// The profile in the file at `path`, at most [`MAX_PROFILE_BYTES`] long. Anything but a + /// regular file (links followed) is refused before it is opened: opening a FIFO or a device + /// can wait forever. + pub fn read(path: &Path) -> Result { + let at = |e: String| format!("{}: {e}", path.display()); + let meta = std::fs::metadata(path).map_err(|e| at(e.to_string()))?; + if !meta.is_file() { + return Err(at("not a regular file".into())); + } + if meta.len() > MAX_PROFILE_BYTES { + return Err(at(too_large())); + } + let file = std::fs::File::open(path).map_err(|e| at(e.to_string()))?; + let bytes = read_limited(file, MAX_PROFILE_BYTES).map_err(at)?; + PrinterProfile::from_bytes(&bytes).map_err(at) + } + + /// The profile's description (its `desc` tag), possibly empty. + pub fn name(&self) -> &str { + &self.name + } + + /// The ICC bytes, as read. + pub fn bytes(&self) -> Arc> { + self.profile.to_bytes() + } + + /// Whether the printer's device space is grey. + pub fn is_gray(&self) -> bool { + self.profile.color_space == ColorSpace::Gray + } +} + +/// At most `limit` bytes of `source`: a file that grew after it was measured is refused, not read +/// whole. +fn read_limited(source: impl std::io::Read, limit: u64) -> Result, String> { + use std::io::Read as _; + let mut bytes = Vec::new(); + source.take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + if bytes.len() as u64 > limit { + return Err(too_large()); + } + Ok(bytes) +} + +fn too_large() -> String { + format!("larger than an ICC profile can be here ({} MB)", MAX_PROFILE_BYTES >> 20) +} + +/// App-managed colour: the profile the pages are converted into, how, and whether black point +/// compensation maps the darkest sRGB black onto the printer's darkest black. +#[derive(Clone, Debug, PartialEq)] +pub struct ManagedColour { + pub profile: PrinterProfile, + pub intent: Intent, + pub black_point: bool, +} + +impl ManagedColour { + /// Converts opaque sRGB pixels (RGBA, alpha ignored and kept) into the printer profile in + /// place. A grey profile's value is written to all three channels. + pub fn convert(&self, rgba: &mut [u8]) -> Result<(), String> { + let opts = TransformOptions { intent: self.intent, bpc: self.black_point, ..TransformOptions::default() }; + let t = craft_cms::cached(Builtin::Srgb.profile(), &self.profile.profile, opts) + .map_err(|e| format!("can't convert into the printer profile: {e}"))?; + let source = rgba.to_vec(); + if self.profile.is_gray() { + let mut grey = vec![0u8; source.len() / 4]; + t.convert_u8(&source, 4, &mut grey, 1, false).map_err(|e| format!("can't convert into the printer profile: {e}"))?; + for (px, g) in rgba.as_chunks_mut::<4>().0.iter_mut().zip(grey) { + px[..3].fill(g); + } + Ok(()) + } else { + t.convert_u8(&source, 4, rgba, 4, true).map_err(|e| format!("can't convert into the printer profile: {e}")) + } + } +} + +/// A profile installed on this computer that pages can be printed into. +#[derive(Clone, Debug, PartialEq)] +pub struct InstalledProfile { + pub path: PathBuf, + /// Its description, else its file name. + pub name: String, + pub gray: bool, +} + +/// The folders the system keeps colour profiles in, those that exist. +pub fn profile_folders() -> Vec { + let mut dirs: Vec = Vec::new(); + if cfg!(windows) { + let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + dirs.push(root.join("System32").join("spool").join("drivers").join("color")); + } else if cfg!(target_os = "macos") { + dirs.push("/System/Library/ColorSync/Profiles".into()); + dirs.push("/Library/ColorSync/Profiles".into()); + if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(home).join("Library/ColorSync/Profiles")); + } + } else { + dirs.push("/usr/share/color/icc".into()); + dirs.push("/usr/local/share/color/icc".into()); + if let Some(home) = std::env::var_os("HOME") { + dirs.push(PathBuf::from(&home).join(".local/share/color/icc")); + dirs.push(PathBuf::from(home).join(".color/icc")); + } + } + dirs.retain(|d| d.is_dir()); + dirs +} + +/// The RGB and grey profiles in `folders` and their immediate subfolders that pages can be +/// printed into, sorted by name. Files that aren't such a profile are skipped. +pub fn installed_profiles_in(folders: &[PathBuf]) -> Vec { + installed_profiles_where(folders, &|_| true) +} + +/// [`installed_profiles_in`], reading only the files `allow` passes (a caller confined to some +/// folders checks each file, links followed, before it is opened). +pub fn installed_profiles_where(folders: &[PathBuf], allow: &dyn Fn(&Path) -> bool) -> Vec { + let mut files: Vec = Vec::new(); + for dir in folders { + collect(dir, 1, &mut files); + } + let mut out: Vec = files + .into_iter() + .filter(|path| allow(path)) + .filter_map(|path| { + let p = PrinterProfile::read(&path).ok()?; + let name = if p.name().is_empty() { path.file_name()?.to_string_lossy().into_owned() } else { p.name().to_string() }; + Some(InstalledProfile { gray: p.is_gray(), name, path }) + }) + .collect(); + out.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.path.cmp(&b.path))); + out +} + +/// The profiles the system has installed (see [`profile_folders`]). +pub fn installed_profiles() -> Vec { + installed_profiles_in(&profile_folders()) +} + +/// The `.icc` and `.icm` regular files in `dir` (links followed), descending `depth` more levels, +/// up to [`MAX_LISTED`]. A FIFO or device with such a name is skipped: reading it can block. +fn collect(dir: &Path, depth: u32, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + if files.len() >= MAX_LISTED { + return; + } + let path = entry.path(); + let Ok(kind) = entry.file_type() else { continue }; + if kind.is_dir() { + if depth > 0 { + collect(&path, depth - 1, files); + } + } else if path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("icc") || e.eq_ignore_ascii_case("icm")) + && std::fs::metadata(&path).is_ok_and(|m| m.is_file()) + { + files.push(path); + } + } +} + +/// The installed profile `name` names: its description or its file name, ignoring case. +pub fn find_installed<'a>(profiles: &'a [InstalledProfile], name: &str) -> Option<&'a InstalledProfile> { + let name = name.trim(); + profiles.iter().find(|p| p.name.eq_ignore_ascii_case(name)).or_else(|| { + profiles.iter().find(|p| { + let file = p.path.file_name().map(|f| f.to_string_lossy()).unwrap_or_default(); + let stem = p.path.file_stem().map(|f| f.to_string_lossy()).unwrap_or_default(); + file.eq_ignore_ascii_case(name) || stem.eq_ignore_ascii_case(name) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use craft_cms::Curve; + + /// A source longer than the limit is refused after reading one byte past it, not read whole. + #[test] + fn a_profile_read_stops_one_byte_past_the_limit() { + let source = [7u8; 100]; + assert_eq!(read_limited(&source[..], 100).map(|b| b.len()), Ok(100)); + let mut rest = &source[..]; + assert!(read_limited(&mut rest, 10).is_err()); + assert_eq!(rest.len(), 89, "read the limit and one byte more"); + } + + /// A FIFO named like a profile is neither listed nor opened: opening it for reading would wait + /// for a writer that never comes. + #[cfg(unix)] + #[test] + fn a_fifo_named_like_a_profile_is_skipped() { + let dir = std::env::temp_dir().join(format!("pdfcraft-print-fifo-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = dir.join("printer.icc"); + let _ = std::fs::remove_file(&fifo); + let made = std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()); + if !made { + eprintln!("mkfifo is unavailable; skipped"); + return; + } + // On a thread, so that a read which blocks fails the test instead of hanging it. + let (tx, rx) = std::sync::mpsc::channel(); + let (folders, path) = (vec![dir.clone()], fifo.clone()); + std::thread::spawn(move || { + let _ = tx.send((installed_profiles_in(&folders).len(), PrinterProfile::read(&path).err())); + }); + let outcome = rx.recv_timeout(std::time::Duration::from_secs(10)); + let _ = std::fs::remove_dir_all(&dir); + let (listed, refused) = outcome.expect("reading the FIFO blocked"); + assert_eq!(listed, 0, "the listing skipped the FIFO"); + assert!(refused.is_some_and(|e| e.contains("not a regular file")), "the FIFO was refused before it was opened"); + } + + /// A grey printer profile whose darkest black is a light charcoal (8% luminance, L* 34): + /// what a matte paper or a worn toner gives. Synthetic, from the built-in sGray. + fn lifted_black_grey() -> Vec { + let mut p = Builtin::SGray.profile().clone(); + p.gray_trc = Some(Curve::Table((0..=255).map(|i| 0.08 + 0.92 * (i as f32 / 255.0)).collect())); + p.black_point = None; + p.description = "Lifted black grey".into(); + p.with_encoded_bytes().to_bytes().to_vec() + } + + fn convert(profile: &[u8], bpc: bool, rgba: &[u8]) -> Vec { + let colour = ManagedColour { profile: PrinterProfile::from_bytes(profile).unwrap(), intent: Intent::RelativeColorimetric, black_point: bpc }; + let mut out = rgba.to_vec(); + colour.convert(&mut out).unwrap(); + out + } + + /// Dark sRGB greys below the printer's black: without compensation they all clip to its + /// black; with it they stay apart and in order (test the shadows, not only mid grey). + #[test] + fn black_point_compensation_keeps_dark_shadows_apart() { + let profile = lifted_black_grey(); + let darks = [20u8, 40, 60].iter().flat_map(|&v| [v, v, v, 255]).collect::>(); + let clipped = convert(&profile, false, &darks); + assert_eq!((clipped[0], clipped[4], clipped[8]), (0, 0, 0), "{clipped:?}"); + let kept = convert(&profile, true, &darks); + assert!(kept[0] < kept[4] && kept[4] < kept[8], "{kept:?}"); + assert!(kept.as_chunks::<4>().0.iter().all(|&[r, g, b, a]| r == g && g == b && a == 255), "grey written to R, G and B: {kept:?}"); + } + + /// An RGB printer profile changes the values (sRGB's green is not the profile's green) and + /// keeps alpha. + #[test] + fn rgb_profiles_convert_the_values_and_keep_alpha() { + let wide = Builtin::AdobeRgbCompat.profile().to_bytes().to_vec(); + let out = convert(&wide, true, &[0, 255, 0, 128, 255, 255, 255, 255]); + assert!(out[0] > 100 && out[1] > 240 && out[2] > 20, "sRGB green inside a wider gamut: {out:?}"); + assert_eq!(out[3], 128); + assert!(out[4..7].iter().all(|&v| v >= 254), "white stays white: {out:?}"); + let same = convert(&Builtin::Srgb.profile().to_bytes(), true, &[10, 120, 230, 255]); + assert!(same.iter().zip([10u8, 120, 230, 255]).all(|(a, b)| a.abs_diff(b) <= 1), "sRGB into sRGB: {same:?}"); + } + + #[test] + fn cmyk_and_other_bytes_are_refused() { + let cmyk = Builtin::CoatedCmyk.profile().to_bytes(); + assert!(PrinterProfile::from_bytes(&cmyk).is_err_and(|e| e.contains("RGB or grey"))); + assert!(PrinterProfile::from_bytes(b"not a profile").is_err()); + assert!(PrinterProfile::from_bytes(&[]).is_err()); + } + + /// The listing finds RGB and grey profiles one folder deep, by description, and skips CMYK + /// profiles, other files and broken ones; a name finds one by description or file name. + #[test] + fn installed_profiles_are_rgb_and_grey_ones() { + let dir = std::env::temp_dir().join(format!("pdfcraft-print-profiles-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("vendor").join("deeper")).unwrap(); + std::fs::write(dir.join("office.ICC"), Builtin::Srgb.profile().to_bytes().as_slice()).unwrap(); + std::fs::write(dir.join("vendor").join("matte.icm"), lifted_black_grey()).unwrap(); + std::fs::write(dir.join("vendor").join("deeper").join("hidden.icc"), Builtin::Srgb.profile().to_bytes().as_slice()).unwrap(); + std::fs::write(dir.join("press.icc"), Builtin::CoatedCmyk.profile().to_bytes().as_slice()).unwrap(); + std::fs::write(dir.join("broken.icc"), b"acsp?").unwrap(); + std::fs::write(dir.join("notes.txt"), b"hello").unwrap(); + let found = installed_profiles_in(std::slice::from_ref(&dir)); + let names: Vec<(&str, bool)> = found.iter().map(|p| (p.name.as_str(), p.gray)).collect(); + assert_eq!(names.len(), 2, "{found:?}"); + assert!(names.contains(&("Lifted black grey", true)), "{names:?}"); + assert!(found.iter().any(|p| !p.gray && p.path.ends_with("office.ICC")), "{found:?}"); + assert!(find_installed(&found, "lifted BLACK grey").is_some_and(|p| p.gray)); + assert!(find_installed(&found, "matte").is_some_and(|p| p.gray), "by file stem"); + assert!(find_installed(&found, "office.icc").is_some_and(|p| !p.gray), "by file name"); + assert!(find_installed(&found, "press").is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 5707a42012..815a8351d3 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -20,6 +20,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +pub mod colour; mod font_names; pub mod range; pub mod spool; diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 50657b328a..49d90c0308 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -186,17 +186,20 @@ pub struct Settings { } /// How pages are rendered when printing as image. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Raster { /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). pub dpi: f64, /// Render the pictures in grey, so the print-ready PDF itself is grayscale. pub grayscale: bool, + /// App-managed colour: the pictures are converted into the printer's profile. `None` leaves + /// colour to the printer. + pub colour: Option, } impl Default for Raster { fn default() -> Self { - Raster { dpi: 300.0, grayscale: false } + Raster { dpi: 300.0, grayscale: false, colour: None } } } @@ -204,7 +207,7 @@ impl Default for Raster { /// Not when the pages go as grey pictures (`raster` with [`Raster::grayscale`]): they are grey /// already, so a driver that keeps no grayscale setting (Microsoft Print to PDF, many inkjets) /// still prints them instead of the job being refused (#5806). -pub fn driver_needs_grayscale(grayscale: bool, raster: Option) -> bool { +pub fn driver_needs_grayscale(grayscale: bool, raster: Option<&Raster>) -> bool { grayscale && !raster.is_some_and(|r| r.grayscale) } diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 2b661426a6..02dbb97856 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -441,7 +441,7 @@ impl PrintDraft { m => m, }); let paper = self.paper_size(); - let raster = self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }); + let raster = self.raster(); // A selected area prints alone, its one page sized like a page of its own: Size is its // only handling (the dialog offers no other while it is chosen). if self.which == Which::Area { @@ -509,7 +509,7 @@ impl PrintDraft { /// Print as image: pictures at the chosen resolution, grey when grayscale is asked for. fn raster(&self) -> Option { - self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale }) + self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale, colour: None }) } /// Whether multiple pages per sheet should be cut and stacked into sorted piles. @@ -556,7 +556,7 @@ impl PrintDraft { self.chosen_duplex() }, scaling: spool::Scaling::Exact, - grayscale: print::driver_needs_grayscale(self.grayscale, self.raster()), + grayscale: print::driver_needs_grayscale(self.grayscale, self.raster().as_ref()), // Set when the copy sent is made grey (`spooler_copy`, #5806). grey_pages: false, title: title.to_string(), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0cdbc3e856..c721ce18e9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -8641,6 +8641,18 @@ tools = [] evidence = [] notes = "" +[[feature]] +id = "print.color-handling" +title = "Print colour handling (app-managed colours, printer profile)" +area = "L" +tier = "P1" +milestone = "M11" +status = "partial" +commands = [] +tools = ["doc_print", "print_color_profiles"] +evidence = ["crates/print/src/colour.rs::black_point_compensation_keeps_dark_shadows_apart", "crates/print/src/colour.rs::rgb_profiles_convert_the_values_and_keep_alpha", "crates/print/src/colour.rs::installed_profiles_are_rgb_and_grey_ones", "crates/engine/src/tests.rs::app_managed_colour_converts_the_printed_pictures", "crates/automation/tests/it/automation.rs::doc_print_with_app_managed_colour"] +notes = "doc_print color_management app (#6269): the pages print as images (dpi, grayscale apply), converted from sRGB into an RGB or grey ICC printer profile (a file, or a name from print_color_profiles: the system's colour folders on Windows, macOS and Linux) with a rendering intent and black point compensation (default on), through craft-cms; the default leaves colour to the printer as before. The driver's own colour management should be off for such a job. Still missing against Acrobat: the Print dialog's Color Handling choice, CMYK printer profiles, vector pages converted without rendering them, and the document's output intent as the source of DeviceCMYK colours." + [[feature]] id = "print.advanced-print-separations" title = "Print separations and in-RIP separations" From 72c607fc47a60fb44828be5db0f931a9800a1549 Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 11:57:26 -0500 Subject: [PATCH 18/21] fix(render): an RGB colour in an isolated DeviceCMYK group blends in CMYK, as its ink (#7031) * fix(render): an RGB colour in an isolated DeviceCMYK group blends in CMYK, as its ink Part of #4887. An isolated DeviceCMYK group blended in CMYK only when every colour in it had process tints (DeviceCMYK, DeviceGray); one RGB fill sent the whole group back to RGB blending. Vendored hayro-interpret patch (104): Color::ink_tints gives DeviceRGB, CalRGB, Lab and three-component ICCBased colours the ink that draws back to them through the bundled CMYK profile (new cmyk_inverse module: black 0.4*k0^2 + 0.6*k0^8 with k0 = 1 - max(r, g, b), matched to Acrobat on synthetic pages; then cyan, magenta and yellow by at most 12 Newton steps against the profile; one-entry per-thread cache). ICCBased CMYK still has no tints. Test an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink: 50% grey under Difference with black within 10 levels of Acrobat's 137 112 106; in-gamut colours draw as themselves; red within 8 of Acrobat's 238 32 37. With the additive arms reverted the grey draws 93 97 96 and it fails. #5228's control now uses an RGB image, which still has no tints. Co-Authored-By: Claude Opus 5.5 * fix(render): an RGB colour with a non-finite component has no CMYK ink `to_rgba` turns a non-finite component into some finite colour, so the inverse could not see it and gave that colour's ink. `ink_from_srgb` now declines such a colour and the group draws it as before. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- .../src/raster/tests/isolated_cmyk_groups.rs | 49 +++++++- vendor/README.md | 2 +- vendor/hayro-interpret/src/cmyk_inverse.rs | 118 ++++++++++++++++++ vendor/hayro-interpret/src/color.rs | 31 ++++- vendor/hayro-interpret/src/lib.rs | 1 + vendor/hayro/src/renderer.rs | 4 +- 6 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 vendor/hayro-interpret/src/cmyk_inverse.rs diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index aa15019277..65b44ec93e 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -88,11 +88,12 @@ endstream endobj assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}"); } - // A group whose contents have no ink tints (here the second element is RGB) is - // composited in RGB exactly as before: the same as a group blending in DeviceRGB. - let rgb = |_: [f32; 4]| "1 0 0 rg".to_string(); - let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb); - let rgb_group = render("/CS /DeviceRGB /I true", &rgb); + // A group whose contents have no ink tints (here the second element draws an RGB image + // over the page) is composited in RGB exactly as before: the same as a group blending + // in DeviceRGB. + let image = |_: [f32; 4]| "q 370 0 0 60 0 0 cm BI /W 1 /H 1 /CS /DeviceRGB /BPC 8 /F /AHx ID ff0000> EI Q".to_string(); + let cmyk_group = render("/CS /DeviceCMYK /I true", &image); + let rgb_group = render("/CS /DeviceRGB /I true", &image); assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before"); } @@ -247,6 +248,32 @@ trailer << /Root 1 0 R >> p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec() } +/// An RGB colour painted in an isolated `DeviceCMYK` group is blended as the process ink that +/// draws back to it through the CMYK profile (hayro-interpret patch (104)), instead of +/// sending the whole group back to RGB blending. +/// +/// Under `/BM /Difference` with black ink, a colour's cyan, magenta and yellow are +/// complemented and its black kept (ISO 32000-2 §11.3.4), so 50% grey, mostly cyan, magenta +/// and yellow ink, comes out a warm grey: neither the neutral grey of RGB blending nor the +/// near-black of a grey made from black ink alone. Acrobat draws the same blend on a CMYK page +/// as (137, 112, 106). Painted without a blend, a colour inside the profile's gamut draws as +/// itself, and pure red is clipped to the profile's red, as in Acrobat. +#[test] +fn an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink() { + let resources = "/ExtGState << /D << /BM /Difference >> >>"; + let pixel = |group: &str| cmyk_group_pixel([100, 100], "/G Do", [20, 20, 80, 80], group, resources, (50, 50)); + let near = |a: &[u8], b: [u8; 3], within: u8| a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= within); + + let blended = pixel("0.5 0.5 0.5 rg 20 20 60 60 re f /D gs 0 0 0 1 k 20 20 60 60 re f"); + assert!(near(&blended, [137, 112, 106], 10), "grey under Difference with black: {blended:?}"); + + // (colour, expected pixel, how far from it) + for (rgb, expected, within) in [("0.8 0.4 0.2", [204, 102, 51], 3), ("0.3 0.7 0.4", [77, 179, 102], 3), ("1 0 0", [238, 32, 37], 8)] { + let drawn = pixel(&format!("{rgb} rg 20 20 60 60 re f")); + assert!(near(&drawn, expected, within), "{rgb} drew as {drawn:?}, expected {expected:?}"); + } +} + /// From the review of #5228: a shading pattern painted before the group left its pattern /// transform on the context, and the finished group was drawn through it, so the cyan half /// of the group showed magenta. The group must look the same whether or not a pattern was @@ -325,3 +352,15 @@ trailer << /Root 1 0 R >> assert!(rgb[1] > 120 && rgb[0] < 60 && rgb[2] < 120, "blended in CMYK, green: {rgb:?}"); } } + +/// An RGB colour with a non-finite component has no tints: converting it to RGB first would +/// give some finite colour that is not its ink. +#[test] +fn an_rgb_colour_with_a_non_finite_component_has_no_tints() { + use hayro::hayro_interpret::color::AlphaColor; + use hayro::hayro_interpret::color::Color; + let rgb = |c: [f32; 3]| Color::from_rgba(AlphaColor::new([c[0], c[1], c[2], 1.0])).ink_tints(); + assert!(rgb([0.5, 0.5, 0.5]).is_some()); + assert_eq!(rgb([f32::NAN, 0.5, 0.5]), None); + assert_eq!(rgb([0.5, f32::INFINITY, 0.5]), None); +} diff --git a/vendor/README.md b/vendor/README.md index 558dbbbc12..cfe8c512ff 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -55,7 +55,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; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (61) stroke geometry by ISO 32000-2 §8.5.3.2 and §8.4.3: a degenerate subpath (a single-point closed path, or points that all coincide) is stroked as a dot of the line width with round caps and not at all with butt or projecting square caps (upstream nudged a zero-length `l` so square caps drew a square, and `x y m h` lost its `h` because kurbo's `is_empty` counts only segments); a segment after `h` begins a new subpath with a `MoveTo` to the point `h` reached (§8.5.2.1, Table 58), so `h` then an `l` back to that point is a degenerate subpath of its own; a dash array that is all zero or has a negative or non-finite entry is kept as `[]`, a solid line (`[0 0]` became hundredth-of-a-point dashes, a faint half-covered line); a negative or non-finite line width (`w`, `/LW`) is kept as 0, so no device sees one below zero; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (65) a fill or clip whose path has no width or no height (but not both) marks the device pixels the line touches (ISO 32000-2 §10.7.4: "A zero-width or zero-height rectangle paints a line 1 pixel wide", and a clip is "the set of pixels that would be included by a fill operation"): whole pixels when the line stays within one device row or column, a band one device pixel wide when a rotated or skewed CTM carries it across rows and columns, built per subpath so the gap between two subpaths on one line stays unpainted (`degenerate_fill_area`). A zero-height clip such as `10 10 100 0 re W n` hid everything painted after it, and the fill fell back to a thinnest-line stroke that half-covered two rows on a pixel boundary. A point paints as before, and a path with area, however thin, is filled as the shape it is (the thinnest-line fallback took a side under 1/256 unit as zero, so a thin rectangle that the CTM scales up to whole pixels was drawn as a hairline across its bounding box); (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; so is an image whose JBIG2 soft mask or `/Mask` one of the decoder's limits refuses, or PdfCraft's bound on the pixels of an image (`image_size_ok`), where a mask the file damaged still leaves its image whole (#6722), and an image drawn on its own, a stencil mask included, that a limit refuses is still a failed image (`ImageXObject::masks_picture`): `decode_raster` leaves the image out when its mask reported `ContentTruncated`, decoded or kept by the cache (91), and a size or count the file damaged can read as past a limit and is taken for one, the safe side; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read; (75) a soft hyphen (U+00AD) that a CFF substitute for a CID font has no glyph for draws its hyphen, as Annex D.2 reads WinAnsi 0xAD in (30), instead of the CID taken as a glyph index (#5888); (76) a CID of an Arial the PDF doesn't embed (`CIDFontType2`, not of an Adobe character collection; `ArialMT`, `Arial-BoldMT`, `Arial,Bold` and the other two faces, not Arial Narrow, Black or Unicode MS) that no Unicode route draws names, through its `/CIDToGIDMap`, the Arial glyph of the standard Macintosh order (read-fonts' `post::DEFAULT_GLYPH_NAMES`, its first 258 glyphs), and a CFF substitute draws its glyph of that name (.notdef when it has none) instead of the CID taken as its own glyph index (#5889); (77) a standard 14 font drawn with its bundled stand-in (`StandardFontBlob::Cff`) reads each glyph's own advance from its charstring (`CffFontBlob::advance_width`), so a stand-in glyph wider or narrower than the standard metrics is fitted to them as the OpenType stand-ins already were (ISO 32000-2 §9.6.2.2): with no advance the outline was drawn as it is, and the stand-ins' € (WinAnsiEncoding code 128, Annex D Table D.2), 667 to 722 units wide against the metrics' 556 (Helvetica) and 500 (Times), overlapped the next glyph. Symbol and ZapfDingbats (Annex D.5, D.6) are fitted the same way, the check mark `4` a check box draws for its `/MK /CA` caption among them; (78) `TilingPattern::instance` numbers each setting of a tiling pattern (`scn`/`SCN`), its copies keeping the number, and `TilingPattern::is_coloured` reports `/PaintType 1`, so a renderer can draw a coloured cell once for every fill of one setting (hayro patch (30)); (79) text extraction from a symbolic embedded TrueType font with no `/Encoding` (and no `/ToUnicode`) reads a code through the `post` table name of the glyph its `cmap` selects (`EmbeddedKind::symbolic_text`), when that name has a Unicode value by the glyph-name rules of `glyph_name_text`, which are the Adobe Glyph List Specification's (ISO 32000-2 §9.10.2) and the editor's in pdfcraft-fonts (a `.suffix` is dropped, `_` separates the characters of a ligature, `uni` with one or more groups of exactly four uppercase hex digits and `u` with four to six are code points; lowercase digits, other lengths and surrogate or out-of-range values name nothing, so `uni20ac` reads as WinAnsi), instead of reading the code as WinAnsi: FrameMaker files distilled with Distiller 3 number their codes in the Macintosh glyph order, 29 below ASCII, so `Solid` was extracted, edited and exported to Word as `6ROLG` (#3962). Codes with no glyph, or whose glyph has no such name, still read as WinAnsi; glyph selection is unchanged; (80) an `/Indexed` colour space that breaks ISO 32000-2 §8.6.6.3 is drawn as most readers draw it: a lookup table shorter than m × (hival + 1) bytes keeps the entries it holds in full and an index past them is drawn black after conversion, in any base space (zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab); a `hival` past 255 is taken as 255, and a real `hival` is truncated toward zero. An image drawn so reports `InterpreterWarning::MalformedIndexedImage`, surfaced as `RenderWarning::MalformedIndexedImage`, and a fill or stroke colour in such a space reports `ColorFallback`. An image whose lookup is missing, not a string or a stream, or a stream that does not decode (or decodes to nothing although it holds data) is not drawn, with the same warning; a colour set in such a space keeps the upstream DeviceGray fallback. Upstream treated all of these as an unreadable space and fell back to DeviceGray, painting the indices near-black, even ones whose entries were present. A `hival` below zero or not a number has no reading: the space falls back as upstream, and an image in it reports the warning. A lookup string given by an indirect reference is read (upstream consumed the reference trying it as a stream and found no table). The warning is taken from the space as built, so no table is read twice: inline images naming the space through a resource read its table once however many there are (`InterpreterCache::indexed_lookup_reads`); (82) a soft mask (or `/Mask` image) larger than its image and more than twice as large on a side as the device pixels its image is drawn at is averaged down to that size, rounded up to a power of two, in `ImageXObject::decoded_soft_mask`, and cached there (`DecodedImageKey::shrunk_to`, with the image's raster keyed by the same rounded size); every size is made from one cached intermediate mask at most 4096 on a side, so the full mask is decoded once however many sizes it is drawn at: it bypassed the decoded-image cache and was decoded, copied and staged at full size each time it was drawn. A 2 × 2 image with a 34,862 × 4,332 mask drawn 40 times on one slide (pdf.js `issue16263.pdf`) took about 3.6 s a draw and minutes a page; it now decodes the mask once and draws the page in about 2.5 s. A mask no larger than twice its drawn size keeps its resolution, which the renderer filters as before; `InterpreterCache::decoded_mask_decodes` counts the masks among the decodes; (83) an embedded TrueType program whose `head.indexToLocFormat` holds neither 0 nor 1 is read with the `loca` format its table's length gives (`with_loca_format_fixed`): the glyph reader took anything but 1 as short offsets, so a font marked 256 had its long offsets misread and drew boxes (pdf.js `issue2537r.pdf`); (84) a Type 0 font of an Adobe character collection (Japan1, GB1, CNS1, Korea1) reads a code its `/ToUnicode` has nothing for, or every code when it has none, through the collection's UCS2 CMap, code to CID to Unicode, embedded or not (ISO 32000-2 §9.10.2; `Type0Font::ucs2`, parsed the first time a code needs it): only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts are embedded without a `/ToUnicode`, or with one that maps nothing, had no text to select, find or copy; (85) a Type 0 font's `/ToUnicode` written as a CID CMap (`begincidchar` / `begincidrange`, destinations as numbers, as Word writes it) gives each number as the code's Unicode value for text, as glyph lookup (`map_via_unicode`) already read it and as pdftotext and PDFium extract it; ISO 32000-2 §9.10.3 has `beginbfchar` / `beginbfrange` there, and such text extracted nothing (pdf.js `issue9367`); (86) form fields' widgets are drawn after the page's other annotations, each group in `/Annots` order (`interpret_page`), as MuPDF and PDFium draw them; ISO 32000-2 doesn't order the two: a check box listed before a filled square comment that covers it was hidden under the comment (pdf.js `issue13003.pdf`); (87) a Type 3 font keeps each glyph procedure it decoded (`Type3::decoded_proc`), so a glyph shown again isn't inflated again: a font used as a fill pattern drew one glyph about 650,000 times on a page and decoded its procedure for each; a procedure over 64 KiB isn't kept, and the fonts of one `InterpreterCache` keep at most 32 MiB together (`MAX_CACHED_TYPE3_PROCS`), each kept procedure counting its bytes, its name and 64 bytes besides, as fonts live as long as the cache; a procedure holds its bytes within the page's content budget while the glyph is painted, kept or not (`context::hold_content`), so a page skips the same glyphs once the budget is spent, and one decoded with no more than 64 KiB of budget left, which the budget may have cut short, isn't kept; `InterpreterCache::type3_proc_decodes` counts the procedures decoded; (88) the rule of (76) also reads Times New Roman (`TimesNewRomanPSMT`, `TimesNewRomanPS-BoldMT`, `TimesNewRoman,Bold` and the other faces, not Times or Times New Roman MT Std), whose first 258 glyphs follow that order too: in a public form whose embedded Times New Roman subsets keep their glyph indexes, 941 of 942 `/ToUnicode` entries below 258 name that order's character, and its non-embedded `TimesNewRoman,Bold` drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" (#6449); (89) a form XObject already being drawn is not drawn inside itself (`Context::enter_form`, reported as `ContentTruncated`, §8.10.1): a name in its content that reached it again, through its own resources or the page resources a form with its own still falls back on, drew it until the page's nested-paint budget was spent (PDFBOX-5782, 38 s); the forms being drawn are kept per page and thread, so the tiling patterns, Type 3 glyphs, soft masks and groups painted inside a form, which are interpreted in contexts of their own, see it too; (90) a Type 3 font without `/Widths` (or `/FirstChar`/`/LastChar`) advances each glyph by the width its glyph procedure sets with `d0` or `d1` (`Type3::proc_width`, ISO 32000-2 9.6.4) instead of 0, read once per code; a font with `/Widths` is unchanged, a code outside `/FirstChar`..`/LastChar` keeping width 0 (Table 110) (PDFBOX-2943: a misspelt `/Widths` key drew every glyph on one spot), and selecting such a font reports `InterpreterWarning::Type3WidthsMissing` (`Font::type3_widths_missing`, checked in `Context::resolve_font`); (91) the decoded-image cache keeps a failed decode for its key, as the warnings it reported, so a picture or stencil mask that doesn't decode (damaged data, a JPX or JBIG2 stream its decoder refuses) isn't decoded again at every draw, tile and render that shares the cache, and each draw still reports what the decode did (`DecodedImageCache::get_or_insert_with`, `ImageXObject::reporting_to`); a failure counts 128 bytes towards the cache's budget, and one the page's JBIG2 allowance may have caused (73) isn't kept (the hayro-syntax comment on `charge_page_reading` says so), nor one a JBIG2 mask's decoding work refused, while a JBIG2 mask refused by a limit its stream reaches by itself is kept, with the image it takes out (`note_stream_limited`), so a page drawing that image a hundred times decodes it once; (92) the text of a glyph whose name is not in the glyph list reads `uniXXXX` and `uXXXX` to `uXXXXXX` by the Adobe Glyph List Specification (`agl_unicode_from_name`: `uni` and exactly four uppercase hex digits, `u` and four to six, no surrogate, nothing past U+10FFFF), so `uni65`, `u65`, `uni20ac` and `uni+065` read as nothing instead of `e` and `€`; upstream's lenient `unicode_from_name` stays for finding a glyph in a font (TrueType `cmap` lookup, accented standard-font letters), so drawing is unchanged; (93) a CID-keyed CFF font keeps the font dicts it can read: a font dict without a Private DICT (one a subsetter emptied) leaves only the glyphs FDSelect gives it missing, where one such dict dropped the whole font and its text drew with a fallback (ISO 32000-2 §9.9 Table 124, Adobe TN #5176); (94) a font dictionary whose `/Subtype` is missing or the empty name (`/Subtype /`) is read as Type 1, so its `/BaseFont` picks the face (ISO 32000-2 Table 109 requires `/Subtype`; other readers fall back this way): a `/Courier-Bold` was drawn in the Helvetica fallback (PDFBOX-1988). Any other unknown subtype is still refused; (98) a non-embedded font named `SymbolMT`, or `Symbol`/`SymbolMT` with a `,` or `-` style suffix (`Symbol,Bold`, `Symbol-Bold`; `SYMBOL_STYLES`, so `Symbol-Helvetica` stays another font), is the standard Symbol font and draws through its built-in encoding, so a TrueType `SymbolMT` declaring `/WinAnsiEncoding` draws `mu` for code 0x6D, not `m` (ISO 32000-2 §9.6.2.2); (99) the mesh sampler walks each row of a triangle only across the columns where that row's sample points can lie in it, not its whole box (a row with none still costs a visit): thin triangles of the fixed 19 × 19 patch grid lying across the pixel grid (a narrow curved band of tensor patches) have boxes many times their area, and a mesh painted through a path the size of its own box spent the visit budget before its last patches and left them unpainted; (100) a Type 1 or Type 3 font glyph name that only numbers its glyph (`C39`, `MT72`, `G4E`: one to three letters, then decimal or hexadecimal digits) and names the very code it is shown with reads as that code's Latin-1 character (controls excepted; in a Type 3 font, which already reads codes 32-126 by the code, above 126), in a font where every such name names its own code (`numbered_names_name_their_codes`, worked out once per font); a font with any numbered name at another code (`/C1` at 233, review on #3239; `/Differences [1 /C70 …]`) or at a C0 control code (a subset counting its glyphs from code 1, `/Differences [1 /C1 /C2 …]`, where `/C65` is the 65th glyph used) keeps reading them as nothing (#3170); (101) a symbolic TrueType font with no encoding reads a code as Mac OS Roman in text extraction when its Macintosh Roman (1, 0) `cmap` subtable selects the glyph the code draws (ISO 32000-2 §9.6.5.4) and that glyph sits where the standard Macintosh glyph order (`post` version 1.0) puts the code's Mac OS Roman character, for a font whose `post` table names no glyphs, that names no base encoding (an unknown name included) and that isn't Symbol or ZapfDingbats (§9.6.6.1), after `/Differences` and before the WinAnsi last resort; Windows subsets in the same order keep their WinAnsi reading, and glyph selection is unchanged; (102) `OutlineGlyph::vertical_glyph_width`: a vertical-writing Type 0 glyph's width w0 from `/W` or `/DW`, its extent across the column (ISO 32000-2 §9.7.4.3), which `advance_width` (the horizontal advance, 0 in vertical writing) does not give; (81) a reply of type R (a markup annotation with an `/IRT` and an `/RT` other than `/Group`; R is the default, so a missing, unknown or malformed `/RT` reads as R; a widget, link or pop-up with `/IRT` still draws) is not drawn, whatever its appearance or flags: ISO 32000-2 Table 172 says such replies "shall not" be displayed individually but in their comment's thread (files from editors that give every reply a note-icon appearance showed a stack of icons, #5631); a `/RT /Group` member still draws; (103) `InterpreterSettings::view_auto_state` (on by default): the default configuration's auto state for the View event (`/AS` usage applications with `/Event /View` and `/View` in `/Category`) sets each managed group from its `/Usage /View /ViewState` before `ocg_overrides`, ON only if every recommendation is ON (ISO 32000-2 §8.11.4.4); `/Zoom`, `/User` and `/Language` give no recommendation; at most 256 applications, 4096 `/OCGs` entries and 16 categories each are read; off for print renders; (104) an additive colour (`DeviceRGB`, `CalRGB`, `Lab`, three-component ICCBased) painted in an isolated `DeviceCMYK` group has ink tints, the ink that draws back to it through the CMYK profile (`cmyk_inverse::srgb_to_cmyk`: black `0.4 · k₀² + 0.6 · k₀⁸` with `k₀ = 1 − max(r, g, b)`, then cyan, magenta and yellow by at most 12 Newton steps; the last colour is cached per thread), so the group blends in CMYK instead of falling back to RGB | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (61) `stroke_geometry::{degenerate_subpaths_draw_a_round_dot_or_nothing, degenerate_subpaths_leave_the_rest_of_the_path_alone, a_segment_after_h_begins_a_new_subpath, all_zero_dash_arrays_draw_a_solid_line, invalid_dash_arrays_reach_a_device_as_solid, valid_dash_patterns_still_dash, negative_line_widths_reach_a_device_as_zero, negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour}` (pdfcraft-render integration tests); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (65) `it::zero_area_fill_clip` (`crates/render/tests/it/zero_area_fill_clip.rs`); (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn`, `a_picture_whose_jbig2_mask_a_decoder_limit_refuses_is_not_drawn`, `a_jbig2_image_drawn_on_its_own_that_a_limit_refuses_is_a_failed_image`, `a_limit_refusal_says_nothing_of_the_next_image`, `a_jpx_picture_whose_jbig2_mask_a_limit_refuses_stays_out_at_every_size` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render); (75) `raster::tests::a_soft_hyphen_a_substitute_lacks_draws_its_hyphen` (pdfcraft-render); (76) `raster::tests::non_embedded_arial::{an_arial_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (77) `euro_width::the_euro_fits_its_standard_advance`, `euro_width::two_euros_stay_apart`, `euro_width::a_slanted_euro_fits_its_standard_advance`, `symbol_dingbats_width::{symbol_glyphs_keep_their_standard_advances, zapf_dingbats_glyphs_keep_their_standard_advances}` (pdfcraft-render `tests/it`), `dingbat_check_box::a_check_box_caption_check_is_drawn_at_its_standard_advance` (pdfcraft-engine `tests/it`); (78) `raster::tests::{fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}` (pdfcraft-render); (79) `raster::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names`, `pdf::tests::a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names` (pdfcraft-fonts, the editor's reading by the same rules) ; (80) `crates/render/tests/it/indexed_lookup.rs` (`cargo test -p pdfcraft-render --test it indexed_lookup`), `raster::tests::indexed_lookup_cache`; (82) `raster::tests::a_soft_mask_far_larger_than_where_it_is_drawn_is_shrunk_once`, `raster::tests::an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size`, `raster::tests::decoded_images_serve_every_size_they_are_drawn_at` (pdfcraft-render); (83) `raster::tests::truetype_loca_format::a_truetype_font_with_an_out_of_range_loca_format_draws_its_glyphs` (pdfcraft-render); (84) `text::tests::an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap`, `every_adobe_collection_reads_through_its_own_ucs2_cmap` (GB1, CNS1, Korea1, Identity-V, `/H` over CFF) and `a_font_outside_the_adobe_collections_gets_no_text_from_a_ucs2_cmap` (pdfcraft-render); (85) `text::tests::{a_tounicode_written_as_cid_ranges_gives_text, a_tounicode_bf_entry_wins_over_a_cid_entry_for_the_same_code}` (pdfcraft-render); (86) `widgets_above_annotations::a_widget_is_drawn_above_the_comments_on_its_page` (pdfcraft-render `tests/it`); (87) `raster::tests::type3_proc_cache::{a_type3_glyph_shown_again_decodes_its_procedure_once, a_procedure_over_64_kib_is_decoded_for_each_copy, the_procedures_one_cache_keeps_stop_at_32_mib, fonts_with_the_same_glyph_name_keep_their_own_procedures}` (pdfcraft-render); (88) `raster::tests::non_embedded_arial::{a_times_new_roman_the_pdf_does_not_embed_draws_the_glyphs_its_codes_name, codes_of_other_fonts_are_not_read_in_the_macintosh_order}` (pdfcraft-render); (89) `raster::tests::a_form_that_draws_itself_is_drawn_once`, `raster::tests::a_form_that_reaches_itself_through_a_pattern_or_a_glyph_is_drawn_once` (pdfcraft-render); (90) `raster::tests::type3_proc_widths::{a_type3_font_without_widths_advances_by_its_glyph_procedures, a_code_outside_first_char_to_last_char_still_has_width_zero, widths_the_font_gives_are_kept, a_code_without_a_usable_procedure_width_still_advances_nothing, a_type3_font_without_widths_is_reported}` (pdfcraft-render); (91) `raster::tests::failed_image_decodes::{a_picture_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_that_does_not_decode_is_decoded_once_at_each_size, a_stencil_mask_that_does_not_decode_is_decoded_once_however_often_it_is_drawn, a_picture_whose_soft_mask_does_not_decode_draws_unmasked_decoded_once, a_picture_that_decodes_is_still_decoded_once_and_drawn_every_time, a_picture_left_out_for_the_pages_allowance_draws_on_the_next_page, a_picture_whose_jbig2_mask_a_limit_refuses_is_decoded_once_however_often_it_is_drawn, a_picture_whose_jbig2_mask_is_refused_for_its_work_is_decoded_at_every_draw}` (pdfcraft-render); (92) `text::tests::agl_unicode_from_name_rule_reads_text` (pdfcraft-render); (93) `cid_cff_unusable_font_dict::{glyphs_of_a_usable_font_dict_draw_beside_an_unusable_one, a_program_with_no_usable_font_dict_draws_no_square}` (pdfcraft-render integration tests); (94) `raster::tests::a_font_without_a_subtype_is_drawn_by_its_base_font` (pdfcraft-render); (98) `symbol_mt_builtin_encoding::{symbol_mt_draws_symbols_through_the_built_in_encoding, a_name_that_only_starts_with_symbol_is_not_symbol, symbol_mt_applies_its_differences, an_embedded_symbol_mt_draws_its_own_program}` (pdfcraft-render integration tests); (99) `raster::tests::thin_patches_of_a_mesh_in_a_tight_path_are_all_painted` (pdfcraft-render); (100) `text::tests::numbered_glyph_names_read_as_their_own_code`, `text::tests::numbered_type3_glyph_names_read_as_their_own_code`, `text::tests::glyph_names_outside_the_glyph_list_still_read_as_text` (pdfcraft-render); (101) `raster::tests::symbolic_truetype_mac_roman_text::{a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman, winansi_codes_of_a_font_in_the_macintosh_order_read_as_before, a_symbolic_truetype_font_without_a_1_0_subtable_reads_as_before, a_1_0_subtable_selecting_other_glyphs_than_the_drawn_ones_is_not_read, a_font_that_names_its_glyphs_is_not_read_by_their_order, a_symbol_or_zapfdingbats_font_in_the_macintosh_order_gets_no_mac_roman_reading, differences_naming_notdef_clear_the_mac_roman_reading_for_its_code, a_base_encoding_name_this_reader_does_not_know_keeps_the_mac_roman_reading_off}` (pdfcraft-render), `pdf::tests::a_symbolic_truetype_font_in_the_macintosh_order_reads_its_codes_as_mac_roman` (pdfcraft-fonts); (102) `text::tests::tracked_vertical_latin_stays_one_line` (pdfcraft-render); (81) `raster::tests::replies_are_not_drawn_but_their_comment_and_group_members_are`, `raster::tests::an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws` (hayro-interpret); (103) `raster::tests::the_view_auto_state_sets_a_groups_state_on_screen`, `raster::tests::layer_toggles_and_print_renders_are_not_the_view_auto_state` and `raster::tests::malformed_view_auto_state_is_ignored` (pdfcraft-render); (104) `raster::tests::isolated_cmyk_groups::an_rgb_colour_in_an_isolated_cmyk_group_blends_as_its_ink` and `::an_rgb_colour_with_a_non_finite_component_has_no_tints` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour (since (21), only inside Type 3 glyphs), planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk image that is interpolated (or, since (21), area-averaged) and whose axes lie along the device's (upright, mirrored or a quarter turn, as on a page under `/Rotate`) is snapped to whole device pixels: shrunk once to its rounded device rectangle (halves round up, so a tile snaps an edge where the whole page does) and drawn 1:1 from the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat, and until #5530 a turned one still did, up to 200 grey levels off the same page drawn upright). An image counts as axis-aligned when the terms the snap drops move its far edges by at most a hundredth of a device pixel (`SNAP_SKEW`), not by a tolerance on the coefficients, which let a slight skew of a very wide image through; and the snapped 1:1 transform is used only when the shrink produced the snapped size, so a failed resize keeps the full transform instead of drawing the source raster at its source size; (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (21) an image that does not request interpolation and is drawn below its resolution, outside a Type 3 glyph, is shrunk by area averaging (`area_shrink`: each device pixel the coverage-weighted mean of the samples it covers, in 16.16 fixed point whose weights sum to exactly one, premultiplied when the image has a mask; a soft mask no larger than the image is first sampled onto the image's grid by `mask_on_image_grid` (nearest-neighbour, or bilinearly when the mask asks for `/Interpolate`, so its edge stays as smooth as an enlarged mask's), so its colour and mask are averaged together instead of the averaged colour of transparent samples showing under a mask layer at the mask's edges) to its snapped device rectangle, instead of nearest-neighbour: `/Interpolate` asks for smoothing only when an image is enlarged (ISO 32000-2 §8.9.5.3), and nearest-neighbour kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes (sampling them again at a fractional offset dropped more). PicScale's `Box` and `Area` filters were not used: shrinking, both average a window four target pixels wide. On a 13-page 300 dpi scan every rendered sample at 96, 120 and 192 dpi is within 1 of the exact area average (it was up to 255 off, 17-23% of samples by more than 1). A snapped image whose shrink failed keeps the full transform, so it is not drawn at its source size. The shrink never enlarges: a side drawn at or above its resolution (an image shrunk across and stretched down, as a 204 × 98 dpi fax is) keeps its samples for the sampler. Each target row averages the whole source rows it covers (contiguous, cheap) and is then shrunk across with taps worked out once per image, which made the shrink about three times faster (a 300 dpi page at 96 dpi: 25 ms, nearest-neighbour 11 ms); working a row at a time (`area_shrink_rows`) keeps its work memory to a few rows. An image with a soft mask, a stencil's ink included, is premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`), never as a full-resolution RGBA copy: a 10718 × 13701 stencil shown at page size peaked at 1.16 GB with that copy and peaks at 1.03 GB without it, as with nearest-neighbour. A source with a side over `MAX_RESAMPLING_SOURCE_SIDE` (2^20) keeps (10)'s nearest-neighbour shrink, and `area_shrink` refuses it, so its accumulator row and tap table stay a few times one source row (a 2^27 × 2 image of zeros is a tiny file); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1); (28) an image of page-scan size (`SMOOTHED_IMAGE_SIDE`: 512 samples or more on its shorter side) that does not request interpolation is drawn with `ImageQuality::Medium` (bilinear, as interpolated images are) when enlarged by more than 1% on either side (`SMOOTHED_IMAGE_SCALE`: a scan drawn at its resolution can come out a few ulps above 1, and blurred by half a pixel at a half-pixel offset), also when the other side is shrunk (the area shrink leaves that side at 1:1), outside a Type 3 glyph: scans rarely set `/Interpolate`, and drawn nearest-neighbour every sample of a zoomed scan became a block, so its text had staircase edges; icons, QR codes and pixel art keep their hard pixels. Bicubic (`High`) looked the same on a zoomed 300 dpi scan and drew four 1024 px tiles at 400% in 267 ms against 50 ms; (30) a stencil mask painted with a pattern is drawn under a clip of its device rectangle, padded by two pixels: the pattern in an isolated layer, cut to the stencil by a layer holding it composited with `Compose::DestIn` (drawn without a Type 3 glyph's image rules, as the sub-render it replaces was), instead of through a canvas-sized luminance mask rendered for every draw; and a coloured tiling pattern's cell pixmap, repeated or shown once under patch (20), is drawn once per setting of the pattern and cell scale (`Renderer::tiling_cell`, `TilingCells`, eight entries per renderer, keyed by hayro-interpret patch (78)'s `TilingPattern::instance`) instead of once per fill, where every cell was kept until the page was composited. pdf.js issue 8078 paints 73,047 16 × 16 stencils with one pattern whose cell is 3.5 MB at 36 dpi: it ran out of memory after 5 minutes and now renders in about 5 s. For its tests, `offscreen_draws` counts on each thread the tiling cells drawn and the pixels of every pixmap drawn offscreen (all made by `offscreen_pixmap`), so a page's cost is checked by count, not by time; (31) `RenderSettings::max_coverage` keeps the rasterizer to that many coverage values for the page (vello_cpu patch 1), and a page whose paths it left out says so with `InterpreterWarning::ContentTruncated` | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does` (`cargo test -p hayro`), `raster::tests::a_turned_or_mirrored_minified_image_snaps_to_whole_pixels`, `rotated_image_snap::a_shrunk_image_on_a_turned_page_is_the_upright_render_turned` (`cargo test -p pdfcraft-render --test it`), `renderer::snap_tests::a_skew_that_moves_a_far_edge_is_not_snapped` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (21) `raster::tests::non_interpolated_minification_keeps_thin_strokes`, `raster::tests::non_integer_minification_averages_the_columns_each_pixel_covers`, `raster::tests::non_integer_minification_averages_colour_and_mask_together`, `raster::tests::non_interpolated_minification_does_not_bleed_transparent_colour`, `raster::tests::a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified`, `raster::tests::an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified`, `raster::tests::a_shrunk_stencil_mask_keeps_thin_strokes`, `raster::tests::minified_image_edges_snap_to_whole_pixels`, `raster::tests::an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`, `renderer::area_shrink_tests` (`cargo test -p hayro`); (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`); (28) `raster::tests::large_non_interpolated_images_are_smoothed_when_enlarged`, `raster::tests::small_non_interpolated_images_keep_hard_pixels_when_enlarged`, `raster::tests::a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched`, `raster::tests::a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp`; (30) `raster::tests::{a_patterned_stencil_mask_paints_only_where_it_inks, a_patterned_stencil_mask_under_a_soft_mask_is_masked_once, a_patterned_stencil_mask_in_a_type3_glyph_keeps_thin_strokes, many_patterned_stencil_masks_draw_no_page_sized_masks, fills_of_one_pattern_setting_share_its_cell, fills_of_interleaved_pattern_settings_each_show_their_own_cell, an_uncoloured_pattern_shows_each_settings_colour}`; (31) `raster::tests::coverage_budget::a_page_past_its_coverage_leaves_the_rest_out_and_says_so` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (2) at most as many symbol pixels per symbol dictionary as the largest page bitmap holds, 2^28 (a fuzzed dictionary decoded for minutes; each symbol now pays for its pixels out of the stream's decoding work and is held in the retained bitmaps, and at the earlier 2^26 the soft mask of a scanned drawing, one dictionary of 7,559 symbols, was refused: #6722), and a refusal by one of the decoder's limits is told apart from damaged data (`DecodeError::is_limit`, `OverflowError::BitmapLimit`); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change; (13) a bitmap filled with a default pixel of 1 (a page, or an intermediate text or halftone region) had 1s past its width in the last word of each row, and a refinement, which reads its reference a word at a time, read them as pixels where the standard has 0 (6.3.5.2): refining such a reference not a multiple of 32 wide decoded to other pixels than were coded, release builds too. `Bitmap::clear_padding` clears those bits when a bitmap is filled with 1s, as cutting symbols and patterns from a collective bitmap already did. Ghostscript reads the test's page and text streams as they decode now; (14) a region's location is unsigned (5.4.4), so one past `i32::MAX` is off the page, where the decoder took it for a signed one: a region there was drawn back onto the page from the left or the top (at 0xFFFFFFFC, four pixels left of it), and a refinement there, of the page or of a region at the same place (7.4.7.5), was refused as an overflow, and the page with it. `Bitmap::combine_region` keeps such a location past the page. A refinement of another region refines it where it is, as Table 35 has it (GRREFERENCEDX and GRREFERENCEDY 0), whatever the two locations say; a refinement of the page, see (16); (15) a refinement whose reference is offset right of it (GRREFERENCEDX above 0, which a refined text instance has from a wider bitmap, Table 12, and a refined symbol from an RDX above 0, Table 18) read the reference's first 32 columns as 0: `RefinementContextGatherer::maybe_reload_buffers` emptied its buffers left of the reference and didn't load them again at its first column. Such a refinement decoded to other pixels than were coded, or failed and lost the page. Left of the reference the buffers now keep the words of its first column each row starts with; with an offset of 0 or less nothing changes; (16) a refinement of the page refines "the current contents of the page buffer, restricted to the area of the page buffer specified by this segment's region segment information field" (7.4.7.4), at an offset of 0 (Table 35): `Bitmap::area` copies that area a word at a time, 0 off the page and past its width whatever the padding of its rows holds, and every refinement region reads its reference at offset 0. The page around the area was read too, so a refinement of the page decoded to other pixels than were coded, and one wider or taller than the page was refused. And `RefinementContextGatherer::start_row` loads the reference's words a column left of where a row starts in it, as the reload would, not from column 0: with the reference offset 32 or more left of the refinement (GRREFERENCEDX below −31, as a refined text instance has from a narrower bitmap or an RDX below −31, and a refined symbol from such an RDX), each row's first context read 0 for the reference column it starts at, and from 33 left for the one before it too, and the refinement decoded to other pixels or failed; (17) `Image::new_embedded` reads data that starts with a JBIG2 file header (the ID string `0x97 4A 42 32 0D 0A 1A 0A`, flags and an optional page count, D.4) past it, in the organization the header declares (sequential or random access), its segments counted together with the globals' as (8) has them: ISO 32000-1 7.4.7 has `JBIG2Decode` data in the embedded organization (D.3), but some producers write the file header too, and Acrobat and PDFBox draw such streams. The header's ID string was read as a segment header and the image failed (`unknown or reserved segment type`), so the page drew white | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_are_refused`; (2) the nightly `cargo xtask fuzz` finding (about a second at the earlier 2^26 bound; not run at 2^28), `raster::tests::a_symbol_dictionary_holds_as_many_pixels_as_a_page`, and for `is_limit` and `BitmapLimit` `error::pdfcraft_limit_tests`, `bitmap::pdfcraft_limit_tests` and `gray_scale::pdfcraft_limit_tests` (hayro-jbig2); (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing`; (13) `jbig2::decode::tests::a_refinement_reads_nothing_past_its_references_width` (pdfcraft-filters), and in the crate `tests::a_bitmap_of_1s_has_nothing_past_its_width`; (14) `jbig2::decode::tests::a_region_located_past_i32_max_is_off_the_page` (pdfcraft-filters), and in the crate `tests::a_region_located_past_i32_max_is_off_the_page` and `tests::a_refinement_of_a_region_decodes_the_same_wherever_they_are`; (15) `jbig2::decode::tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column` (pdfcraft-filters), and in the crate `tests::a_refinement_reads_a_reference_right_of_it_from_its_first_column`; (16) `jbig2::decode::tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` (pdfcraft-filters), and in the crate `tests::a_refinement_refines_the_page_in_its_area_and_any_reference_where_it_is` and `tests::a_page_area_is_the_page_inside_it_and_0_outside` ; (17) `jbig2::decode::tests::a_stream_with_a_jbig2_file_header_decodes_past_it`, `jbig2::decode::tests::a_truncated_jbig2_file_header_is_an_error`, `jbig2::decode::tests::segments_after_a_jbig2_file_header_count_with_the_globals` (pdfcraft-filters), `raster::tests::a_jbig2_image_with_a_file_header_draws` (pdfcraft-render) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365); (7) object parsing builds at most 4,194,304 objects (every array element and dictionary value at any depth, `parser::MAX_OBJECT_NODES`, the same cap as `pdfcraft_cos`) per top-level object, an object stream reads at most 1,000,000 index entries (cos's `/N` clamp), and one load builds at most 8,388,608 objects from all of its object streams together, shared across rayon workers (a decoded stream of `0 0 0 ...` inside the 256 MiB decompression limit built ~120M objects, 10+ GB, and aborted). A lookup parses only the member it wants, so it isn't skipped behind a large member, and a load parses only the members it would add: none its cross-reference table places in another stream (stale copies) and none already loaded as an ordinary object or from an earlier stream. Members the table doesn't list at all are still parsed (a rebuilt table lists no compressed objects) and share the budget, so they can spend it before listed members of the same stream; a load expands its object streams one at a time in object-number order, a stream's members in parallel only when the budget holds all of them and otherwise in index order, so a spent budget skips the same members on every load (#5083) | (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`; (7) `inspect::tests::{object_stream_members_over_the_node_cap_are_skipped, object_stream_index_entries_past_the_clamp_are_ignored, object_streams_of_one_load_share_their_node_budget, object_streams_loaded_in_parallel_share_their_node_budget, a_malformed_object_stream_member_refunds_its_reservation, a_tight_budget_builds_object_stream_members_in_index_order, a_lookup_in_an_object_stream_parses_only_the_member_it_wants}` (pdfcraft-render; `NodeBudget` and `ObjectStream::new_within` are public so these can use a small budget) | diff --git a/vendor/hayro-interpret/src/cmyk_inverse.rs b/vendor/hayro-interpret/src/cmyk_inverse.rs new file mode 100644 index 0000000000..74804f92a7 --- /dev/null +++ b/vendor/hayro-interpret/src/cmyk_inverse.rs @@ -0,0 +1,118 @@ +//! PdfCraft patch (104): an sRGB colour as the process ink that draws back to it through the +//! CMYK profile pages are drawn with, for an additive colour painted into a `DeviceCMYK` +//! blending space (ISO 32000-2 §11.3.4). +//! +//! Black is `K = 0.4 · k₀² + 0.6 · k₀⁸` with `k₀ = 1 − max(r, g, b)`, light in the mid tones +//! and full for black; cyan, magenta and yellow are then solved for by Newton's method at that +//! black, clamped to the ink range. A colour inside the profile's gamut draws as itself; one +//! outside it lands where the clamped solve stops. The curve matches Acrobat's output for the +//! greys, black and red measured in a CMYK page group, plain and under a blend mode; +//! §10.4.2.4's black generation and undercolour removal is for a device without a profile. + +use moxcms::{ColorProfile, Layout, TransformF32Executor, TransformOptions}; +use std::cell::Cell; +use std::sync::{Arc, LazyLock}; + +use crate::color::CMYK_PROFILE; + +/// The weight of `k₀²` in the black ink; the rest is on `k₀⁸`. +const BLACK_GENERATION: f32 = 0.4; +/// Newton steps; each costs four profile evaluations. +const MAX_STEPS: usize = 12; +/// Close enough: well under one 8-bit level. +const TOLERANCE: f32 = 0.5 / 255.0; +/// The finite-difference step for the Jacobian. +const STEP: f32 = 0.02; + +static FORWARD: LazyLock>> = LazyLock::new(|| { + ColorProfile::new_from_slice(CMYK_PROFILE) + .ok()? + .create_transform_f32( + Layout::Rgba, + &ColorProfile::new_srgb(), + Layout::Rgb, + TransformOptions::default(), + ) + .ok() +}); + +/// The process ink `[C, M, Y, K]` (0 = no ink) that the CMYK profile draws as `rgb`; for a +/// colour outside its gamut, where the clamped solve stops. `None` when the +/// profile can't be evaluated or `rgb` isn't finite. +/// +/// The last colour converted on this thread is kept, as a run of fills (the glyphs of a +/// line of text) usually shares one colour. +pub(crate) fn srgb_to_cmyk(rgb: [f32; 3]) -> Option<[f32; 4]> { + if !rgb.iter().all(|v| v.is_finite()) { + return None; + } + let rgb = rgb.map(|v| v.clamp(0.0, 1.0)); + let key = rgb.map(f32::to_bits); + if let Some((last, ink)) = LAST.get() + && last == key + { + return Some(ink); + } + let ink = solve_ink(rgb)?; + LAST.set(Some((key, ink))); + Some(ink) +} + +thread_local! { + static LAST: Cell> = const { Cell::new(None) }; +} + +fn solve_ink(rgb: [f32; 3]) -> Option<[f32; 4]> { + let forward: &TransformF32Executor = FORWARD.as_ref()?.as_ref(); + let k0 = 1.0 - rgb[0].max(rgb[1]).max(rgb[2]); + let k = BLACK_GENERATION * k0 * k0 + (1.0 - BLACK_GENERATION) * k0.powi(8); + let eval = |cmy: [f32; 3]| draw(forward, [cmy[0], cmy[1], cmy[2], k]); + + let mut cmy = rgb.map(|v| 1.0 - v); + for _ in 0..MAX_STEPS { + let drawn = eval(cmy)?; + let residual: [f32; 3] = std::array::from_fn(|i| drawn[i] - rgb[i]); + if residual.iter().all(|r| r.abs() < TOLERANCE) { + break; + } + // Column `j` of the Jacobian: how the drawn colour moves with ink `j`. + let mut columns = [[0.0_f32; 3]; 3]; + for (j, column) in columns.iter_mut().enumerate() { + let step = if cmy[j] <= 1.0 - STEP { STEP } else { -STEP }; + let mut moved = cmy; + moved[j] += step; + let shifted = eval(moved)?; + *column = std::array::from_fn(|i| (shifted[i] - drawn[i]) / step); + } + let Some(delta) = solve(columns, residual.map(|r| -r)) else { + break; + }; + cmy = std::array::from_fn(|i| (cmy[i] + delta[i]).clamp(0.0, 1.0)); + } + Some([cmy[0], cmy[1], cmy[2], k]) +} + +fn draw(forward: &TransformF32Executor, cmyk: [f32; 4]) -> Option<[f32; 3]> { + let mut out = [0.0_f32; 3]; + forward.transform(&cmyk, &mut out).ok()?; + out.iter().all(|v| v.is_finite()).then_some(out) +} + +/// `x` with `A x = b`, `A` given by its columns (Cramer's rule). `None` when `A` is +/// singular or the result isn't finite. +fn solve(a: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> { + let det = |[c0, c1, c2]: [[f32; 3]; 3]| { + c0[0] * (c1[1] * c2[2] - c2[1] * c1[2]) - c1[0] * (c0[1] * c2[2] - c2[1] * c0[2]) + + c2[0] * (c0[1] * c1[2] - c1[1] * c0[2]) + }; + let d = det(a); + if !d.is_finite() || d.abs() < 1e-9 { + return None; + } + let x: [f32; 3] = std::array::from_fn(|j| { + let mut m = a; + m[j] = b; + det(m) / d + }); + x.iter().all(|v| v.is_finite()).then_some(x) +} diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 4021b31e04..4a7a05f1b6 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -1552,9 +1552,10 @@ impl Color { /// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4). /// /// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray` - /// (§10.4.2.2). `None` for every other space, ICCBased CMYK included: such a colour has no - /// tints without a colour conversion this crate does not make (an ICCBased colour keeps - /// its own profile), and the caller draws it as before. + /// (§10.4.2.2). PdfCraft patch (104): an additive colour (`DeviceRGB`, `CalRGB`, `Lab`, a + /// three-component ICCBased colour) is the ink that draws back to it through the CMYK + /// profile. `None` for every other space, ICCBased CMYK included (it keeps its own + /// profile), and the caller draws it as before. pub fn ink_tints(&self) -> Option<([f32; 4], f32)> { let c = |i: usize| { self.components @@ -1566,11 +1567,27 @@ impl Color { let tints = match self.color_space.0.as_ref() { ColorSpaceType::DeviceCmyk => [c(0), c(1), c(2), c(3)], ColorSpaceType::DeviceGray => [0.0, 0.0, 0.0, 1.0 - c(0)], + ColorSpaceType::DeviceRgb | ColorSpaceType::CalRgb(_) | ColorSpaceType::Lab(_) => { + self.ink_from_srgb()? + } + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 3 => { + self.ink_from_srgb()? + } _ => return None, }; Some((tints, self.opacity.clamp(0.0, 1.0))) } + fn ink_from_srgb(&self) -> Option<[f32; 4]> { + // `to_rgba` turns a non-finite component into some finite colour, which is not the + // colour's ink, so such a colour has no tints. + if self.components.iter().any(|c| !c.is_finite()) { + return None; + } + let [r, g, b, _] = self.to_rgba().components(); + crate::cmyk_inverse::srgb_to_cmyk([r, g, b]) + } + /// Create a color from RGBA. pub fn from_rgba(rgba: AlphaColor) -> Self { let c = rgba.components(); @@ -1592,9 +1609,11 @@ pub fn cmyk_to_srgb8(input: &[u8], output: &mut [u8]) -> Option<()> { CMYK_TRANSFORM.convert_u8(input, output) } -static CMYK_TRANSFORM: LazyLock = LazyLock::new(|| { - ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4).unwrap() -}); +/// The CMYK profile pages are drawn with. +pub(crate) const CMYK_PROFILE: &[u8] = include_bytes!("../assets/CGATS001Compat-v2-micro.icc"); + +static CMYK_TRANSFORM: LazyLock = + LazyLock::new(|| ICCProfile::new(CMYK_PROFILE, 4).unwrap()); pub(crate) trait ToRgb { fn convert_sample(&self, input: &[f32], output: &mut [u8], manual_scale: bool) -> Option<()> { diff --git a/vendor/hayro-interpret/src/lib.rs b/vendor/hayro-interpret/src/lib.rs index 01e717da93..03a0f44811 100644 --- a/vendor/hayro-interpret/src/lib.rs +++ b/vendor/hayro-interpret/src/lib.rs @@ -33,6 +33,7 @@ mod log; mod cache; mod cmyk_group; +mod cmyk_inverse; mod context; mod convert; mod device; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index c8640adb14..372f7e28cc 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -922,8 +922,8 @@ impl TilingCells { #[derive(Clone, Copy, Default)] struct InkState { pass: Option, - /// Something in the group has no ink tints (an RGB colour, a pattern, an image): the - /// group is drawn as before instead. + /// Something in the group has no ink tints (a pattern, an image, an ICCBased CMYK colour): + /// the group is drawn as before instead. unsupported: bool, } From 490287fc9d04d1915c690a3d0f6954bde706e15e Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 11:58:14 -0500 Subject: [PATCH 19/21] prc: rebuild compressed tessellations whose normals are recalculated (#1297) (#7034) * prc: rebuild compressed tessellations whose normals are recalculated A TESS_3D_Compressed (WD 7.8.9.7) was read to its end but never rebuilt, so prc-render drew nothing for it. Rebuild the form whose normals are recalculated, with three edge statuses per triangle, into a Mesh grouped by face and styled by each face's line attribute. The walk (tess/compressed.rs) takes a seed, then one apex per triangle, from a reference or the next point in the frame of the edge it continues across (WD 7.8.9.2), with the WD 12.3 Length, Unitize and MakeOrthoRep arithmetic bit for bit. Arrays that do not fit, or an edge shared by more than two triangles, leave the tessellation Compressed. Every triangle, style and face range is charged to the element allowance first. Stored-normals meshes, the one-status-per-triangle form, the planar bend check, the best-fit search and partial salvage are later PRs. Adapted from pdfcer (MIT, same author) at 0a249a73. Co-Authored-By: Claude Opus 5.5 * prc: charge rebuilt compressed meshes after they fit; model_render test The rebuilt triangles, their styles and the face ranges are now charged to the element allowance once the arrays fit and before the face ranges are made. Arrays the walk cannot fit stay Compressed and spend nothing, so they no longer fail the file over a face count they never use. Tests: - a_rebuilt_compressed_mesh_is_charged_its_triangles_and_faces pins the exact charge (4 triangles x 3 elements + 2 faces); - arrays_that_do_not_fit_spend_no_triangles_or_faces; - rebuilt_triangles_are_grouped_by_face_with_their_styles, a direct by_face test with out-of-order faces, empty faces and styles; - model_render's a_compressed_prc_model_is_rebuilt_and_drawn, end to end from a PDF, cited in misc.3d-view; its hex fixture is the prc-render builder's output, kept so by the_automation_fixture_is_the_compressed_triangle. The walk's working tables are documented as bounded by the triangle count rather than charged. The module docs now say the arithmetic matches the WD pseudocode as read and that the traversal rules are inferred, not WD text. Co-Authored-By: Claude Opus 5.5 * prc: charge compressed styles and face ranges before making them A rebuilt compressed mesh's per-triangle styles were built before the budget was charged, and also when the normals were stored and the mesh was read past. The face attributes are now kept as read, and the styles are built only for a rebuilt mesh, after the charge. Each face range is a Range, two budget elements on 64-bit targets, so it is charged at its size, not as one element. Co-Authored-By: Claude Opus 5.5 * prc: bound the compressed rebuild walk by the allowance left The walk's vertices, triangles, edge counts, edge log and stack were allocated before anything was charged, and could hold several times the decoded arrays. The walk now gets the elements the reader has left and stops once its arrays, by length, would hold more; the tessellation then stays compressed, as when its arrays do not fit the walk. A face whose line attribute is 0 but whose behaviour is not now styles its triangles, so the behaviour bits are kept. Co-Authored-By: Claude Opus 5.5 * prc: say the rebuild walk is bounded by the allowance Co-Authored-By: Claude Opus 5.5 * prc: cap the rebuild walk's first reservation by its limit Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- crates/automation/tests/it/model_render.rs | 23 ++ crates/prc-render/tests/model.rs | 48 +++ crates/prc/src/bits.rs | 1 - crates/prc/src/tess.rs | 181 ++++++++--- crates/prc/src/tess/compressed.rs | 353 +++++++++++++++++++++ crates/prc/src/tess/compressed/tests.rs | 299 +++++++++++++++++ crates/prc/src/tess/tests.rs | 271 +++++++++++++++- parity/acrobat-features.toml | 4 +- 8 files changed, 1136 insertions(+), 44 deletions(-) create mode 100644 crates/prc/src/tess/compressed.rs create mode 100644 crates/prc/src/tess/compressed/tests.rs diff --git a/crates/automation/tests/it/model_render.rs b/crates/automation/tests/it/model_render.rs index c7dcca1af6..4323ca50e7 100644 --- a/crates/automation/tests/it/model_render.rs +++ b/crates/automation/tests/it/model_render.rs @@ -9,6 +9,11 @@ use serde_json::{Value, json}; /// file structure with no product tree, in hexadecimal for the stream's `/ASCIIHexDecode`. const TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000cd000000d5000000dd000000e500000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c087758da2422b43084fe3140e038713464cd759381a162800006ed70828789c030000000001789c030000000001789c030000000001"; +/// The same triangle stored as a `TESS_3D_Compressed` whose normals are recalculated, so it is +/// drawn only once its triangles are rebuilt. `prc-render`'s tests build it +/// (`the_automation_fixture_is_the_compressed_triangle` keeps this copy their output). +const COMPRESSED_TRIANGLE_PRC_HEX: &str = "505243c91f0000c91f00000100000002000000030000000400000000000000000000000000000000000000010000000500000006000000070000000800000000000000060000006b0000009a000000a3000000ab000000d2000000da000000e2000000ea00000000000000505243c91f0000c91f0000050000000600000007000000080000000000000000000000000000000000000000000000789c63000000010001789c030000000001789c9b71c08775aded1d0624d0d2c808055a8d0c8c8ccc0c0c0c0a0a223ce2e50c2c00bed0061f789c030000000001789c030000000001789c030000000001"; + /// A one-page PDF whose page holds a 3D annotation over a stream of `/Subtype /{subtype}`. fn pdf(subtype: &str, hex: &str) -> Vec { let stream = format!("<< /Type /3D /Subtype /{subtype} /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1); @@ -82,6 +87,24 @@ fn a_prc_model_is_drawn_over_the_background() { assert!(a.call("model_render", &json!({ "doc": doc, "page": 1, "model": 1 })).is_ok()); } +#[test] +fn a_compressed_prc_model_is_rebuilt_and_drawn() { + let (dir, mut a) = setup("prc-compressed"); + std::fs::write(dir.join("in/compressed.pdf"), pdf("PRC", COMPRESSED_TRIANGLE_PRC_HEX)).unwrap(); + let doc = open(&mut a, "compressed.pdf"); + let args = json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "direction": [0, 0, -1], "perspective": false }); + let out = a.call("model_render", &args).unwrap(); + let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") }; + assert_eq!(rgb(data, 0, 0), [255, 255, 255], "the corner is background"); + assert_eq!(rgb(data, 32, 24), [190, 192, 200], "the centre is the triangle, lit head on"); + // Its apex is up: the drawn span narrows from the base to the top, and the centre column + // is covered over many rows. + let (top, bottom) = rows(data, 32); + assert!(bottom - top > 20, "rows {top}..{bottom}"); + let (near_top, near_bottom) = (extent(data, top + 4), extent(data, bottom - 2)); + assert!(near_bottom.1 - near_bottom.0 > 2 * (near_top.1 - near_top.0), "{near_top:?} {near_bottom:?}"); +} + #[test] fn a_u3d_model_or_a_missing_one_is_a_clear_error() { let (_dir, mut a) = setup("u3d"); diff --git a/crates/prc-render/tests/model.rs b/crates/prc-render/tests/model.rs index 80e84252c6..ce94ceb4ce 100644 --- a/crates/prc-render/tests/model.rs +++ b/crates/prc-render/tests/model.rs @@ -78,6 +78,30 @@ fn tess_section(points: &[[f64; 3]], normals: &[[f64; 3]], corners: &[(u32, u32) w.bytes() } +/// A tessellation section of one `TESS_3D_Compressed` (authoring version 8137, origin 0, +/// tolerance 0.5) whose normals are recalculated: the seed triangle `seed`, in tolerances +/// [WD 7.8.9.7]. +fn compressed_section(seed: [i64; 9]) -> Vec { + let mut w = W::default(); + w.uint(305).uint(0).bit(true).uint(1); + w.uint(173).bit(false).bit(true).double(0.5).put(0, 32).put(0, 32).put(0, 32); + // point_array: 4-bit sign-magnitude integers. + w.bit(false).uint(9); + seed.iter().for_each(|_| _ = w.put(4, 8)); + seed.iter().for_each(|&p| _ = w.bit(p < 0).put(p.unsigned_abs(), 3)); + // Three edge statuses, none set; one triangle on face 0; three slots, none a reference. + w.bit(false).uint(3).put(0, 24); + w.bit(false).uint(1).put(1, 8).bit(false); + w.uint(3).put(0, 3).uint(0); + // Normals recalculated; no colours or multiple line attributes; no style; no texture or + // behaviours. + w.bit(true).bit(false).double(0.5).put(0, 8).bit(false).bit(false); + w.bit(false).uint(0).bit(true).bit(false); + // The section's user data. + w.uint(0); + w.bytes() +} + /// A one-file-structure PRC stream whose tessellation section is `tess`, with no product tree. fn prc_file(tess: &[u8]) -> Vec { const HEADER_LEN: usize = 107; @@ -133,6 +157,30 @@ fn a_synthetic_prc_renders_its_nearer_triangle_over_the_farther() { assert_eq!(pixel(&image, 20, 34), [57, 58, 60, 255], "the farther triangle, lit edge on"); } +/// The seed (-1, -1, 0), (1, -1, 0), then the midpoint (0, -1, 0) raised by 2 to (0, 1, 0). +const COMPRESSED_TRIANGLE: [i64; 9] = [-2, -2, 0, 4, 0, 0, 0, 4, 0]; + +#[test] +fn a_compressed_tessellation_is_rebuilt_and_rendered() { + let prc = PrcFile::parse(&prc_file(&compressed_section(COMPRESSED_TRIANGLE))).unwrap(); + let meshes = assemble(&prc).unwrap(); + assert_eq!(meshes.len(), 1); + assert_eq!(meshes[0].mesh.triangles.len(), 1); + let camera = Camera::fit_meshes(&meshes, [0.0, 0.0, -1.0], [0.0, 1.0, 0.0], false, 1.0).unwrap(); + let image = render(&meshes, &camera, &RenderOptions { width: 40, height: 40, ..RenderOptions::default() }).unwrap(); + assert_eq!(pixel(&image, 1, 1), [255, 255, 255, 255], "background corner"); + assert_ne!(pixel(&image, 20, 24), [255, 255, 255, 255], "the triangle"); +} + +/// `model_render`'s end-to-end test embeds this builder's compressed triangle as hexadecimal; +/// this keeps that fixture the builder's output. +#[test] +fn the_automation_fixture_is_the_compressed_triangle() { + let hex: String = prc_file(&compressed_section(COMPRESSED_TRIANGLE)).iter().map(|b| format!("{b:02x}")).collect(); + let fixture = include_str!("../../automation/tests/it/model_render.rs"); + assert!(fixture.contains(&format!("const COMPRESSED_TRIANGLE_PRC_HEX: &str = \"{hex}\";")), "{hex}"); +} + #[test] fn damaged_streams_are_errors_not_panics() { let points = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]; diff --git a/crates/prc/src/bits.rs b/crates/prc/src/bits.rs index 837d281f3b..b6897e56f6 100644 --- a/crates/prc/src/bits.rs +++ b/crates/prc/src/bits.rs @@ -69,7 +69,6 @@ impl<'a> BitReader<'a> { } /// Array elements this reader may still decode. - #[cfg(test)] pub(crate) fn elements_left(&self) -> usize { self.budget.elements } diff --git a/crates/prc/src/tess.rs b/crates/prc/src/tess.rs index c9bffb30f4..a7614b0a5c 100644 --- a/crates/prc/src/tess.rs +++ b/crates/prc/src/tess.rs @@ -2,12 +2,14 @@ //! (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. +//! `TESS_3D_Compressed` is read field by field to its end [WD 7.8.9.7]. One whose normals are +//! recalculated is rebuilt into triangles ([`compressed`]); one that stores its normals, or +//! whose arrays do not fit the traversal, is not. //! //! 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 compressed; mod triangulate; use crate::arrays; @@ -49,7 +51,8 @@ pub enum Tessellation { Wire(Vec>), /// A `TESS_Markup` (PMI drawing), read past but not decoded. Markup, - /// A `TESS_3D_Compressed` mesh, read past but not rebuilt. + /// A `TESS_3D_Compressed` mesh, read past but not rebuilt: it stores its normals, or its + /// arrays do not fit the traversal. One that is rebuilt is a [`Tessellation::Mesh`]. #[non_exhaustive] Compressed { /// The entity's triangle count. @@ -65,7 +68,8 @@ 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]. + /// [`Self::positions`] [WD 7.8.5.2]. A rebuilt `TESS_3D_Compressed` keeps its traversal's + /// winding, which is not oriented. pub triangles: Vec<[u32; 3]>, /// `triangles` split by `TESS_Face`: face `i` is `triangles[faces[i].clone()]`. pub faces: Vec>, @@ -136,7 +140,7 @@ impl Ctx<'_, '_> { self.markup()?; Tessellation::Markup } - TESS_3D_COMPRESSED => Tessellation::Compressed { triangles: self.tess_3d_compressed()? }, + TESS_3D_COMPRESSED => self.tess_3d_compressed()?, t => return Err(malformed(format!("entity type {t} in the tessellation array"))), }); } @@ -295,23 +299,25 @@ impl Ctx<'_, '_> { 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. + /// `TESS_3D_Compressed` (173) after its type code [WD 7.8.9.7]: rebuilt into a mesh when + /// its normals are recalculated and its arrays fit, else read past to its end. 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 { + fn tess_3d_compressed(&mut self) -> Result { let r = &mut self.r; r.bit()?; // is_calculated r.bit()?; // has_faces - r.double()?; // tolerance + let tolerance = r.double()?; + let mut origin = [0.0f64; 3]; if self.version >= ORIGIN_FROM { - for _ in 0..3 { - r.float_as_bytes()?; + for o in &mut origin { + *o = f64::from(r.float_as_bytes()?); } } - arrays::compressed_integer_array(r)?; // point_array + let points = 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(); @@ -320,42 +326,50 @@ impl Ctx<'_, '_> { } 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()))); + let is_reference = arrays::bool_array(r, n)?; + let flagged = is_reference.iter().filter(|&&b| b).count(); + let references = arrays::compressed_indice_array(r, Some(flagged > 3))?; + if references.len() != flagged { + return Err(malformed(format!("{} point references for {flagged} flagged points", references.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 + let recalc = self.compressed_normals(t, faces)?; + let attributes = self.compressed_attributes(faces)?; + if !recalc { + return Ok(Tessellation::Compressed { triangles: t }); } - 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) + let arrays = compressed::Arrays { + tolerance, + origin, + points: &points, + edge_status: &edge_status, + triangles: t, + is_reference: &is_reference, + references: &references, + limit: self.r.elements_left(), + }; + // A walk that would outgrow the allowance left stops there, and the arrays stay + // compressed, as do arrays the walk does not fit. + let Ok(m) = compressed::rebuild(&arrays) else { + return Ok(Tessellation::Compressed { triangles: t }); + }; + // The rebuilt triangles, their styles and every face range are charged once the arrays + // fit and before any of them is made, so arrays that do not fit spend nothing. + let styled = attributes.styled(faces); + let kept = m.triangles.len().saturating_mul(triangle_elements(false, 0, styled)); + self.r.take_elements(kept.saturating_add(faces.saturating_mul(RANGE_ELEMENTS)))?; + let styles = if styled { attributes.styles(&face_of, faces) } else { Vec::new() }; + Ok(Tessellation::Mesh(by_face(m, &face_of, faces, &styles))) } - /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past. - fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result<(), PrcError> { + /// A compressed tessellation's normals, from `must_recalculate_normals` on, read past; + /// returns whether they are recalculated. + fn compressed_normals(&mut self, triangles: usize, faces: usize) -> Result { 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(()); + return Ok(true); } let angle_bits = u32::from(r.character()?); if angle_bits > MAX_ANGLE_BITS { @@ -365,7 +379,29 @@ impl Ctx<'_, '_> { 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(()) + Ok(false) + } + + /// A compressed tessellation's colours, line attributes, texture and behaviours, through + /// its added schema fields; returns what its faces' styles are made from. + fn compressed_attributes(&mut self, faces: usize) -> Result { + 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 + } + // is_multiple_line_attribute_on_face + let multi = if r.bit()? { arrays::bool_array(r, faces)? } else { Vec::new() }; + let line_attributes = arrays::short_array(r, 16)?; + if !r.bit()? { + self.compressed_texture_parameter()?; + if !self.r.bit()? { + arrays::bool_array(&mut self.r, faces)?; // face_has_texture + } + } + let behaviours = if self.r.bit()? { arrays::character_array(&mut self.r, 8, None, false)? } else { Vec::new() }; + self.schema.skip_added_fields(TESS_3D_COMPRESSED, &mut self.r)?; + Ok(FaceAttributes { multi, line_attributes, behaviours }) } /// `CompressedTextureParameter` [WD 7.8.9.8-7.8.9.9; pdf-issues #729, #749], read past. @@ -387,6 +423,71 @@ impl Ctx<'_, '_> { } } +/// Budget elements one face range (`Range`) takes. +const RANGE_ELEMENTS: usize = std::mem::size_of::>().div_ceil(8); + +/// What a compressed tessellation's faces are styled from [WD 7.8.9.5]: with no face flagged +/// `is_multiple_line_attribute_on_face` (`multi`), `line_attributes` holds one style index + 1 +/// per face (0 keeps the owner's graphics) and `behaviours` one behaviour byte per face. +struct FaceAttributes { + multi: Vec, + line_attributes: Vec, + behaviours: Vec, +} + +impl FaceAttributes { + /// Whether the faces give their triangles styles. The one-per-triangle layout of a flagged + /// face is not established, so such a tessellation keeps its owner's graphics, as does one + /// whose attributes and behaviours are all 0. + fn styled(&self, faces: usize) -> bool { + let behaviours = self.behaviours.len() == faces && self.behaviours.iter().any(|&b| b != 0); + !self.multi.iter().any(|&b| b) && self.line_attributes.len() == faces && (behaviours || self.line_attributes.iter().any(|&a| a != 0)) + } + + /// Per triangle, the style its face gives it; call only when [`Self::styled`]. + fn styles(&self, face_of: &[u32], faces: usize) -> Vec { + let behaviour = |f: usize| { + if self.behaviours.len() == faces { self.behaviours.get(f).and_then(|&b| u16::try_from(b).ok()).unwrap_or(0) } else { 0 } + }; + face_of + .iter() + .map(|&f| { + let f = f as usize; + TriangleStyle { style: self.line_attributes.get(f).and_then(|&a| u32::try_from(a).ok()).unwrap_or(0), behaviour: behaviour(f) } + }) + .collect() + } +} + +/// A rebuilt compressed mesh with its triangles grouped by face, in walk order within each, +/// so face `i` is `triangles[faces[i].clone()]` as for a `TESS_3D`. `faces` is charged to the +/// element budget by the caller. +fn by_face(m: compressed::Rebuilt, face_of: &[u32], faces: usize, styles: &[TriangleStyle]) -> TriangleMesh { + let mut order: Vec = (0..m.triangles.len()).collect(); + order.sort_by_key(|&i| face_of.get(i).copied().unwrap_or(0)); + let mut mesh = TriangleMesh { positions: m.positions, normals_recalculated: true, ..TriangleMesh::default() }; + mesh.faces = Vec::with_capacity(faces); + for &i in &order { + let f = face_of.get(i).map_or(0, |&f| f as usize); + while mesh.faces.len() <= f { + let at = mesh.triangles.len(); + mesh.faces.push(at..at); + } + if let (Some(&t), Some(r)) = (m.triangles.get(i), mesh.faces.last_mut()) { + mesh.triangles.push(t); + r.end = mesh.triangles.len(); + } + if let Some(&s) = styles.get(i) { + mesh.triangle_styles.push(s); + } + } + while mesh.faces.len() < faces { + let at = mesh.triangles.len(); + mesh.faces.push(at..at); + } + mesh +} + /// 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]>) { diff --git a/crates/prc/src/tess/compressed.rs b/crates/prc/src/tess/compressed.rs new file mode 100644 index 0000000000..53e082b4d2 --- /dev/null +++ b/crates/prc/src/tess/compressed.rs @@ -0,0 +1,353 @@ +//! Triangle reconstruction for a `TESS_3D_Compressed` (173) whose normals are recalculated +//! [WD 7.8.9, 7.8.9.2, 7.8.9.7]. +//! +//! The WD names the arrays but not the traversal that wrote them; the rules below are the ones +//! the arrays fit when every slot, reference and point is consumed exactly. They are inferred +//! from compressed tessellations and are not WD text. +//! +//! Traversal. A component starts with a seed triangle of three slots; every later triangle +//! takes one slot, its apex. A slot flagged in `point_is_reference` takes the next +//! `point_reference_array` value, a vertex number; any other slot makes the next vertex from +//! the next `point_array` triple. Triangle `[A B C]`, entered across `[A B]`, reads +//! `edge_status & 3`: bit 1 continues across `[C B]`, bit 0 across `[A C]`; with both, +//! `[C B]` is taken and `[A C]` pushed. With neither, pushed edges are popped, skipping any +//! already shared by two triangles; an empty stack starts the next component. A triangle +//! whose continuation edge is already shared by two triangles, while the other edge is open, +//! continues across the open one. +//! +//! Geometry, in units of `tolerance`: +//! - seed: `V0 = origin + d`, `V1 = V0 + d`, `V2 = mid(V0, V1) + d`; +//! - apex across `[P Q]` with the triangle's third vertex `W` [WD 7.8.9.2]: with +//! `O = (P + Q) * 0.5`, `L` and `H` the lower- and higher-numbered of `P`, `Q`, +//! `X = unitize(L - H)`, `Z = unitize((W - O) x X)` and `Y = unitize(Z x X)`, the apex is +//! `((O + d.x X) - d.y Y) - d.z Z`. +//! +//! Arithmetic. The encoder frames each apex on the points it already approximated [WD 7.8.9], +//! so the decoder repeats the encoder's floating point: a last-bit difference compounds from +//! apex to apex. `unitize` therefore matches the WD's `PrcPt::Unitize` pseudocode [WD 12.3] as +//! read, whose length is not exact, and the operations keep the order above. +//! +//! Fold. A triangle folded against its parent swaps bits 1 and 0 for its own continuation. +//! Without stored normals to orient it, a new apex lying in its parent's plane on the parent's +//! side (`d.z == 0`, `d.y > 0`) is folded. A component that does not fit is rewound and walked +//! again with the fold inverted at its second triangle, where an apex that is a reference +//! leaves no other trace of its fold. +//! +//! Only the three-statuses-per-triangle `edge_status` form is reconstructed (the first `T` +//! are read); the one-per-triangle form walks differently. + +use std::collections::HashMap; + +type V = [f64; 3]; + +/// The arrays of one `TESS_3D_Compressed` the walk reads. +pub(super) struct Arrays<'a> { + pub(super) tolerance: f64, + pub(super) origin: V, + pub(super) points: &'a [i64], + pub(super) edge_status: &'a [i32], + pub(super) triangles: usize, + pub(super) is_reference: &'a [bool], + pub(super) references: &'a [u32], + /// Budget elements the walk may hold at once (see [`Walk::held`]). + pub(super) limit: usize, +} + +/// The rebuilt vertices and triangles, in walk order. +#[derive(Debug)] +pub(super) struct Rebuilt { + pub(super) positions: Vec, + pub(super) triangles: Vec<[u32; 3]>, +} + +/// Rebuilds the mesh, or says why the arrays do not fit the traversal. +pub(super) fn rebuild(a: &Arrays<'_>) -> Result { + if a.edge_status.len() != a.triangles.saturating_mul(3) { + return Err("the one-status-per-triangle edge form is not reconstructed"); + } + let mut w = Walk::new(a.triangles, a.limit); + w.walk(a)?; + if w.slot != a.is_reference.len() || w.ri != a.references.len() || w.pi != a.points.len() { + return Err("the decoded triangles do not use up the stored arrays"); + } + Ok(Rebuilt { positions: w.pos, triangles: w.tris }) +} + +fn add(a: V, b: V) -> V { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +fn sub(a: V, b: V) -> V { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn scale(a: V, s: f64) -> V { + a.map(|c| c * s) +} +fn dot(a: V, b: V) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +fn cross(a: V, b: V) -> V { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} +fn key(a: u32, b: u32) -> (u32, u32) { + if a < b { (a, b) } else { (b, a) } +} + +/// `PrcPt::Length` [WD 12.3]: the squared length summed in `f64` and rounded to `f32`, then +/// Newton's square root in `f64`, seeded with that `f32` with its exponent halved. The printed +/// loop never advances its iterate; it is read as iterating until two successive values are +/// equal, `None` (the WD's -1) when 100 steps do not settle. +fn wd_length(a: V) -> Option { + let squared = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]) as f32; + let bits = squared.to_bits(); + let exponent = (bits >> 23) & 0xff; + let seed = if exponent > 127 { f32::from_bits((bits & !(0xff << 23)) | (((exponent - 127) / 2 + 127) << 23)) } else { squared }; + let squared = f64::from(squared); + let mut x = f64::from(seed); + for _ in 0..100 { + let next = 0.5 * (x + squared / x); + if next == x { + return Some(next); + } + x = next; + } + None +} + +/// `PrcPt::Unitize` [WD 12.3]: each component divided by [`wd_length`]; `None` below +/// `FLT_EPSILON`. +fn unitize(a: V) -> Option { + let l = wd_length(a).filter(|&l| l >= f64::from(f32::EPSILON))?; + Some([a[0] / l, a[1] / l, a[2] / l]) +} + +/// The `Z` of `PrcPt::MakeOrthoRep` [WD 12.3] on `X`: `unitize(X x (0, 1, 0))`, or +/// `X x (1, 0, 0)` when that is null, from `X` unitized again as the pseudocode does. +fn make_ortho_rep_z(x: V) -> Option { + let x = unitize(x)?; + unitize(cross(x, [0.0, 1.0, 0.0])).or_else(|| unitize(cross(x, [1.0, 0.0, 0.0]))) +} + +/// The apex across `[lo hi]` (`lo` the lower-numbered vertex) opposite `w`, `d` already scaled +/// by the tolerance [WD 7.8.9.2]. A null `Z` or `Y` takes `MakeOrthoRep(X)`'s `Z` as the WD +/// prescribes, with `Y = unitize(Z x X)` from the frame's own `X` (not the copy `MakeOrthoRep` +/// unitized again, whose last bits differ), turned a half-turn about `X` when the residue of +/// `(W - O) x X` points against that `Z`. A null `X`, an edge the WD's non-degeneracy rule +/// excludes, is used as it is. +fn apex(lo: V, hi: V, w: V, d: V) -> V { + let o = scale(add(lo, hi), 0.5); + let x = sub(lo, hi); + let x = unitize(x).unwrap_or(x); + let residue = cross(sub(w, o), x); + let (y, z) = unitize(residue) + .and_then(|z| Some((unitize(cross(z, x))?, z))) + .or_else(|| { + let z = make_ortho_rep_z(x)?; + let y = unitize(cross(z, x))?; + Some(if dot(residue, z) < 0.0 { (scale(y, -1.0), scale(z, -1.0)) } else { (y, z) }) + }) + .unwrap_or_default(); + sub(sub(add(o, scale(x, d[0])), scale(y, d[1])), scale(z, d[2])) +} + +/// Traversal state; `log` records every edge-count increment so a component can be rewound. +/// Everything it holds is checked against [`Arrays::limit`] after each triangle. +#[derive(Default)] +struct Walk { + pos: Vec, + tris: Vec<[u32; 3]>, + edges: HashMap<(u32, u32), u8>, + log: Vec<(u32, u32)>, + stack: Vec<(u32, u32, u32)>, + next: Option<(u32, u32, u32)>, + slot: usize, + ri: usize, + pi: usize, +} + +/// Where a component starts, to rewind to. +#[derive(Clone, Copy)] +struct Mark { + tri: usize, + pos: usize, + log: usize, + slot: usize, + ri: usize, + pi: usize, +} + +impl Walk { + /// Reserves room for `t` triangles, no more than `limit` elements' worth (two each). + fn new(t: usize, limit: usize) -> Self { + Walk { tris: Vec::with_capacity(t.min(limit / 2).min(1 << 20)), ..Walk::default() } + } + + fn mark(&self, tri: usize) -> Mark { + Mark { tri, pos: self.pos.len(), log: self.log.len(), slot: self.slot, ri: self.ri, pi: self.pi } + } + + fn rewind(&mut self, m: Mark) { + for k in self.log.drain(m.log..) { + if let Some(n) = self.edges.get_mut(&k) { + *n = n.saturating_sub(1); + } + } + self.pos.truncate(m.pos); + self.tris.truncate(m.tri); + self.stack.clear(); + self.next = None; + (self.slot, self.ri, self.pi) = (m.slot, m.ri, m.pi); + } + + /// One slot: `Some(v)` a reference to vertex `v`, `None` a new point. + fn take(&mut self, a: &Arrays<'_>) -> Result, &'static str> { + let flagged = *a.is_reference.get(self.slot).ok_or("slots run out")?; + self.slot = self.slot.saturating_add(1); + if !flagged { + return Ok(None); + } + let v = *a.references.get(self.ri).ok_or("references run out")?; + self.ri = self.ri.saturating_add(1); + self.get(v)?; + Ok(Some(v)) + } + + fn point(&mut self, a: &Arrays<'_>) -> Result { + let Some(&[x, y, z]) = a.points.get(self.pi..self.pi.saturating_add(3)) else { + return Err("points run out"); + }; + self.pi = self.pi.saturating_add(3); + let tol = a.tolerance; + Ok([x as f64 * tol, y as f64 * tol, z as f64 * tol]) + } + + fn get(&self, v: u32) -> Result { + self.pos.get(v as usize).copied().ok_or("a triangle refers to a vertex not yet decoded") + } + + fn push(&mut self, p: V) -> Result { + let id = u32::try_from(self.pos.len()).map_err(|_| "too many vertices")?; + self.pos.push(p); + Ok(id) + } + + /// The next triangle and its default fold: the seed, or the apex across the edge to + /// continue from. + fn triangle(&mut self, a: &Arrays<'_>) -> Result<([u32; 3], bool), &'static str> { + if let Some((p, q, w)) = self.next.take() { + if let Some(r) = self.take(a)? { + return Ok(([p, q, r], false)); + } + let (pp, qq, ww) = (self.get(p)?, self.get(q)?, self.get(w)?); + let (lo, hi) = if p < q { (pp, qq) } else { (qq, pp) }; + let fold = matches!(a.points.get(self.pi..self.pi.saturating_add(3)), Some(&[_, dy, 0]) if dy > 0); + let d = self.point(a)?; + let r = self.push(apex(lo, hi, ww, d))?; + return Ok(([p, q, r], fold)); + } + let mut v = [0u32; 3]; + for k in 0..3 { + let id = match self.take(a)? { + Some(r) => r, + None => { + let d = self.point(a)?; + let p = match k { + 0 => add(a.origin, d), + 1 => add(self.get(v[0])?, d), + _ => add(scale(add(self.get(v[0])?, self.get(v[1])?), 0.5), d), + }; + self.push(p)? + } + }; + if let Some(s) = v.get_mut(k) { + *s = id; + } + } + Ok((v, false)) + } + + /// Budget elements (8 bytes each) the walk's arrays hold, by length: growth can add up to + /// as much again in spare capacity. + fn held(&self) -> usize { + let pos = self.pos.len().saturating_mul(3); + let tris = self.tris.len().saturating_mul(2); + let edges = self.edges.len().saturating_mul(2); + let stack = self.stack.len().saturating_mul(2); + pos.saturating_add(tris).saturating_add(edges).saturating_add(self.log.len()).saturating_add(stack) + } + + fn closed(&self, e: (u32, u32, u32)) -> bool { + self.edges.get(&key(e.0, e.1)).copied().unwrap_or(0) >= 2 + } + + /// Adds one triangle; `flip` inverts its fold. + fn step(&mut self, a: &Arrays<'_>, status: i32, flip: bool) -> Result<(), &'static str> { + let ([ta, tb, tc], fold) = self.triangle(a)?; + for (x, y) in [(ta, tb), (tb, tc), (tc, ta)] { + let n = self.edges.entry(key(x, y)).or_insert(0); + *n = n.saturating_add(1); + let shared = *n; + self.log.push(key(x, y)); + if shared > 2 { + return Err("an edge is shared by more than two triangles"); + } + } + self.tris.push([ta, tb, tc]); + if self.held() > a.limit { + return Err("the rebuild needs more than the decoded-element allowance left"); + } + let (mut left, mut right) = ((tc, tb, ta), (ta, tc, tb)); + if fold != flip { + std::mem::swap(&mut left, &mut right); + } + let (taken, other) = if status & 2 != 0 { (left, right) } else { (right, left) }; + if status & 3 != 0 && self.closed(taken) && !self.closed(other) { + std::mem::swap(&mut left, &mut right); + } + match (status & 2 != 0, status & 1 != 0) { + (true, true) => { + self.next = Some(left); + self.stack.push(right); + } + (true, false) => self.next = Some(left), + (false, true) => self.next = Some(right), + (false, false) => {} + } + while self.next.is_none() { + let Some(e) = self.stack.pop() else { break }; + if !self.closed(e) { + self.next = Some(e); + } + } + Ok(()) + } + + /// Walks every component. One that fails is rewound and walked once more with the fold + /// inverted at its second triangle, so the walk is at most twice the triangle count. + fn walk(&mut self, a: &Arrays<'_>) -> Result<(), &'static str> { + let mut start = self.mark(0); + let mut flip_at = None; + let mut i = 0; + while i < a.triangles { + if self.next.is_none() && !(flip_at.is_some() && i == start.tri) { + start = self.mark(i); + flip_at = None; + } + let status = a.edge_status.get(i).copied().unwrap_or(0); + match self.step(a, status, flip_at == Some(i)) { + Ok(()) => i = i.saturating_add(1), + Err(e) => { + if flip_at.is_some() || i == start.tri { + return Err(e); + } + self.rewind(start); + flip_at = Some(start.tri.saturating_add(1)); + i = start.tri; + } + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/prc/src/tess/compressed/tests.rs b/crates/prc/src/tess/compressed/tests.rs new file mode 100644 index 0000000000..d174ff657e --- /dev/null +++ b/crates/prc/src/tess/compressed/tests.rs @@ -0,0 +1,299 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +const S: f64 = std::f64::consts::SQRT_2; + +fn run(points: &[i64], status: &[i32], t: usize, is_ref: &[bool], refs: &[u32]) -> Result { + rebuild(&Arrays { + tolerance: 0.5, + origin: [10.0, 0.0, 0.0], + points, + edge_status: status, + triangles: t, + is_reference: is_ref, + references: refs, + limit: usize::MAX, + }) +} + +fn close(a: V, b: V) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() < 1e-9) +} + +/// Seed `[0 1 2]`, then the apex across `[2 1]`: `X` runs from vertex 2 to vertex 1, and +/// each of `d`'s three components lands on its own axis with its own sign. Tolerance 0.5 +/// halves every step. +#[test] +fn a_seed_and_one_apex_rebuild() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 2, -6, 2]; + let m = run(&pts, &[2, 0, 0, 0, 0, 0], 2, &[false; 4], &[]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3]]); + let p: Vec = m.positions.iter().map(|v| [v[0] - 10.0, v[1], v[2]]).collect(); + assert!(close(p[0], [0.0, 0.0, 0.0]) && close(p[1], [2.0, 0.0, 0.0])); + assert!(close(p[2], [0.0, 2.0, 0.0]), "{:?}", p[2]); + assert!(close(p[3], [1.0 + 2.0 * S, 1.0 + S, -1.0]), "{:?}", p[3]); +} + +/// The walk stops once its arrays would hold more than `limit` elements: the seed-and-apex +/// pair ends holding four vertices (12), two triangles (4), five edges (10) and six logged +/// edges (6), 32 in all. +#[test] +fn a_walk_past_its_limit_stops() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 2, -6, 2]; + let with = |limit| { + rebuild(&Arrays { + tolerance: 0.5, + origin: [0.0; 3], + points: &pts, + edge_status: &[2, 0, 0, 0, 0, 0], + triangles: 2, + is_reference: &[false; 4], + references: &[], + limit, + }) + }; + assert_eq!(with(32).unwrap().triangles.len(), 2); + assert_eq!(with(31).unwrap_err(), "the rebuild needs more than the decoded-element allowance left"); +} + +/// The triangles reserved up front fit the limit too. +#[test] +fn the_walk_reserves_within_its_limit() { + assert!(Walk::new(1 << 20, 10).tris.capacity() < 1 << 20); + assert!(Walk::new(1 << 20, 10).tris.capacity() <= 5); + assert!(Walk::new(4, usize::MAX).tris.capacity() >= 4); +} + +/// Bit 0 alone continues across `[A C]`, keeping `W = B`. +#[test] +fn the_right_edge_is_entered_from_a_to_c() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 0, -2, 0]; + let m = run(&pts, &[1, 0, 0, 0, 0, 0], 2, &[false; 4], &[]).unwrap(); + assert_eq!(m.triangles[1], [0, 2, 3]); +} + +/// A reference slot reuses a vertex; a closed pushed edge is skipped and the next component +/// starts from a fresh seed. +#[test] +fn references_and_components() { + let pts = [0; 9 + 9]; + let is_ref = [false, false, false, true, false, false, false]; + let m = run(&pts, &[3, 0, 0, 0, 0, 0, 0, 0, 0], 3, &is_ref, &[0]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 0], [3, 4, 5]]); +} + +/// A double-sided panel: four coplanar vertices, four triangles. The second triangle's apex +/// folds back onto the seed's side, so its own continuation takes `[A C]` first; the last two +/// apexes are the seed's first two vertices. +#[test] +fn a_folded_apex_swaps_its_continuation() { + let is_ref = [false, false, false, false, true, true]; + let status = [3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let m = run(&[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, 3, 0], &status, 4, &is_ref, &[0, 1]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3], [2, 3, 0], [0, 3, 1]]); + // The same panel unfolded is rescued by the component retry. + let m = run(&[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, -3, 0], &status, 4, &is_ref, &[0, 1]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3], [2, 3, 0], [0, 3, 1]]); +} + +/// The fourth apex `(1, 3, 0)` lies in its parent's plane on the parent's side, so its +/// triangle folds and continues across `[A C]` to the reference. +#[test] +fn a_fold_past_the_retry_point_is_detected() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 0, -3, 1, 1, 3, 0]; + let is_ref = [false, false, false, false, false, true]; + let m = run(&pts, &[3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], 4, &is_ref, &[0]).unwrap(); + assert_eq!(m.triangles[3], [3, 4, 0]); +} + +/// A panel whose fold apex is a reference carries no fold signal: the unfolded walk closes an +/// edge it still needs, so the component is rewound and walked again folded at its second +/// triangle. +#[test] +fn a_reference_fold_is_found_by_retrying_the_component() { + let pts: Vec = (1..=30).collect(); + let mut is_ref = vec![false; 9]; + is_ref.extend([true, true, false, true, true, true]); + let mut status = vec![0; 21]; + status[3..7].copy_from_slice(&[3, 3, 2, 0]); + let m = run(&pts, &status, 7, &is_ref, &[0, 3, 6, 0, 3]).unwrap(); + assert_eq!(m.triangles[3..], [[0, 3, 9], [9, 3, 6], [9, 6, 0], [0, 6, 3]]); +} + +/// A tetrahedron closed by two references: after `[3 1 0]` the continuation edge `[0 1]` is +/// already shared twice, so the walk takes the open `[3 0]` instead. +#[test] +fn a_closed_continuation_edge_gives_way_to_the_open_one() { + let pts = [0, 0, 0, 4, 0, 0, -2, 4, 0, 0, 0, 4]; + let mut status = [0; 12]; + status[..3].copy_from_slice(&[2, 2, 2]); + let mut is_ref = [false; 6]; + is_ref[4..].copy_from_slice(&[true, true]); + let m = run(&pts, &status, 4, &is_ref, &[0, 2]).unwrap(); + assert_eq!(m.triangles, [[0, 1, 2], [2, 1, 3], [3, 1, 0], [3, 0, 2]]); +} + +#[test] +fn arrays_that_do_not_fit_are_refused() { + let pts = [0; 9]; + let ok = [false; 3]; + assert!(run(&pts, &[0, 0, 0], 1, &ok, &[]).is_ok()); + assert!(run(&pts, &[0], 1, &ok, &[]).is_err(), "T form"); + assert!(run(&[0; 12], &[0, 0, 0], 1, &ok, &[]).is_err(), "points left"); + assert!(run(&[0; 6], &[0, 0, 0], 1, &ok, &[]).is_err(), "points short"); + assert!(run(&pts, &[0, 0, 0], 1, &[false; 4], &[]).is_err(), "slots left"); + assert!(run(&[0; 6], &[0, 0, 0], 1, &[false, false, true], &[7]).is_err(), "forward reference"); + assert!(run(&[0; 9], &[0, 0, 0], 1, &[false, false, false, true], &[0]).is_err(), "references left"); + // A third triangle on the seed's edge [0 1], each apex a reference. + let three = [false, false, false, true, true]; + let shared = run(&[0; 9], &[2, 2, 0, 0, 0, 0, 0, 0, 0], 3, &three, &[0, 0]).map(|_| ()); + assert_eq!(shared, Err("an edge is shared by more than two triangles")); +} + +/// `PrcPt::Length` rounds the squared length to `f32` and takes Newton's root from a halved +/// exponent, which can settle an ulp off the correctly rounded root; `Unitize` refuses +/// lengths below `FLT_EPSILON` [WD 12.3]. +#[test] +fn length_and_unitize_follow_the_wd_pseudocode() { + assert_eq!(wd_length([3.0, 4.0, 0.0]), Some(5.0)); + let newton = f64::from_bits(S.to_bits() - 1); + assert_eq!(wd_length([1.0, 1.0, 0.0]), Some(newton)); + let long = 1.0 + 2f64.powi(-30); + assert_eq!(wd_length([long, 0.0, 0.0]), Some(1.0)); + assert_eq!(unitize([0.0, long, 0.0]), Some([0.0, long, 0.0])); + assert_eq!(unitize([1e-8, 0.0, 0.0]), None); + assert_eq!(unitize([0.0; 3]), None); +} + +/// A `W` on the edge's line leaves `Z` null, so the frame comes from `MakeOrthoRep(X)`: `Z` +/// from `X x (0, 1, 0)`, or from `X x (1, 0, 0)` when `X` is along `Y` [WD 7.8.9.2, 12.3]. +#[test] +fn a_null_z_takes_the_ortho_rep_of_x() { + let d = [1.0, 2.0, 3.0]; + assert_eq!(apex([0.0; 3], [-2.0, 0.0, 0.0], [5.0, 0.0, 0.0], d), [0.0, -2.0, -3.0]); + assert_eq!(apex([0.0; 3], [0.0, -2.0, 0.0], [0.0, 5.0, 0.0], d), [-2.0, 0.0, 3.0]); +} + +/// A degenerate frame is turned a half-turn about `X` when the sub-epsilon residue of +/// `(W - O) x X` points against `MakeOrthoRep`'s `Z`, and kept when it points along it. +#[test] +fn a_degenerate_frame_follows_its_residue() { + let (lo, hi, d) = ([0.0; 3], [-2.0, 0.0, 0.0], [1.0, 2.0, 3.0]); + assert_eq!(apex(lo, hi, [5.0, 1e-9, 0.0], d), [0.0, 2.0, 3.0]); + assert_eq!(apex(lo, hi, [5.0, -1e-9, 0.0], d), [0.0, -2.0, -3.0]); +} + +/// A degenerate frame's `Y` comes from the frame's own `X`, whose length `unitize` left off by +/// a few 1e-8, not from the copy `MakeOrthoRep` unitized again: the two differ by that much, +/// and the walk would carry the difference into later apexes. +#[test] +fn a_degenerate_frame_takes_y_from_its_own_x() { + let (lo, hi) = ([0.1, 0.2, 0.3], [0.7, -0.4, 0.9]); + let o = scale(add(lo, hi), 0.5); + let x = unitize(sub(lo, hi)).unwrap(); + let w = add(o, scale(x, 0.37)); + let d = [1.0, 2.0, 3.0]; + let z = make_ortho_rep_z(x).unwrap(); + let p = apex(lo, hi, w, d); + let own = unitize(cross(z, x)).unwrap(); + let again = unitize(cross(z, unitize(x).unwrap())).unwrap(); + assert_ne!(own, again, "the probe must tell the two Ys apart"); + let sign = if dot(cross(sub(w, o), x), z) < 0.0 { -1.0 } else { 1.0 }; + let expect = |y: V| sub(sub(add(o, scale(x, d[0])), scale(y, sign * d[1])), scale(z, sign * d[2])); + assert_eq!(p, expect(own)); + assert_ne!(p, expect(again)); +} + +/// A strip of thin, non-planar triangles compressed by an encoder written out from the WD: +/// each point is expressed in the frame of the working mesh, rounded to the tolerance and +/// reinjected [WD 7.8.9, 7.8.9.2], with `PrcPt::Length` and `Unitize` transcribed from the +/// pseudocode [WD 12.3] as read. The decoder must land exactly on that working mesh, which +/// keeps every vertex within the rounding bound of its source. +#[test] +fn a_strip_decodes_onto_the_encoders_working_mesh() { + fn length(v: V) -> f64 { + let f = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) as f32; + let mut bits = f.to_bits(); + let exponent = (bits >> 23) & 0xff; + if exponent > 127 { + bits = (bits & 0x807f_ffff) | (((exponent - 127) / 2 + 127) << 23); + } + let (squared, mut x0) = (f64::from(f), f64::from(f32::from_bits(bits))); + for _ in 0..100 { + let xi = 0.5 * (x0 + squared / x0); + if xi == x0 { + return xi; + } + x0 = xi; + } + -1.0 + } + fn unitized(v: V) -> V { + let l = length(v); + assert!(l >= f64::from(f32::EPSILON), "null axis"); + [v[0] / l, v[1] / l, v[2] / l] + } + let (tol, n) = (1e-4, 48); + let mut state = 0x9e37_79b9_7f4a_7c15_u64; + let mut jitter = |scale: f64| { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((state >> 11) as f64 / (1u64 << 53) as f64 - 0.5) * scale + }; + // Two rails 4 apart, 0.05 between a rail's points, 0.02 of relief. + let source: Vec = (0..n) + .map(|i| { + let (along, rail) = (i as f64 * 0.025, (i % 2) as f64 * 4.0); + [along + jitter(0.01), rail + jitter(0.01), jitter(0.02)] + }) + .collect(); + let round = |v: f64| (v / tol).round() as i64; + let (mut points, mut work) = (Vec::new(), Vec::::new()); + for (i, target) in source.iter().take(3).enumerate() { + let base = match i { + 0 => [0.0; 3], + 1 => work[0], + _ => scale(add(work[0], work[1]), 0.5), + }; + let d = [0, 1, 2].map(|c| round(target[c] - base[c])); + points.extend(d); + work.push(add(base, d.map(|c| c as f64 * tol))); + } + // Triangle k enters across vertices k and k + 1, opposite k - 1, and continues right on + // odd k, left on even k, unless it folds. + let mut status = vec![0; 3 * (n - 2)]; + status[0] = 2; + for k in 1..n - 2 { + let (lo, hi, w) = (work[k], work[k + 1], work[k - 1]); + let o = scale(add(hi, lo), 0.5); + let x = unitized(sub(lo, hi)); + let z = unitized(cross(sub(w, o), x)); + let y = unitized(cross(z, x)); + let r = sub(source[k + 2], o); + let d = [round(dot(r, x)), round(-dot(r, y)), round(-dot(r, z))]; + points.extend(d); + let [dx, dy, dz] = d.map(|c| c as f64 * tol); + work.push(sub(sub(add(o, scale(x, dx)), scale(y, dy)), scale(z, dz))); + if k < n - 3 { + let fold = d[2] == 0 && d[1] > 0; + status[k] = if (k % 2 == 1) != fold { 1 } else { 2 }; + } + } + let m = rebuild(&Arrays { + tolerance: tol, + origin: [0.0; 3], + points: &points, + edge_status: &status, + triangles: n - 2, + is_reference: &vec![false; n], + references: &[], + limit: usize::MAX, + }) + .unwrap(); + assert_eq!(m.triangles[1..4], [[2, 1, 3], [2, 3, 4], [4, 3, 5]]); + assert_eq!(m.positions, work); + for (p, s) in m.positions.iter().zip(&source) { + let off = dot(sub(*p, *s), sub(*p, *s)).sqrt() / tol; + assert!(off < 0.87, "{off} tolerances off"); + } +} diff --git a/crates/prc/src/tess/tests.rs b/crates/prc/src/tess/tests.rs index 26219f2865..253e1c9d5e 100644 --- a/crates/prc/src/tess/tests.rs +++ b/crates/prc/src/tess/tests.rs @@ -529,12 +529,281 @@ fn a_compressed_mesh_is_read_to_its_end() { 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}"); + assert!(matches!(t[1], Tessellation::Mesh(_)), "full={full} v={v}: {t:?}"); + // Stored normals and the one-status-per-triangle form are not rebuilt; the + // recalculated 3T seed is. + if full || !three_t { + assert!(matches!(t[0], Tessellation::Compressed { triangles: 1, .. }), "full={full} v={v}: {t:?}"); + } else { + let m = mesh(&t[0]); + assert_eq!((m.triangles.as_slice(), m.faces.as_slice()), ([[0, 1, 2]].as_slice(), std::slice::from_ref(&(0..1))), "v={v}"); + assert!(m.normals_recalculated && m.normals.is_empty()); + assert_eq!(m.triangle_styles, [TriangleStyle { style: 1, behaviour: 0 }]); + // Each point component is 1, in units of the 0.001 tolerance, from the origin 0. + let want = [0.001, 0.002, 0.0025]; + assert!(m.positions.iter().zip(want).all(|(p, c)| p.iter().all(|x| (x - c).abs() < 1e-12)), "{:?}", m.positions); + } } } } +/// A `TESS_3D_Compressed` (authoring version 8137, origin 0) whose normals are recalculated, +/// with `points` in units of `tolerance`, the 3T `status`, each triangle's face, the slots' +/// reference flags, the references and one line attribute per face; every other optional +/// branch absent. +struct Packed<'a> { + tolerance: f64, + points: &'a [i64], + status: &'a [u8], + face_of: &'a [u32], + /// The width of each face index; 8 unless an index needs more. + face_bits: u32, + is_ref: &'a [bool], + refs: &'a [u32], + line_attributes: &'a [u16], +} + +/// A `CompressedIndiceArray` [WD 11.9] of `bits`-wide deltas, `implicit` with no compressed +/// flag. +fn indices(w: &mut W, v: &[u32], implicit: bool, bits: u32) { + if !implicit { + w.bit(false); + } + w.uint(u32::try_from(v.len()).unwrap()); + for i in 0..v.len() { + w.put(if i == 0 { u64::from(bits) } else { 0 }, 8); + } + let mut prev = 0i64; + for &x in v { + let d = i64::from(x) - prev; + w.bit(d < 0).put(d.unsigned_abs(), bits - 1); + prev = i64::from(x); + } +} + +fn packed(w: &mut W, c: &Packed<'_>) { + w.uint(173).bit(false).bit(true).double(c.tolerance); + w.put(0, 32).put(0, 32).put(0, 32); + // point_array: 8-bit sign-magnitude integers. + w.bit(false).uint(u32::try_from(c.points.len()).unwrap()); + for _ in c.points { + w.put(8, 8); + } + for &p in c.points { + w.bit(p < 0).put(p.unsigned_abs(), 7); + } + w.bit(false).uint(u32::try_from(c.status.len()).unwrap()); + for &e in c.status { + w.put(u64::from(e), 8); + } + indices(w, c.face_of, false, c.face_bits); + w.uint(u32::try_from(c.is_ref.len()).unwrap()); + for &b in c.is_ref { + w.bit(b); + } + assert!(c.refs.len() <= 3, "more than three references are Huffman-coded"); + indices(w, c.refs, true, 8); + // must_recalculate_normals, normal_is_reversed, crease angle, flags; no colours, no + // multiple line attributes. + w.bit(true); + for _ in c.face_of { + w.bit(false); + } + w.double(0.5).put(0, 8).bit(false).bit(false); + w.bit(false).uint(u32::try_from(c.line_attributes.len()).unwrap()); + for &a in c.line_attributes { + w.put(u64::from(a & 0xff), 8).put(u64::from(a >> 8), 8); + } + // No texture, no behaviours. + w.bit(true).bit(false); +} + +/// A double-sided panel of two faces packed in walk order, its fold apex and its last two +/// apexes references: rebuilt into a mesh grouped by face, each triangle styled by its face. +#[test] +fn a_recalculated_compressed_mesh_is_rebuilt_by_face() { + let mut b = W::default(); + packed( + &mut b, + &Packed { + tolerance: 0.5, + points: &[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, 3, 0], + status: &[3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], + face_of: &[1, 0, 1, 0], + face_bits: 8, + is_ref: &[false, false, false, false, true, true], + refs: &[0, 1], + line_attributes: &[3, 0], + }, + ); + let w = section(1, &b); + let bytes = w.bytes(); + let schema = Schema::default(); + let mut ctx = Ctx::new(BitReader::new(&bytes), &schema, 8137); + let t = ctx.file_structure_tessellation().unwrap(); + assert_eq!(ctx.r.position(), w.len()); + let m = mesh(&t[0]); + // Walk order [0 1 2] [2 1 3] [2 3 0] [0 3 1]; faces 1, 0, 1, 0. + assert_eq!(m.triangles, [[2, 1, 3], [0, 3, 1], [0, 1, 2], [2, 3, 0]]); + assert_eq!(m.faces, [0..2, 2..4]); + assert_eq!(m.positions[..3], [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]]); + let style = |style| TriangleStyle { style, behaviour: 0 }; + assert_eq!(m.triangle_styles, [style(3), style(3), style(0), style(0)]); + assert!(m.normals_recalculated); +} + +/// Arrays the walk cannot fit stay a [`Tessellation::Compressed`], read to their end. +#[test] +fn a_compressed_mesh_that_does_not_fit_is_left_compressed() { + let mut b = W::default(); + // One point too many for a lone seed. + let c = Packed { + tolerance: 0.5, + points: &[0; 12], + status: &[0, 0, 0], + face_of: &[0], + face_bits: 8, + is_ref: &[false; 3], + refs: &[], + line_attributes: &[0], + }; + packed(&mut b, &c); + let w = section(1, &b); + let t = decode(&w, &Schema::default(), 8137).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }]), "{t:?}"); +} + +/// A face index near `u32::MAX` claims four billion faces: once the triangles rebuild, they are +/// charged before any face range is made, so the decode stops at the element allowance instead +/// of allocating them. +#[test] +fn a_huge_face_index_is_charged_not_allocated() { + let mut b = W::default(); + let c = Packed { + tolerance: 0.5, + points: &[0; 9], + status: &[0, 0, 0], + face_of: &[u32::MAX - 1], + face_bits: 33, + is_ref: &[false; 3], + refs: &[], + line_attributes: &[], + }; + packed(&mut b, &c); + let r = decode(§ion(1, &b), &Schema::default(), 8137); + assert!(matches!(r, Err(PrcError::TooManyElements { .. })), "{r:?}"); +} + +/// The same huge face index on arrays the walk cannot fit charges nothing for triangles or +/// faces: the tessellation stays compressed and the file reads on. +#[test] +fn arrays_that_do_not_fit_spend_no_triangles_or_faces() { + let mut b = W::default(); + let c = Packed { + tolerance: 0.5, + points: &[0; 12], + status: &[0, 0, 0], + face_of: &[u32::MAX - 1], + face_bits: 33, + is_ref: &[false; 3], + refs: &[], + line_attributes: &[], + }; + packed(&mut b, &c); + let t = decode(§ion(1, &b), &Schema::default(), 8137).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 1, .. }]), "{t:?}"); +} + +/// The two-face panel of [`a_recalculated_compressed_mesh_is_rebuilt_by_face`], its second +/// reference `last_ref`. +fn panel(last_ref: u32) -> W { + let mut b = W::default(); + packed( + &mut b, + &Packed { + tolerance: 0.5, + points: &[0, 0, 0, 4, 0, 0, -2, 4, 0, 1, 3, 0], + status: &[3, 3, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], + face_of: &[1, 0, 1, 0], + face_bits: 8, + is_ref: &[false, false, false, false, true, true], + refs: &[0, last_ref], + line_attributes: &[3, 0], + }, + ); + section(1, &b) +} + +/// A rebuilt mesh is charged its triangles, each with its style, and each face range at its +/// size; the same arrays left compressed (a reference to a vertex not yet made) are charged +/// neither. +#[test] +fn a_rebuilt_compressed_mesh_is_charged_its_triangles_and_faces() { + let fits = panel(1); + let t = decode(&fits, &Schema::default(), 8137).unwrap(); + assert_eq!(mesh(&t[0]).triangles.len(), 4); + let left = panel(9); + let t = decode(&left, &Schema::default(), 8137).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 4, .. }]), "{t:?}"); + assert_eq!(spent(&fits) - spent(&left), 4 * triangle_elements(false, 0, true) + 2 * RANGE_ELEMENTS); + assert_eq!(triangle_elements(false, 0, true), 3); + #[cfg(target_pointer_width = "64")] + assert_eq!(RANGE_ELEMENTS, 2); +} + +/// A rebuild whose walk would outgrow the allowance left keeps the arrays compressed, even +/// when the rebuilt mesh alone would have fit, and the stream reads on. +#[test] +fn a_compressed_rebuild_is_bounded_by_the_allowance_left() { + let fits = panel(1); + let bytes = fits.bytes(); + let schema = Schema::default(); + let read = |n| Ctx::new(BitReader::with_elements(&bytes, n), &schema, 8137).file_structure_tessellation(); + let arrays = spent(&panel(9)); + let mesh_alone = spent(&fits) - arrays; + let t = read(arrays + mesh_alone).unwrap(); + assert!(matches!(t[..], [Tessellation::Compressed { triangles: 4, .. }]), "{t:?}"); + assert!(matches!(read(crate::MAX_DECODED_ELEMENTS).unwrap()[..], [Tessellation::Mesh(_)])); +} + +/// A face's nonzero behaviour styles its triangles even when its line attribute is 0 (the +/// owner's graphics); all zero, the faces give no styles. +#[test] +fn a_compressed_face_behaviour_alone_styles_its_triangles() { + let attributes = |behaviours: Vec| FaceAttributes { multi: Vec::new(), line_attributes: vec![0], behaviours }; + let a = attributes(vec![0x30]); + assert!(a.styled(1)); + assert_eq!(a.styles(&[0, 0], 1), [TriangleStyle { style: 0, behaviour: 0x30 }; 2]); + assert!(!attributes(vec![0]).styled(1)); + assert!(!attributes(Vec::new()).styled(1)); +} + +fn rebuilt(triangles: usize) -> compressed::Rebuilt { + compressed::Rebuilt { positions: vec![[0.0; 3]; 3], triangles: (0..triangles).map(|i| [0, 1, u32::try_from(i).unwrap()]).collect() } +} + +/// Triangles are grouped by face in walk order within each, faces no triangle names (between +/// and after) get empty ranges, and each style moves with its triangle. +#[test] +fn rebuilt_triangles_are_grouped_by_face_with_their_styles() { + let face_of = [2, 0, 2, 1, 0]; + let m = by_face(rebuilt(5), &face_of, 5, &[]); + let apex: Vec = m.triangles.iter().map(|t| t[2]).collect(); + assert_eq!(apex, [1, 4, 3, 0, 2]); + assert_eq!(m.faces, [0..2, 2..3, 3..5, 5..5, 5..5]); + assert!(m.triangle_styles.is_empty()); + assert!(m.normals_recalculated); + + let style = |style| TriangleStyle { style, behaviour: u16::try_from(style).unwrap() }; + let styles: Vec = face_of.iter().map(|&f| style(f + 1)).collect(); + let m = by_face(rebuilt(5), &[2, 0, 2, 3, 0], 4, &styles); + let apex: Vec = m.triangles.iter().map(|t| t[2]).collect(); + assert_eq!(apex, [1, 4, 0, 2, 3]); + assert_eq!(m.faces, [0..2, 2..2, 2..4, 4..5]); + assert_eq!(m.triangle_styles, [style(1), style(1), style(3), style(3), style(2)]); +} + #[test] fn refusals_and_damage_are_errors() { // Edge statuses neither T nor 3T long. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c721ce18e9..e533ecf6c7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -10062,8 +10062,8 @@ milestone = "M12" status = "partial" commands = [] tools = ["model_render"] -evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/automation/tests/it/model_render.rs::an_orbit_frames_the_model_from_a_named_view_and_its_moves", "crates/prc-render/src/nav/orbit_tests.rs::a_pressed_point_off_centre_keeps_its_place_as_the_view_turns", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther"] -notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, or by orbit navigation (named views about a chosen up axis, turn, zoom about a picture point, pan) from prc-render's GUI-free nav module, with headlight shading. Missing: the 3D view in the app (no activation, no on-screen navigation; nav is not wired to a viewer yet), walk and fly navigation, U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellation, picking, cross sections, the model tree, and measurements" +evidence = ["crates/automation/tests/it/model_render.rs::a_prc_model_is_drawn_over_the_background", "crates/automation/tests/it/model_render.rs::an_orbit_frames_the_model_from_a_named_view_and_its_moves", "crates/prc-render/src/nav/orbit_tests.rs::a_pressed_point_off_centre_keeps_its_place_as_the_view_turns", "crates/annot/src/model3d/tests.rs::a_3dref_leads_to_the_stream_it_names", "crates/prc-render/tests/model.rs::a_synthetic_prc_renders_its_nearer_triangle_over_the_farther", "crates/prc-render/tests/model.rs::a_compressed_tessellation_is_rebuilt_and_rendered", "crates/automation/tests/it/model_render.rs::a_compressed_prc_model_is_rebuilt_and_drawn"] +notes = "headless only: model_render draws a page's PRC model (through a /3DRef too) to a PNG, framed from a chosen direction, or by orbit navigation (named views about a chosen up axis, turn, zoom about a picture point, pan) from prc-render's GUI-free nav module, with headlight shading. Missing: the 3D view in the app (no activation, no on-screen navigation; nav is not wired to a viewer yet), walk and fly navigation, U3D (an error), the annotation's /3DV views, lighting schemes and /RM render modes, materials and textures, compressed tessellations that store their normals (those whose normals are recalculated are rebuilt), picking, cross sections, the model tree, and measurements" [[feature]] id = "misc.3d-add" From 120c62fb82f41998ecdf6dbbfe39f27d4c9a410e Mon Sep 17 00:00:00 2001 From: Ken Mantle Date: Thu, 12 Nov 2026 11:58:31 -0500 Subject: [PATCH 20/21] Auto-tag Apply tags headings by size and weight (#7062) (#7069) * feat(a11y): auto-tag Apply tags headings by size and weight Apply now chooses H1-H6 or P per text object instead of P for all. The body style is the half-point size showing the most characters (bold when most of those are bold). A short object (at most 3 lines, 200 characters, with a letter) is a heading when it is 1.15x the body, or bold at 0.9x the body over a regular body in at most 2 lines. Heading styles rank across the document, larger and bolder first, capped at H6, and a level never skips past the one before it so heading-nesting passes. Size is Tf x Tm x CTM with q/Q tracked; bold is Metrics.bold. Text in render mode 3 (an OCR layer) is not counted. Font metrics are measured once per font object per document, at most 256 fonts. AutoTagResult gains a headings count. Parity, gaps, roadmap and the apply tool's note say headings are tagged. Fixes #7062. Co-Authored-By: Claude Opus 5.5 * fix(a11y): rank heading styles by map and skip clip-only text Look up each heading style's rank in a map built once after sorting, so ranking is linear in the number of text objects. Text in render mode 7 (clip only) paints nothing, so it is excluded from heading evidence like mode 3. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: KenM76 Co-authored-by: Claude Opus 5.5 --- crates/a11y/src/autotag.rs | 66 +++- crates/a11y/src/autotag_roles.rs | 382 +++++++++++++++++++++++ crates/a11y/src/lib.rs | 1 + crates/a11y/src/tests.rs | 14 +- crates/automation/src/a11y.rs | 2 +- crates/automation/tests/it/automation.rs | 25 ++ docs/gaps.md | 2 +- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +- 9 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 crates/a11y/src/autotag_roles.rs diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 59d30930ff..bf3df8d0a4 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -1,7 +1,8 @@ //! Create a valid, reviewable structure tree for untagged PDFs. //! -//! Each text object becomes a paragraph, which can then be retagged with the existing -//! tag-property tool after human review. Painted paths, shadings and images outside text objects +//! Each text object becomes a paragraph, or a heading when its size or weight sets it apart from +//! the body text ([`crate::autotag_roles`]); either can be retagged with the existing tag-property +//! tool after human review. Painted paths, shadings and images outside text objects //! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check. use std::collections::{HashMap, HashSet}; @@ -9,6 +10,8 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Op, Pieces, serialize_ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use crate::autotag_roles::{self, Evidence}; + const MAX_PAGES: usize = 1_000; const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_OPERATORS: usize = 100_000; @@ -22,6 +25,7 @@ const MAX_TEXT_OBJECTS: usize = 4_096; pub struct AutoTagResult { pub pages: usize, pub paragraphs: usize, + pub headings: usize, } #[derive(Debug, thiserror::Error, PartialEq)] @@ -44,6 +48,7 @@ struct Plan { page: ObjRef, ops: Vec, spans: Vec<(usize, usize)>, + evidence: Vec, artifacts: HashMap, } @@ -98,6 +103,14 @@ fn page_xobjects(doc: &Document, page: &Dict) -> Dict { .unwrap_or_default() } +fn page_fonts(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|r| r.get(b"Font").map(|x| doc.resolve(x)))) + .and_then(|x| x.as_dict().cloned()) + .unwrap_or_default() +} + fn harmless_outside(op: &[u8]) -> bool { matches!( op, @@ -183,7 +196,13 @@ fn harmless_inside(op: &[u8]) -> bool { /// `totals` is the decoded bytes and operators of the pages planned so far, bounded by /// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`. -fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result { +fn plan_page( + doc: &Document, + page: &pdfcraft_model::Page, + index: usize, + totals: &mut (usize, usize), + fonts: &mut autotag_roles::Fonts, +) -> Result { if page.dict.contains(b"StructParents") { return Err(unsupported(index, "page already has a structure parent key")); } @@ -248,7 +267,9 @@ fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: if path.is_some() { return Err(unsupported(index, "unterminated path object")); } - Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts }) + let resources = page_fonts(doc, &page.dict); + let evidence = autotag_roles::evidence(doc, fonts, &resources, &parsed.ops, &spans); + Ok(Plan { page: page.obj, ops: parsed.ops, spans, evidence, artifacts }) } fn plan(doc: &Document) -> Result, AutoTagError> { @@ -268,8 +289,9 @@ fn plan(doc: &Document) -> Result, AutoTagError> { let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; let mut totals = (0usize, 0usize); + let mut fonts = autotag_roles::Fonts::default(); for (index, page) in pages.iter().enumerate() { - let planned = plan_page(doc, page, index, &mut totals)?; + let planned = plan_page(doc, page, index, &mut totals, &mut fonts)?; total = total.saturating_add(planned.spans.len()); out.push(planned); } @@ -279,16 +301,21 @@ fn plan(doc: &Document) -> Result, AutoTagError> { Ok(out) } -fn mark_ops(ops: Vec, starts: &HashMap, ends: &HashSet, artifacts: &HashMap) -> Result, AutoTagError> { +fn mark_ops( + ops: Vec, + starts: &HashMap, + ends: &HashSet, + artifacts: &HashMap, +) -> Result, AutoTagError> { let artifact_ends: HashSet = artifacts.values().copied().collect(); let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2); let mut marked = Vec::with_capacity(ops.len().saturating_add(extra)); for (at, op) in ops.into_iter().enumerate() { let is_artifact = artifacts.contains_key(&at); - if let Some(mcid) = starts.get(&at) { + if let Some(&(mcid, role)) = starts.get(&at) { let mut properties = Dict::new(); - properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); - marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); + properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); + marked.push(Op::new("BDC", vec![Object::name(role), Object::Dict(properties)])); } else if is_artifact { marked.push(Op::new("BMC", vec![Object::name("Artifact")])); } @@ -334,16 +361,18 @@ fn write_structure( Ok(()) } -/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and +/// Tag every text object in an untagged PDF as a paragraph or a heading and mark painted paths, shadings and /// images outside text objects as artifacts. The full structure tree, parent tree and /// marked-content IDs are written together. Unsupported operators are refused before any -/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. +/// mutation. On success the caller can use tag-property edits to correct the roles after review. pub fn autotag_text(doc: &mut Document) -> Result { let plans = plan(doc)?; + let all: Vec = plans.iter().flat_map(|p| p.evidence.iter().copied()).collect(); + let mut roles = autotag_roles::roles(&all).into_iter(); let mut next = doc.clone(); let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; let (root, document) = (next.add(Object::Null), next.add(Object::Null)); - let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize); + let (mut children, mut numbers, mut tagged_pages, mut paragraphs, mut headings) = (Vec::new(), Vec::new(), 0usize, 0usize, 0usize); for page in plans { if page.spans.is_empty() && page.artifacts.is_empty() { continue; @@ -353,18 +382,23 @@ pub fn autotag_text(doc: &mut Document) -> Result { let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; let mut parents = Vec::with_capacity(page.spans.len()); for (mcid, (first, last)) in page.spans.iter().enumerate() { + let role = roles.next().unwrap_or("P"); let mut element = Dict::new(); element.set(b"Type".to_vec(), Object::name("StructElem")); - element.set(b"S".to_vec(), Object::name("P")); + element.set(b"S".to_vec(), Object::name(role)); element.set(b"P".to_vec(), Object::Ref(document)); element.set(b"Pg".to_vec(), Object::Ref(page.page)); element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); let reference = next.add(element); parents.push(Object::Ref(reference)); children.push(Object::Ref(reference)); - starts.insert(*first, mcid); + starts.insert(*first, (mcid, role)); ends.insert(*last); - paragraphs = paragraphs.saturating_add(1); + if role == "P" { + paragraphs = paragraphs.saturating_add(1); + } else { + headings = headings.saturating_add(1); + } } next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?; numbers.extend([Object::Int(key), Object::Array(parents)]); @@ -376,5 +410,5 @@ pub fn autotag_text(doc: &mut Document) -> Result { } write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?; *doc = next; - Ok(AutoTagResult { pages: tagged_pages, paragraphs }) + Ok(AutoTagResult { pages: tagged_pages, paragraphs, headings }) } diff --git a/crates/a11y/src/autotag_roles.rs b/crates/a11y/src/autotag_roles.rs new file mode 100644 index 0000000000..a72e14a417 --- /dev/null +++ b/crates/a11y/src/autotag_roles.rs @@ -0,0 +1,382 @@ +//! Heading roles for auto-tagged text objects, from the size and weight of the text each shows. +//! +//! The body style is the size (to the half point) carrying the most characters in the document, +//! and whether most of those characters are bold. A text object is a heading when it shows at most +//! three lines and 200 characters, including a letter, and is either 15% larger than the body or, +//! where the body is not bold, bold at no less than 90% of the body size on at most two lines. +//! Levels rank the heading styles (size, then bold) over the whole document, largest first, capped +//! at 6, and never skip a level after the heading before (the `heading-nesting` check). + +use std::collections::HashMap; +use std::rc::Rc; + +use pdfcraft_content::{Matrix, Op}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_fonts::pdf::Metrics; + +/// A line this much larger than body text reads as a heading. +const HEADING_SIZE_RATIO: f64 = 1.15; +/// Bold text this much of the body size or more can be a heading by weight. +const WEIGHT_SIZE_RATIO: f64 = 0.9; +const MAX_LINES: usize = 3; +const MAX_WEIGHT_LINES: usize = 2; +const MAX_CHARS: usize = 200; +/// Saved graphics states followed; deeper `q` nesting keeps the current state. +const MAX_SAVED: usize = 64; +/// Distinct fonts measured per document; text in any further font counts as unstyled. +const MAX_FONTS: usize = 256; + +/// What one text object shows. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Evidence { + /// The size, in user space, carrying the most of the object's characters. + pub size: f64, + /// Most of the object's characters are in a bold font. + pub bold: bool, + pub lines: usize, + /// Characters shown, whitespace excluded. + pub chars: usize, + pub letters: bool, +} + +/// The structure type for each text object, in document order: `H1`–`H6` or `P`. +pub(crate) fn roles(objects: &[Evidence]) -> Vec<&'static str> { + const HEADINGS: [&str; 6] = ["H1", "H2", "H3", "H4", "H5", "H6"]; + let Some((body, body_bold)) = body_style(objects) else { return vec!["P"; objects.len()] }; + let candidate = |e: &Evidence| { + let fits = e.lines >= 1 && e.lines <= MAX_LINES && e.chars <= MAX_CHARS && e.letters; + let by_size = e.size >= body * HEADING_SIZE_RATIO; + let by_weight = e.bold && !body_bold && e.lines <= MAX_WEIGHT_LINES && e.size >= body * WEIGHT_SIZE_RATIO; + fits && (by_size || by_weight) + }; + let mut styles: Vec<(i64, bool)> = objects.iter().filter(|e| candidate(e)).map(|e| (half_points(e.size), e.bold)).collect(); + styles.sort_by(|a, b| b.cmp(a)); + styles.dedup(); + let ranks: HashMap<(i64, bool), usize> = styles.into_iter().enumerate().map(|(rank, style)| (style, rank)).collect(); + let mut previous = 0usize; + objects + .iter() + .map(|e| { + if !candidate(e) { + return "P"; + } + let rank = ranks.get(&(half_points(e.size), e.bold)).copied().unwrap_or(0); + let level = rank.saturating_add(1).min(previous.saturating_add(1)).min(HEADINGS.len()); + previous = level; + HEADINGS.get(level.saturating_sub(1)).copied().unwrap_or("P") + }) + .collect() +} + +fn half_points(size: f64) -> i64 { + // Sizes are finite and bounded by `evidence`; `as` saturates in any case. + (size * 2.0).round() as i64 +} + +/// The size carrying the most characters, and whether most of them are bold. +fn body_style(objects: &[Evidence]) -> Option<(f64, bool)> { + let mut by_size: HashMap = HashMap::new(); + for e in objects.iter().filter(|e| e.chars > 0) { + let entry = by_size.entry(half_points(e.size)).or_default(); + entry.0 = entry.0.saturating_add(e.chars); + if e.bold { + entry.1 = entry.1.saturating_add(e.chars); + } + } + let (size, (chars, bold)) = by_size.into_iter().filter(|&(s, _)| s > 0).max_by_key(|&(s, (n, _))| (n, s))?; + Some((size as f64 / 2.0, bold.saturating_mul(2) > chars)) +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + tm: Matrix, + tlm: Matrix, + size: f64, + leading: f64, + /// Text render mode 3 or 7: shown text that paints nothing, such as an OCR layer or a + /// clip-only object. + invisible: bool, + metrics: Option>, +} + +/// Font metrics for a whole document, measured once per font object and capped at +/// [`MAX_FONTS`]. +#[derive(Default)] +pub(crate) struct Fonts { + by_ref: HashMap>>, + measured: usize, +} + +impl Fonts { + fn get(&mut self, doc: &Document, font: &Object) -> Option> { + let key = match font { + Object::Ref(r) => Some(*r), + _ => None, + }; + if let Some(found) = key.and_then(|r| self.by_ref.get(&r)) { + return found.clone(); + } + if self.measured >= MAX_FONTS { + return None; + } + self.measured = self.measured.saturating_add(1); + let metrics = doc.resolve(font).as_dict().map(|d| Rc::new(Metrics::from_dict(doc, d))); + if let Some(r) = key { + self.by_ref.insert(r, metrics.clone()); + } + metrics + } +} + +#[derive(Default)] +struct Shown { + by_size: HashMap, + lines: usize, + shown: bool, + moved: bool, + letters: bool, +} + +impl Shown { + fn show(&mut self, state: &State, bytes: &[u8]) { + let Some(metrics) = state.metrics.as_ref().filter(|_| !state.invisible) else { return }; + let text = metrics.decode(bytes); + let chars = text.chars().filter(|c| !c.is_whitespace()).count(); + if chars == 0 { + return; + } + self.letters |= text.chars().any(char::is_alphabetic); + if !self.shown { + (self.shown, self.lines) = (true, 1); + } else if self.moved { + self.lines = self.lines.saturating_add(1); + } + self.moved = false; + let [_, _, c, d, _, _] = state.tm.then(&state.ctm).0; + let size = (state.size * c.hypot(d)).abs(); + if !size.is_finite() || size <= 0.0 || size > 1e6 { + return; + } + let entry = self.by_size.entry(half_points(size)).or_default(); + entry.0 = entry.0.saturating_add(chars); + if metrics.bold { + entry.1 = entry.1.saturating_add(chars); + } + } + + /// A line move counts once text is shown after it, so blank lines and trailing moves do not. + fn new_line(&mut self) { + self.moved = self.shown; + } + + fn finish(self) -> Evidence { + let chars = self.by_size.values().fold(0usize, |n, v| n.saturating_add(v.0)); + let Some((size, (count, bold))) = self.by_size.into_iter().max_by_key(|&(s, (n, _))| (n, s)) else { + return Evidence::default(); + }; + Evidence { size: size as f64 / 2.0, bold: bold.saturating_mul(2) > count, lines: self.lines, chars, letters: self.letters } + } +} + +/// The evidence for each `(BT, ET)` operator span of a page, in order. `resources` is the page's +/// `/Font` resource dictionary; `fonts` is shared by the document's pages. +pub(crate) fn evidence(doc: &Document, fonts: &mut Fonts, resources: &Dict, ops: &[Op], spans: &[(usize, usize)]) -> Vec { + let mut by_name: HashMap, Option>> = HashMap::new(); + let mut state = + State { ctm: Matrix::IDENTITY, tm: Matrix::IDENTITY, tlm: Matrix::IDENTITY, size: 0.0, leading: 0.0, invisible: false, metrics: None }; + let (mut saved, mut unsaved) = (Vec::new(), 0usize); + let mut out = Vec::with_capacity(spans.len()); + let mut spans = spans.iter().peekable(); + let mut current: Option<(usize, Shown)> = None; + for (at, op) in ops.iter().enumerate() { + if current.is_none() && spans.peek().is_some_and(|s| s.0 == at) { + current = spans.next().map(|s| (s.1, Shown::default())); + } + let object = current.as_mut().map(|c| &mut c.1); + match op.op.as_slice() { + b"q" if saved.len() < MAX_SAVED => saved.push(state.clone()), + b"q" => unsaved = unsaved.saturating_add(1), + b"Q" if unsaved > 0 => unsaved -= 1, + b"Q" => { + if let Some(s) = saved.pop() { + state = s; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + state.ctm = Matrix(m).then(&state.ctm); + } + } + b"BT" => (state.tm, state.tlm) = (Matrix::IDENTITY, Matrix::IDENTITY), + b"Tf" => { + state.size = op.num(1).unwrap_or(state.size); + state.metrics = op + .name(0) + .and_then(|name| by_name.entry(name.to_vec()).or_insert_with(|| resources.get(name).and_then(|f| fonts.get(doc, f))).clone()); + } + b"TL" => state.leading = op.num(0).unwrap_or(state.leading), + b"Tr" => state.invisible = matches!(op.num(0), Some(m) if m == 3.0 || m == 7.0), + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.op == b"TD" { + state.leading = -y; + } + state.tlm = Matrix::translate(x, y).then(&state.tlm); + state.tm = state.tlm; + if y != 0.0 + && let Some(o) = object + { + o.new_line(); + } + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + let moved = m[5] != state.tlm.0[5]; + (state.tm, state.tlm) = (Matrix(m), Matrix(m)); + if moved && let Some(o) = object { + o.new_line(); + } + } + } + b"T*" | b"'" | b"\"" => { + state.tlm = Matrix::translate(0.0, -state.leading).then(&state.tlm); + state.tm = state.tlm; + if let Some(o) = object { + o.new_line(); + if op.op != b"T*" + && let Some(Object::String(s)) = op.operands.last() + { + o.show(&state, &s.bytes); + } + } + } + b"Tj" => { + if let (Some(o), Some(Object::String(s))) = (object, op.operands.first()) { + o.show(&state, &s.bytes); + } + } + b"TJ" => { + if let (Some(o), Some(Object::Array(items))) = (object, op.operands.first()) { + for item in items { + if let Object::String(s) = item { + o.show(&state, &s.bytes); + } + } + } + } + _ => {} + } + if current.as_ref().is_some_and(|c| c.0 == at) + && let Some((_, o)) = current.take() + { + out.push(o.finish()); + } + } + out.resize(spans.len().saturating_add(out.len()), Evidence::default()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(size: f64, chars: usize) -> Evidence { + Evidence { size, bold: false, lines: 1, chars, letters: true } + } + + fn bold(e: Evidence) -> Evidence { + Evidence { bold: true, ..e } + } + + #[test] + fn a_line_larger_than_the_body_is_a_heading_and_body_text_is_not() { + let objects = [text(20.0, 10), text(12.0, 400), text(12.0, 300)]; + assert_eq!(roles(&objects), ["H1", "P", "P"]); + } + + #[test] + fn the_body_is_the_size_with_the_most_characters_not_the_most_objects() { + // Ten short 9 pt captions and three 12 pt body lines: the body lines hold more characters, + // so 12 pt is the body. Counting objects would make 9 pt the body and the body lines headings. + let mut objects = vec![text(12.0, 150); 3]; + objects.extend(std::iter::repeat_n(text(9.0, 5), 10)); + assert!(roles(&objects).iter().all(|r| *r == "P")); + } + + #[test] + fn bold_at_body_size_is_a_heading_only_where_the_body_is_not_bold() { + let plain_body = [bold(text(12.0, 15)), text(12.0, 500)]; + assert_eq!(roles(&plain_body), ["H1", "P"]); + let bold_body = [bold(text(12.0, 15)), bold(text(12.0, 500))]; + assert_eq!(roles(&bold_body), ["P", "P"]); + } + + #[test] + fn long_text_numbers_alone_and_four_lines_stay_paragraphs() { + let long = text(20.0, MAX_CHARS + 1); + let number = Evidence { letters: false, ..text(20.0, 2) }; + let four_lines = Evidence { lines: 4, ..text(20.0, 40) }; + let two_bold_lines = Evidence { lines: 3, ..bold(text(12.0, 40)) }; + assert_eq!(roles(&[long, number, four_lines, two_bold_lines, text(12.0, 2_000)]), ["P"; 5]); + } + + #[test] + fn levels_rank_styles_largest_first_and_never_skip_a_level() { + // Styles: 24 pt, 18 pt, bold 12 pt. The first heading met is 18 pt, ranked second, which + // would skip H1: it becomes H1, and the bold heading after it H2, not H3. + let objects = [text(18.0, 10), bold(text(12.0, 10)), text(24.0, 10), text(18.0, 10), bold(text(12.0, 10)), text(12.0, 3_000)]; + assert_eq!(roles(&objects), ["H1", "H2", "H1", "H2", "H3", "P"]); + } + + #[test] + fn levels_stop_at_six() { + let mut objects: Vec = (0..8).map(|i| text(40.0 - f64::from(i) * 2.0, 10)).collect(); + objects.push(text(12.0, 5_000)); + assert_eq!(roles(&objects), ["H1", "H2", "H3", "H4", "H5", "H6", "H6", "H6", "P"]); + } + + #[test] + fn a_document_without_text_has_only_paragraphs() { + assert_eq!(roles(&[Evidence::default(), Evidence::default()]), ["P", "P"]); + assert!(roles(&[]).is_empty()); + } + + /// The evidence for each `BT`..`ET` of `content`, drawn in Helvetica-Bold as `/F1`. + fn walk(content: &[u8]) -> Vec { + let doc = Document::new_empty(); + 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-Bold")); + let mut resources = Dict::new(); + resources.set(b"F1".to_vec(), Object::Dict(font)); + let ops = pdfcraft_content::parse(content).ops; + let at = |name: &[u8]| -> Vec { ops.iter().enumerate().filter(|(_, o)| o.op == name).map(|(i, _)| i).collect() }; + let spans: Vec<(usize, usize)> = at(b"BT").into_iter().zip(at(b"ET")).collect(); + evidence(&doc, &mut Fonts::default(), &resources, &ops, &spans) + } + + #[test] + fn size_comes_from_the_font_size_the_text_matrix_and_the_ctm() { + let found = walk( + b"q 2 0 0 2 0 0 cm BT /F1 1 Tf 9 0 0 9 10 10 Tm (Title) Tj ET Q BT /F1 12 Tf 10 50 Td (one) Tj T* (two) ' 0 -14 Td (three) Tj ET BT ET", + ); + assert_eq!(found[0], Evidence { size: 18.0, bold: true, lines: 1, chars: 5, letters: true }); + assert_eq!(found[1], Evidence { size: 12.0, bold: true, lines: 3, chars: 11, letters: true }); + assert_eq!(found[2], Evidence::default()); + assert_eq!(found.len(), 3); + } + + #[test] + fn line_moves_leading_invisible_text_and_absurd_sizes() { + let found = walk( + b"BT /F1 10 Tf 1 0 0 1 0 90 Tm (a) Tj 1 0 0 1 40 90 Tm (b) Tj 1 0 0 1 0 70 Tm (c) Tj ET BT /F1 10 Tf 14 TL 0 0 Td (a) Tj 0 0 (b) \" 0 -12 TD (c) Tj T* (d) Tj ET BT /F1 10 Tf 3 Tr (hidden) Tj 7 Tr (clip) Tj 0 Tr (shown) Tj ET BT /F1 2000000 Tf (huge) Tj ET", + ); + assert_eq!(found[0].lines, 2, "a Tm on the same baseline is not a new line"); + assert_eq!(found[1].lines, 4, "\" and TD each start a line, and T* uses TD's leading"); + assert_eq!(found[2].chars, 5, "render mode 3 and 7 text is not counted"); + assert_eq!(found[3], Evidence::default(), "a size past 1e6 is dropped"); + } +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 50e632dec8..d3492f50e7 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -15,6 +15,7 @@ use pdfcraft_cos::Document; mod alt; mod autotag; mod autotag_fields; +mod autotag_roles; mod content; mod headings; mod layout_table; diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 890eb33e9f..4a981aaf5b 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -50,7 +50,7 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() { "", ); let result = autotag_text(&mut doc).unwrap(); - assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 3 }); + assert_eq!(result, AutoTagResult { pages: 2, paragraphs: 2, headings: 1 }); let catalog = doc.get(doc.root().unwrap()); let root = catalog.as_dict().unwrap().reference(b"StructTreeRoot").unwrap(); let tree = doc.get(root); @@ -62,14 +62,18 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() { assert_eq!(entries[2].as_int(), Some(1)); assert_eq!(entries[1].as_array().unwrap().len(), 2); assert_eq!(entries[3].as_array().unwrap().len(), 1); - for page in pdfcraft_model::pages(&doc) { + // The 18 pt line stands out from the 12 pt body: a heading, the rest paragraphs. + let first_marks = ["/H1 <> BDC", "/P <> BDC"]; + for (page, first) in pdfcraft_model::pages(&doc).iter().zip(first_marks) { let content = page.dict.get(b"Contents").unwrap(); let stream = doc.resolve(content); let Object::Stream(stream) = &*stream else { panic!("marked page content") }; let text = String::from_utf8(stream.decoded_strict().unwrap()).unwrap(); - assert!(text.contains("/P <> BDC"), "{text}"); + assert!(text.contains(first), "{text}"); assert!(text.contains("EMC"), "{text}"); } + let kinds: Vec = tags(&doc).iter().map(|t| t.kind.clone()).collect(); + assert_eq!(kinds, ["Document", "H1", "P", "P"]); let report = check(&doc, &Options::default()); assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); @@ -104,7 +108,7 @@ fn autotag_text_reads_content_with_parameters_given_by_reference() { let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows); predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); doc.set(ObjRef::new(4, 0), predicted); - assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 }); + assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1, headings: 0 }); let page = &pdfcraft_model::pages(&doc)[0]; let written = doc.resolve(page.dict.get(b"Contents").unwrap()); let Object::Stream(written) = &*written else { panic!("page content") }; @@ -157,7 +161,7 @@ fn autotag_text_marks_vector_graphics_and_images_as_artifacts() { "", ); let result = autotag_text(&mut doc).unwrap(); - assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 }); + assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1, headings: 0 }); let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None }); assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index b17192aab0..cab0fb9a61 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -217,7 +217,7 @@ impl Automation { let tags = self.accessibility_tags(a)?; let out = json!({ "tags": tags["tags"], "count": tags["count"], - "note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.", + "note": "Text objects were tagged as headings (H1-H6, by size and weight against the body text) or paragraphs. Review heading roles, reading order and remaining accessibility findings manually.", }); Ok(with_warnings(out, warnings)) } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f749ed2953..5251bf96ce 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -509,6 +509,25 @@ fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { assert!(pages.iter().all(|page| page.dict.contains(b"StructParents"))); } +/// #7062: Apply tags short text larger than the body as headings, ranked across the pages, and +/// the body text as paragraphs. +#[test] +fn autotag_apply_tags_headings_by_size_across_pages() { + let dir = workdir("autotag-headings"); + let body = "BT /FR 11 Tf 72 670 Td (Body text that runs long enough to be the most common style) Tj ET"; + let page1 = format!("BT /FB 18 Tf 72 700 Td (Introduction) Tj ET {body} BT /FB 14 Tf 72 640 Td (Background) Tj ET"); + let page2 = format!("BT /FB 18 Tf 72 700 Td (Results) Tj ET {body}"); + std::fs::write(dir.join("headed.pdf"), bold_and_regular_pdf(&page1, &page2)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "headed.pdf" }))["doc"].as_u64().unwrap(); + let applied = ok(&mut a, "accessibility_autotag_apply", json!({ "doc": doc })); + let types: Vec<&str> = applied["tags"].as_array().unwrap().iter().map(|t| t["type"].as_str().unwrap()).collect(); + assert_eq!(types, ["Document", "H1", "P", "H2", "H1", "P"]); + let check = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["heading-nesting"] })); + let nesting = check["results"].as_array().unwrap().iter().find(|r| r["rule"] == "heading-nesting").unwrap(); + assert_eq!(nesting["status"], "passed", "{check}"); +} + #[test] fn autotag_form_fields_writes_reviewable_widget_tags() { let dir = workdir("autotag-form-fields"); @@ -531,6 +550,12 @@ fn styled_headings_pdf() -> Vec { let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \ BT /FB 14 Tf 72 640 Td (Background) Tj ET"; let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + bold_and_regular_pdf(page1, page2) +} + +/// A two-page PDF whose pages draw `page1` and `page2` with `/FB` (Helvetica-Bold) and `/FR` +/// (Helvetica). +fn bold_and_regular_pdf(page1: &str, page2: &str) -> Vec { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(), diff --git a/docs/gaps.md b/docs/gaps.md index 1a1c04a9da..6f2ab13219 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -250,7 +250,7 @@ update the numbers. Read Out Loud; full keyboard operation of PdfCraft itself and a screen-reader audit. - **Evidence:** all 32 checker rules, figure alt text, tag property and sibling order edits, and a bounded auto-tag preview exist. Apply writes valid structure and parent-tree links for - text-only files; it refuses mixed content and existing tags; + text-only files, tagging headings by size and weight; it refuses mixed content and existing tags; `a11y.keyboard-only` (P0) planned, `a11y.app-screen-reader` partial. - **Impact:** most untagged files still need another editor to build their structure; PdfCraft itself is not yet usable without a mouse. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7ca83dedd9..abc6d13b9f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -296,7 +296,7 @@ total matches the parity estimate. | 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. | -| 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). | +| 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, heading and paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, 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 | 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 | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e533ecf6c7..6eb57527d3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9158,8 +9158,8 @@ milestone = "M12" status = "partial" commands = ["a11y.autotag"] tools = ["accessibility_autotag_preview", "accessibility_autotag_apply"] -evidence = ["crates/automation/tests/it/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/it/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/ui-egui/tests/it/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"] -notes = "Auto-tag writes real marked content, a Document structure element, P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. It refuses mixed content, forms, annotations and existing tags. The preview separately proposes headings. Images, tables, lists, links, fields and headings are not classified or tagged by Apply." +evidence = ["crates/automation/tests/it/automation.rs::autotag_preview_proposes_text_without_changing_the_pdf", "crates/automation/tests/it/automation.rs::autotag_apply_writes_reviewable_tags_and_survives_save_reopen", "crates/automation/tests/it/automation.rs::autotag_apply_tags_headings_by_size_across_pages", "crates/a11y/src/autotag_roles.rs::levels_rank_styles_largest_first_and_never_skip_a_level", "crates/ui-egui/tests/it/accessibility.rs::automatic_text_tagging_opens_reviewable_tags"] +notes = "Auto-tag writes real marked content, a Document structure element, H1-H6 and P tags and a parent tree for text-only PDFs, with undo and Tags-panel review. A text object is a heading when it is short and larger or bolder than the body text; levels rank those styles across the document and never skip a level. It refuses mixed content, forms, annotations and existing tags. The preview still proposes headings by its own single-line rule, so it can differ from Apply. Running headers and footers can still become headings (#7065); invisible text (render mode 3) is ignored. Images, tables, lists, links and fields are not classified or tagged by Apply." [[feature]] id = "a11y.autotag-fields" From bffa2d58c0b1469241068769f962cc3f84489764 Mon Sep 17 00:00:00 2001 From: FredApps Date: Thu, 12 Nov 2026 19:27:38 +0100 Subject: [PATCH 21/21] fix(scripts): a batch the redaction checks refuse discards its script globals After the edit ran, a batch can still fail the redaction checks: a deleted marked page alongside added pages, or text still shown under a mark. Both returned without discard_edit_callbacks, so globals (and now timers) the batch's field scripts left stayed in the document's realm while the document rolled back. Every other refusal after the edit already discards them. Test: a_batch_a_redaction_check_refuses_discards_callback_globals. A keystroke script sets a global, and a batch that sets the field and deletes the marked page while adding one is refused. Without the fix the global is still there ("number" for "undefined"). --- crates/engine/src/session/editing.rs | 5 ++++- crates/engine/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 52922da290..872d79eb2a 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -205,13 +205,16 @@ impl Session { // Fail closed. let before: std::collections::HashSet<_> = pdfcraft_model::pages(&editor.cos).iter().map(|p| p.obj).collect(); let after: std::collections::HashSet<_> = pdfcraft_model::pages(&next).iter().map(|p| p.obj).collect(); + // Either refusal also discards what the batch's field scripts left in the realm. if cx.redaction_marks.iter().any(|(o, _)| !after.contains(o)) && after.iter().any(|o| !before.contains(o)) { + discard_edit_callbacks(&cx); return Err(EditError::Invalid( "a batch that deletes a page with a redaction mark and adds pages can't be verified; the document was not changed".into(), )); } guard(|| shown_under_redactions(&next, &cx.redaction_marks, &cx.redaction_overlays, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label())))) + .inspect_err(|_| discard_edit_callbacks(&cx))?; } // Renamed fields are said, not slipped in: a repair-log note (with the caveat that // scripts were left alone) and a count in the undo step's label. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bf1a4ebd01..bdd770dda2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14398,6 +14398,31 @@ fn a_budget_refusal_discards_pdf_edits_and_callback_globals() { assert_eq!(s.run_javascript(id, "typeof fromRejectedEdit;", None).unwrap().result.as_deref(), Some("undefined")); } +/// A batch whose field scripts ran and that a redaction check then refuses leaves the script realm +/// as it was, like the document: here the check that a deleted marked page can't be tied to the +/// pages the batch added. +#[test] +fn a_batch_a_redaction_check_refuses_discards_callback_globals() { + let (mut s, id) = reset_order_session(""); + let script = Some("var fromRefusedBatch = 1;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "keystroke".into(), script }).unwrap(); + let shape = Shape::Redact { quads: vec![pdfcraft_annot::rect_quad([10.0, 10.0, 60.0, 30.0])], overlay: String::new(), look: Default::default() }; + let mark = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }; + s.apply(id, Edit::AddAnnotation(mark)).unwrap(); + s.set_javascript(true); + let before = s.save_bytes(id).unwrap(); + let edits = vec![ + Edit::SetFieldValue { name: "qty".into(), value: pdfcraft_forms::FieldValue::Text("10".into()) }, + Edit::ApplyRedactions { pages: None }, + Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 }, + Edit::DeletePages { pages: vec![0] }, + ]; + let refused = s.apply(id, Edit::Batch { label: "x".into(), edits }); + assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("can't be verified")), "{refused:?}"); + assert_eq!(s.save_bytes(id).unwrap(), before); + assert_eq!(s.run_javascript(id, "typeof fromRefusedBatch;", None).unwrap().result.as_deref(), Some("undefined")); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn timeout_after_undo_releases_the_stale_script_runtime() {