From 306bbcedf98b3a22f276e333d70688c776897154 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:32:48 +0900 Subject: [PATCH] M3: harden render and CLI failure surfaces (#669) * M3: harden render and CLI failure surfaces Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage. Co-authored-by: Codex * Render: remember a failed render by page and kind again A key with scale and tag made every zoom level and tile a new key: a pathological page tied up a worker for the watchdog limit per request (exhausting worker replacements) and was re-parsed each time. The repeated-parsing check asks for a new generation and scale again. * Export: pages too large at the chosen dpi export at the most the renderer allows, and say so #669 made every Exporter refuse oversized whole-page renders, which reached File > Export to image, doc_export_* and OCR: at 600 dpi any page side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The exporter clamps again; Exporter::dpi_used reports the resolution a page is drawn at, which the Export dialog's message and doc_export_png's lower_dpi list mention. A source with reject_oversize still refuses (CLI render keeps its strict config). * doc_export_images: describe lower_dpi * Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded Nested forms, Type 3 glyphs and patterns are interpreted again and each reported the exhausted budget; pdfcraft-render now keeps one. The warning is part of hayro-interpret patch (18) (the page content budget): marked in the code and described in vendor/README.md. * CLI render: print render warnings (skipped content) to stderr * CLI: the option check refuses unknown options only; commands report missing values themselves text's own --page parsing (and its test on main) says what a page value must be; the generic "needs a value" pre-empted it. --------- Co-authored-by: Codex Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 26 +- apps/pdfcraft-cli/src/main.rs | 83 ++++- apps/pdfcraft-cli/tests/options.rs | 27 ++ crates/automation/src/lib.rs | 12 +- crates/automation/src/tools.rs | 2 +- crates/engine/src/export.rs | 29 +- crates/engine/src/tests.rs | 21 ++ crates/render/src/lib.rs | 3 +- crates/render/src/raster.rs | 329 ++++++++++++++++++-- crates/render/src/raster/work_queue.rs | 11 +- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/export_ui.rs | 12 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + vendor/README.md | 2 +- vendor/hayro-interpret/src/context.rs | 10 + vendor/hayro-interpret/src/interpret/mod.rs | 6 + 28 files changed, 535 insertions(+), 52 deletions(-) create mode 100644 apps/pdfcraft-cli/tests/options.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 9aadfc938..3c3ca4563 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "55e7300dbffa2f74cff0fedbf9411ce7b7465e2a871eabfeb691ac076abdda94" +sha256 = "abb6981d86d4df565c72f2741b931052462c6689eb5415b888991f40af1adcb7" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a147d7ea988620f64cf5e24069187d633f79c9bcd71e86788a0692a89bd415a3" +sha256 = "9bf3f80e72839e983b9ffb9bc3b44db4e29ebdaf2933852a8a600582b851a3fe" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ba0b707dedeb94595f8e11d0d4ce341223344c33208f1a113f8feab6a956f752" +sha256 = "e7bf0d59c35cc0f65139a7fe08e7bb213e79820611e7992392d4af2457759253" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b39691a4d106c67668adf9763ae4613bb3aa74caf45592bd3cf1e941473c04b0" +sha256 = "f0c6fcf6fe391c4e7784b64acfb0c91d53ffbe18d9e7708ad663e2afd29a0760" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5b6a7b7e61969efb0a32fba1ab85671d03ebf14f2d274647e5e4ad1a76e128da" +sha256 = "6f6fd18c652127a3e5dbc3a0a7c2d24e2cb47ec79ec58ac4170632e37f1e7b1c" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3742cd185a9fc70021a7fee4fb0a159896a3e29c35a372bec059b277f978c689" +sha256 = "180b624986752af06b173a00899ca38d6ba4965b68ba7d3b8a926cf7e5b942de" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "272f449649c3cc9c71dec77b20fcd912f5e00b828aa2c6ca91735d3aae876203" +sha256 = "5d4fe0c8f680a0ecf72f8d23d4f075e3944f71e2c7594aac8a8e59c6cda29043" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b45e7d7e49987852dc2b8262c2738f8b51defe6832c917e07dae0374af936013" +sha256 = "7b7edbe92edea1809710c9b89df05a5618fc1cf581b907521351b06eccd5cf3a" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7da3706da8c7eae25685499d04707bbb781378707130481059a98a4e76ff0e16" +sha256 = "362b3c8014cda1fe8c682b75c96bc5f630e8648cb05a431eb1448714cb8bc435" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "16d327b8b2c445d592e29cfcee862c47e5015dc720e689157ae9bf936373ee04" +sha256 = "b3fe50274b3117535bb35e935737206417322deb280a643f59855974931b1d9c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9cafc2c56166d8b33176af66fa8118588e21aaa2f8df1b91f7bb215b2ad8b7e8" +sha256 = "002a2ba35999da21d8c2ceb383c38f0bdf0aa43f2fe60015835d2f03bac8d576" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "460521cbf67f5bfead4de294e46fd2d8aa7fcab86cffab4b79cc7c80417091a4" +sha256 = "b268c077b44635ca346d4a2372bef6eea8c5c891e82df549228e68bf4ec4baa5" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7855b47b221290f47560bf00238122086fdc2ecf55b130ee7bedb4a25421f678" +sha256 = "7c043ba0237c3cb7cb0107066722dee309c3265ac8c930d3eadb3a9ac9d4d345" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 49bb4dfd1..db61d46e6 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -150,6 +150,66 @@ fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) } +#[derive(Clone, Copy)] +struct OptionSpec { + name: &'static str, + takes_value: bool, +} + +/// Refuse a long option `command` doesn't take (a misspelling) before any file is read. +fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(name) = arg.strip_prefix("--") { + let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { + return Err(format!("{command}: unknown option --{name}").into()); + }; + // A missing or malformed value is left to the command, which reports it in its own + // terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { 2 } else { 1 }; + } else { + i += 1; + } + } + Ok(()) +} + +const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; +const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; +const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; +const EXTRACT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--pages", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; +const SPLIT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--every", takes_value: true }, + OptionSpec { name: "--before", takes_value: true }, + OptionSpec { name: "--out-dir", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; +const RENDER_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; +const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; +const CHECK_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--timeout", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--json", takes_value: true }, +]; +const RUN_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--root", takes_value: true }, + OptionSpec { name: "--script", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, +]; +const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; + +#[cfg(feature = "mcp")] +const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; + fn positional(args: &[String]) -> Vec<&str> { let mut out = Vec::new(); let mut skip = false; @@ -172,6 +232,7 @@ fn read(path: &str) -> Result>, String> { } fn info(args: &[String]) -> Result<(), CliError> { + validate_options("info", args, INFO_OPTIONS)?; let path = *positional(args).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); @@ -205,6 +266,7 @@ fn info(args: &[String]) -> Result<(), CliError> { } fn text(args: &[String]) -> Result<(), CliError> { + validate_options("text", args, TEXT_OPTIONS)?; let path = *positional(args).first().ok_or("text: missing file")?; let mut selected_page = None; // Validate every supplied value before reading, retaining the first valid selection. @@ -337,6 +399,7 @@ fn file_stem(path: &str) -> String { } fn combine(args: &[String]) -> Result<(), CliError> { + validate_options("combine", args, COMBINE_OPTIONS)?; let out = flag(args, "--out").ok_or("combine: missing --out")?; let inputs = positional(args); if inputs.len() < 2 { @@ -348,6 +411,7 @@ fn combine(args: &[String]) -> Result<(), CliError> { } fn extract(args: &[String]) -> Result<(), CliError> { + validate_options("extract", args, EXTRACT_OPTIONS)?; let path = *positional(args).first().ok_or("extract: missing file")?; let out = flag(args, "--out").ok_or("extract: missing --out")?; let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; @@ -358,6 +422,7 @@ fn extract(args: &[String]) -> Result<(), CliError> { } fn split(args: &[String]) -> Result<(), CliError> { + validate_options("split", args, SPLIT_OPTIONS)?; use pdfcraft_engine::SplitBy; let path = *positional(args).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { @@ -382,6 +447,7 @@ fn split(args: &[String]) -> Result<(), CliError> { } fn render(args: &[String]) -> Result<(), CliError> { + validate_options("render", args, RENDER_OPTIONS)?; let path = *positional(args).first().ok_or("render: missing file")?; let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?; @@ -395,7 +461,10 @@ fn render(args: &[String]) -> Result<(), CliError> { Some("pam") => None, _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), }; - let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() }); + let mut r = PageRenderer::new( + read(path)?, + RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, + ); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 }); if let Some(e) = p.error { return Err(e.into()); @@ -411,11 +480,19 @@ fn render(args: &[String]) -> Result<(), CliError> { }; std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); + for warning in &p.warnings { + match warning { + pdfcraft_render::RenderWarning::ContentTruncated => { + let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + } + } + } Ok(()) } /// Child-process body for `check`: prints one JSON line. fn check_one(args: &[String]) -> Result<(), CliError> { + validate_options("check-one", args, CHECK_ONE_OPTIONS)?; let path = *positional(args).first().ok_or("check-one: missing file")?; let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; let start = Instant::now(); @@ -496,6 +573,7 @@ fn collect(paths: &[&str]) -> Vec { } fn check(args: &[String]) -> Result<(), CliError> { + validate_options("check", args, CHECK_OPTIONS)?; let files = collect(&positional(args)); if files.is_empty() { return Err("check: no PDF files found".into()); @@ -600,6 +678,7 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec Result<(), CliError> { + validate_options("run", args, RUN_OPTIONS)?; let mut auto = automation(args)?; if let Some(script) = flag(args, "--script") { let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; @@ -627,6 +706,7 @@ fn run(args: &[String]) -> Result<(), CliError> { #[cfg(feature = "mcp")] fn mcp(args: &[String]) -> Result<(), CliError> { + validate_options("mcp", args, MCP_OPTIONS)?; let compact = args.iter().any(|a| a == "--compact"); let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact); let _ = writeln!( @@ -642,6 +722,7 @@ fn mcp(args: &[String]) -> Result<(), CliError> { /// One request to a running app's control channel (`pdfcraft --control FILE`). fn ui(args: &[String]) -> Result<(), CliError> { + validate_options("ui", args, UI_OPTIONS)?; use std::io::{BufRead, BufReader, Write as _}; let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; let info = read_control_file(file)?; diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs new file mode 100644 index 000000000..c337d27e2 --- /dev/null +++ b/apps/pdfcraft-cli/tests/options.rs @@ -0,0 +1,27 @@ +//! Every command rejects misspelled long options before opening or writing a file. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +#[test] +fn non_edit_commands_reject_unknown_options_before_io() { + for args in [ + vec!["info", "missing.pdf", "--passwrod", "secret"], + vec!["text", "missing.pdf", "--pgae", "1"], + vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"], + vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"], + vec!["split", "missing.pdf", "--ever", "2"], + vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"], + vec!["check-one", "missing.pdf", "--edti"], + vec!["check", "missing.pdf", "--timout", "2"], + vec!["run", "doc_open", "--roto", "/tmp"], + vec!["ui", "--contro", "control.json", "state"], + ] { + let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option"); + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); + assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}"); + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 93fecaa30..daabbf16e 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -980,13 +980,23 @@ impl Automation { f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), }; let mut files = Vec::new(); + let mut lowered = Vec::new(); for p in pages { let img = ex.image(p, dpi, format).map_err(failed)?; let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); write_atomic(&path, &img)?; files.push(path.to_string_lossy().into_owned()); + // A page too large for the renderer at `dpi` is drawn at the most it allows. + let used = ex.dpi_used(p, dpi); + if used < dpi.clamp(18.0, 1200.0) - 0.5 { + lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); + } + } + if lowered.is_empty() { + Ok(json!({ "count": files.len(), "files": files })) + } else { + Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) } - Ok(json!({ "count": files.len(), "files": files })) } fn doc_create(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 604ec20f4..29866b5a8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -755,7 +755,7 @@ pub fn tools() -> Vec { t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), - t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`_page_.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. Includes unsaved edits.") + t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`_page_.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.") .cmd("export.image") .with(schema( json!({ diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 5360531ad..a6af0cdf8 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -12,6 +12,8 @@ use crate::Document; pub struct Exporter { renderer: PageRenderer, pages: usize, + /// Each page's displayed size in points, for [`Exporter::dpi_used`]. + sizes: Vec<(f32, f32)>, } /// What an export needs from a document, as plain values that can move to a worker thread. @@ -20,12 +22,19 @@ pub struct ExportSource { pub bytes: std::sync::Arc>, pub config: pdfcraft_render::RenderConfig, pub pages: usize, + /// Each page's displayed size in points (after `/Rotate` and `/UserUnit`). + pub sizes: Vec<(f32, f32)>, } impl Document { /// The current state, for exporting on another thread. pub fn export_source(&self) -> ExportSource { - ExportSource { bytes: self.display.clone(), config: self.config.clone(), pages: self.info.pages.len() } + ExportSource { + bytes: self.display.clone(), + config: self.config.clone(), + pages: self.info.pages.len(), + sizes: self.info.pages.iter().map(|p| (p.width, p.height)).collect(), + } } } @@ -46,15 +55,29 @@ impl Exporter { Self::from_source(doc.export_source()) } + /// An exporter that draws a page too large for the renderer's size limits at the largest + /// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what. + /// A source whose `config.reject_oversize` is set refuses such pages instead. pub fn from_source(src: ExportSource) -> Self { - Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages } + Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes } + } + + /// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked + /// when the page is too large for the renderer's size limits at that resolution. + pub fn dpi_used(&self, page: usize, dpi: f64) -> f64 { + let dpi = dpi.clamp(18.0, 1200.0); + match self.sizes.get(page) { + Some(&(w, h)) => f64::from(pdfcraft_render::effective_scale(w, h, (dpi / 72.0) as f32)) * 72.0, + None => dpi, + } } fn check(&self, page: usize) -> Result<(), String> { if page < self.pages { Ok(()) } else { Err(format!("page {} does not exist", page + 1)) } } - /// Page `page` (0-based) as a PNG at `dpi` (capped by the renderer's size limits). + /// Page `page` (0-based) as a PNG at `dpi`, at most the renderer's size limits allow (see + /// [`Exporter::dpi_used`]); refused instead when the source asked for `reject_oversize`. pub fn png(&mut self, page: usize, dpi: f64) -> Result, String> { self.check(page)?; let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 821d6366a..c6493b010 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2449,3 +2449,24 @@ fn adopting_page_only_fields_is_noted_as_a_repair() { assert_eq!(names, ["alpha", "beta"]); assert!(doc.repair_log().iter().any(|l| l.contains("2 fields") && l.contains("page annotations")), "{:?}", doc.repair_log()); } + +/// A page too large for the renderer at the asked resolution exports at the most it allows (and +/// says at what), as File ▸ Export does; a source that asks for strictness refuses it instead. +#[test] +fn oversized_exports_clamp_and_report_their_dpi_unless_strict() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + // 14,400 pt (200 in) wide: 600 dpi would be 120,000 px. + let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap(); + let id = s.open("wide.pdf", None, bytes, None).unwrap(); + let src = s.get(id).unwrap().export_source(); + let mut ex = export::Exporter::from_source(src.clone()); + let used = ex.dpi_used(0, 600.0); + assert!(used < 600.0 && used > 0.0, "{used}"); + let png = ex.png(0, 600.0).expect("clamped, not refused"); + assert!(png.starts_with(b"\x89PNG")); + assert!((ex.dpi_used(0, 18.0) - 18.0).abs() < 0.01, "a small enough request (3,600 px) is drawn as asked"); + let mut strict = src; + strict.config.reject_oversize = true; + let err = export::Exporter::from_source(strict).png(0, 600.0).unwrap_err(); + assert!(err.contains("exceeds renderer limits"), "{err}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 747f6e351..b8aed6570 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -20,7 +20,8 @@ pub use inspect::{ }; pub use pixels::Pixels; pub use raster::{ - MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, device_pixels, effective_scale, + MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Tile, + device_pixels, effective_scale, }; pub use text::{PageText, TextGlyph}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index b07499309..746c76683 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -10,7 +10,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; #[cfg(not(target_arch = "wasm32"))] -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::SyncSender; #[cfg(not(target_arch = "wasm32"))] use std::sync::mpsc::{Receiver, sync_channel}; @@ -20,9 +21,9 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use work_queue::{ResultReceiver, WorkQueue}; -use hayro::hayro_interpret::InterpreterSettings; use hayro::hayro_interpret::font::{FontData, FontQuery}; use hayro::hayro_interpret::hayro_cmap::CidFamily; +use hayro::hayro_interpret::{InterpreterSettings, InterpreterWarning}; use hayro::hayro_syntax::Pdf; use hayro::vello_cpu::color::palette::css::WHITE; use hayro::{RenderCache, RenderSettings, render_into, render_size}; @@ -57,6 +58,9 @@ pub struct RenderConfig { pub layers: Arc>, /// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are). pub hide_comments: bool, + /// 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. + pub reject_oversize: bool, } impl RenderConfig { @@ -154,6 +158,22 @@ pub struct RenderRequest { pub tag: u64, } +/// What a render that panicked or overran the watchdog is remembered by: its page and kind. Not +/// its scale, tile or tag: the canvas tags every zoom level and tile anew, so a wider key would +/// let one pathological page tie up a worker for the full watchdog limit per zoom and per tile +/// (exhausting worker replacements) and re-parse the document for every new request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct RequestFailureKey { + page: usize, + kind: RequestKind, +} + +impl From for RequestFailureKey { + fn from(req: RenderRequest) -> Self { + Self { page: req.page, kind: req.kind } + } +} + #[derive(Debug)] pub struct RenderedPage { pub request: RenderRequest, @@ -165,9 +185,74 @@ pub struct RenderedPage { pub error: Option, /// For `RequestKind::Text`. pub text: Option>, + /// Non-fatal conditions that made the result partial or otherwise noteworthy. + pub warnings: Vec, pub millis: u32, } +/// A non-fatal condition reported while interpreting a page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderWarning { + /// Content was skipped after the per-page decoded-content budget was exhausted. + ContentTruncated, +} + +/// Counters for work performed by a renderer or render pool. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RenderStats { + /// Number of parser construction attempts. + pub parser_builds: u64, + /// Number of pixel page interpretations, including tiled requests. + pub page_interpretations: u64, + /// Number of render requests accepted for processing. + pub render_requests: u64, + /// Number of requests that render only a tile. + pub tile_requests: u64, + /// Number of text-layer interpretations. + pub text_interpretations: u64, + /// Number of pixel rasterizations. + pub rasterizations: u64, +} + +#[derive(Default)] +struct AtomicRenderStats { + parser_builds: AtomicU64, + page_interpretations: AtomicU64, + render_requests: AtomicU64, + tile_requests: AtomicU64, + text_interpretations: AtomicU64, + rasterizations: AtomicU64, +} + +impl AtomicRenderStats { + fn record_request(&self, req: RenderRequest) { + self.render_requests.fetch_add(1, Ordering::Relaxed); + if req.tile.is_some() { + self.tile_requests.fetch_add(1, Ordering::Relaxed); + } + match req.kind { + RequestKind::Pixels => { + self.page_interpretations.fetch_add(1, Ordering::Relaxed); + self.rasterizations.fetch_add(1, Ordering::Relaxed); + } + RequestKind::Text => { + self.text_interpretations.fetch_add(1, Ordering::Relaxed); + } + } + } + + fn snapshot(&self) -> RenderStats { + RenderStats { + parser_builds: self.parser_builds.load(Ordering::Relaxed), + page_interpretations: self.page_interpretations.load(Ordering::Relaxed), + render_requests: self.render_requests.load(Ordering::Relaxed), + tile_requests: self.tile_requests.load(Ordering::Relaxed), + text_interpretations: self.text_interpretations.load(Ordering::Relaxed), + rasterizations: self.rasterizations.load(Ordering::Relaxed), + } + } +} + /// Clamp a requested scale so the output respects `MAX_SIDE` and `MAX_PIXELS`. pub fn effective_scale(width_pt: f32, height_pt: f32, scale: f32) -> f32 { let (w, h) = (width_pt.max(1.0), height_pt.max(1.0)); @@ -192,7 +277,13 @@ pub fn device_pixels(pt: f32, scale: f32) -> u32 { /// and reported as `Err((message, panicked))`. type Output = (u32, u32, Pixels, Option>); -fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result { +fn render_page<'a>( + pdf: &'a Pdf, + cache: &RenderCache<'a>, + settings: &InterpreterSettings, + reject_oversize: bool, + req: RenderRequest, +) -> Result { if req.kind == RequestKind::Text { return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) { Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))), @@ -224,6 +315,13 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter } None => { let scale = effective_scale(w, h, req.scale); + if reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) { + return Err(format!( + "requested page {} raster scale {req_scale} exceeds renderer limits (maximum scale {scale})", + req.page + 1, + req_scale = req.scale + )); + } // hayro floors the size when none is given, losing the partial edge pixels. The // scale keeps each side within MAX_SIDE (give or take float noise), so it fits u16. let side = |pt: f32| device_pixels(pt, scale).min(MAX_SIDE as u32) as u16; @@ -243,43 +341,89 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter } } -fn finish(req: RenderRequest, start: Stopwatch, r: Result) -> RenderedPage { +fn finish(req: RenderRequest, start: Stopwatch, r: Result, warnings: Vec) -> RenderedPage { let millis = start.millis(); match r { - Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis }, - Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, millis }, + Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, warnings, millis }, + Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, warnings, millis }, } } +fn warning_settings(settings: &InterpreterSettings, warnings: Arc>>) -> InterpreterSettings { + let target = warnings; + InterpreterSettings { + warning_sink: Arc::new(move |warning| { + // Nested forms, Type 3 glyphs and patterns are interpreted again and each reports the + // exhausted budget: once per page is enough. + if matches!(warning, InterpreterWarning::ContentTruncated) { + let mut warnings = lock(&target); + if !warnings.contains(&RenderWarning::ContentTruncated) { + warnings.push(RenderWarning::ContentTruncated); + } + } + }), + ..settings.clone() + } +} + +fn take_warnings(warnings: &Mutex>) -> Vec { + std::mem::take(&mut *lock(warnings)) +} + /// A single-threaded renderer over one parsed document (used by the CLI and tests). pub struct PageRenderer { bytes: Arc>, config: RenderConfig, pdf: Option, settings: InterpreterSettings, + stats: RenderStats, } impl PageRenderer { pub fn new(bytes: Arc>, config: RenderConfig) -> Self { let pdf = parse(&bytes, config.password.as_deref()); let settings = config.settings(); - Self { bytes, config, pdf, settings } + Self { bytes, config, pdf, settings, stats: RenderStats { parser_builds: 1, ..RenderStats::default() } } } pub fn page_count(&self) -> usize { self.pdf.as_ref().map(|p| p.pages().len()).unwrap_or(0) } + /// Snapshot the work counters collected by this renderer. + pub fn stats(&self) -> RenderStats { + self.stats + } + /// Render one page. Never panics. pub fn render(&mut self, req: RenderRequest) -> RenderedPage { let start = Stopwatch::start(); - let Some(pdf) = self.pdf.as_ref() else { return finish(req, start, Err(("the document could not be parsed".into(), false))) }; + if self.pdf.is_some() { + self.stats.render_requests += 1; + if req.tile.is_some() { + self.stats.tile_requests += 1; + } + match req.kind { + RequestKind::Pixels => { + self.stats.page_interpretations += 1; + self.stats.rasterizations += 1; + } + RequestKind::Text => self.stats.text_interpretations += 1, + } + } + let Some(pdf) = self.pdf.as_ref() else { + return finish(req, start, Err(("the document could not be parsed".into(), false)), Vec::new()); + }; let cache = RenderCache::new(); - let r = render_page(pdf, &cache, &self.settings, req); + let warnings = Arc::new(Mutex::new(Vec::new())); + let settings = warning_settings(&self.settings, warnings.clone()); + let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, req); + let warnings = take_warnings(&warnings); if matches!(r, Err((_, true))) { self.pdf = parse(&self.bytes, self.config.password.as_deref()); + self.stats.parser_builds += 1; } - finish(req, start, r) + finish(req, start, r, warnings) } } @@ -321,9 +465,10 @@ struct Shared { /// Per worker id: the request it is rendering and since when. #[cfg(not(target_arch = "wasm32"))] busy: Mutex>>, - /// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a - /// pathological page cannot trap every worker in turn. - stuck: Mutex>, + /// Exact requests the watchdog gave up on: answered with an error at once, so a pathological + /// request cannot trap every worker in turn while other scales, tiles, or generations remain usable. + stuck: Mutex>, + stats: AtomicRenderStats, /// Per worker id: set to stop that worker's render at its next content operator once nobody /// can receive its answer (the pool was dropped, or the watchdog gave up on the render). /// Lock `busy` first when holding both. @@ -548,6 +693,11 @@ impl RenderPool { self.inline.is_some() } + /// Snapshot the work counters collected by this pool. + pub fn stats(&self) -> RenderStats { + self.inline.as_ref().map_or_else(|| self.shared.stats.snapshot(), |renderer| renderer.borrow().stats()) + } + /// Change the watchdog limit (tests and benchmarks). pub fn set_stuck_after(&mut self, limit: std::time::Duration) { self.stuck_after = limit; @@ -599,6 +749,7 @@ impl RenderPool { rgba: Pixels::default(), error: Some("all render workers exceeded their time limits; close and reopen the document to try again".into()), text: None, + warnings: Vec::new(), millis: 0, }) }) @@ -640,7 +791,7 @@ impl RenderPool { // the timeout to this page; do not blacklist it here. self.shared.queue.retry(req); } else { - lock(&self.shared.stuck).insert((req.page, req.kind)); + lock(&self.shared.stuck).insert(req.into()); let what = if req.kind == RequestKind::Text { "text extraction for page" } else { "page" }; let error = format!( "{what} {} took longer than {:.0} s and was skipped; the page may be damaged or extremely complex", @@ -654,6 +805,7 @@ impl RenderPool { rgba: Pixels::default(), error: Some(error), text: None, + warnings: Vec::new(), millis: 0, }); } @@ -690,7 +842,8 @@ fn worker( out: Arc, stop: Arc, ) { - let settings = worker_settings(&config, &stop); + let warnings = Arc::new(Mutex::new(Vec::new())); + let settings = warning_settings(&worker_settings(&config, &stop), warnings.clone()); // A local cell keeps Pdf borrows used by RenderCache stable. Drop both on an // epoch change/panic before publishing an error or taking another request. loop { @@ -710,15 +863,25 @@ fn worker( if shared.queue.cancel_obsolete(req) { continue; } - if lock(&shared.stuck).contains(&(req.page, req.kind)) { + if lock(&shared.stuck).contains(&req.into()) { let error = format!("page {} was skipped earlier because rendering failed or took too long", req.page + 1); - let page = RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(error), text: None, millis: 0 }; + let page = RenderedPage { + request: req, + width: 0, + height: 0, + rgba: Pixels::default(), + error: Some(error), + text: None, + warnings: Vec::new(), + millis: 0, + }; if out.send_valid(page, None).is_err() { return; } continue; } let start = Stopwatch::start(); + shared.stats.record_request(req); let stage = parsed.get().map_or( BusyStage::Parsing, |(_, _, shared_generation)| { @@ -738,6 +901,7 @@ fn worker( std::thread::sleep(delay); } } + shared.stats.parser_builds.fetch_add(1, Ordering::Relaxed); parse(&bytes, config.password.as_deref()) }; match shared.parser.acquire(load, || matches!(wake.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected))) { @@ -768,7 +932,10 @@ fn worker( if lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()) { return; } - break Some((finish(req, start, Err(("the shared parser was retired".into(), false))), Some(shared.parser.valid.clone()))); + break Some(( + finish(req, start, Err(("the shared parser was retired".into(), false)), Vec::new()), + Some(shared.parser.valid.clone()), + )); } #[cfg(test)] { @@ -785,17 +952,19 @@ fn worker( std::thread::sleep(delay); } } + lock(&warnings).clear(); let r = catch_unwind(AssertUnwindSafe(|| { #[cfg(test)] if *lock(&shared.panic_page) == Some(req.page) { panic!("injected page panic"); } match pdf { - Some(pdf) => render_page(pdf, cache, &settings, req), + Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, req), None => Err(("the document could not be parsed".into(), false)), } })) .unwrap_or_else(|panic| Err((format!("renderer crashed on page {}: {}", req.page + 1, panic_message(&panic)), true))); + let request_warnings = take_warnings(&warnings); // If the watchdog cleared our slot meanwhile, it already answered for this request // and started a replacement: drop the late result and retire. let abandoned = lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()); @@ -805,14 +974,14 @@ fn worker( let panicked = matches!(r, Err((_, true))); if panicked { shared.parser.abandon(); - lock(&shared.stuck).insert((req.page, req.kind)); - break Some((finish(req, start, r), None)); + lock(&shared.stuck).insert(req.into()); + break Some((finish(req, start, r, request_warnings), None)); } let valid = shared_generation.then(|| shared.parser.valid.clone()); if *shared_generation && !shared.parser.valid.load(Ordering::Acquire) { - break Some((finish(req, start, r), valid)); + break Some((finish(req, start, r, request_warnings), valid)); } - if out.send_valid(finish(req, start, r), valid).is_err() { + if out.send_valid(finish(req, start, r, request_warnings), valid).is_err() { return; } } @@ -976,8 +1145,9 @@ mod tests { assert!(matches!(*lock(&pool.shared.parser.state), ParserState::Private)); assert!((2..=4).contains(&pool.shared.parses.load(Ordering::Relaxed))); let before = pool.shared.parses.load(Ordering::Relaxed); - pool.set_queue(vec![RenderRequest { page: 0, tag: 1, scale: 1.0, ..Default::default() }]); - assert!(receive_before_deadline(&pool).error.is_some()); + // A new generation (tag) and scale of the failed page is still refused without parsing again. + pool.set_queue(vec![RenderRequest { page: 0, tag: 1, scale: 2.0, ..Default::default() }]); + assert!(receive_before_deadline(&pool).error.as_deref().is_some_and(|message| message.contains("skipped earlier"))); assert_eq!(pool.shared.parses.load(Ordering::Relaxed), before, "a failed page does not trigger repeated parsing"); } @@ -1063,8 +1233,8 @@ mod tests { assert!(pages[0].error.as_deref().is_some_and(|message| message.contains("took longer"))); assert!(pages[1].error.is_none(), "{:?}", pages[1].error); assert_eq!((pages[1].width, pages[1].height, pages[1].request.tag), (100, 50, 41)); - assert!(lock(&pool.shared.stuck).contains(&(0, RequestKind::Pixels))); - assert!(!lock(&pool.shared.stuck).contains(&(1, RequestKind::Pixels))); + assert!(lock(&pool.shared.stuck).contains(&RequestFailureKey::from(RenderRequest { page: 0, scale: 1.0, tag: 40, ..Default::default() }))); + assert!(!lock(&pool.shared.stuck).contains(&RequestFailureKey::from(RenderRequest { page: 1, scale: 1.0, tag: 41, ..Default::default() }))); assert!((2..=3).contains(&pool.shared.parses.load(Ordering::Relaxed)), "one shared parser and one or two reusable private parsers"); assert!(lock(&pool._workers).len() <= 4, "the existing replacement cap is unchanged"); } @@ -1087,7 +1257,7 @@ mod tests { assert!(exhausted.error.as_deref().is_some_and(|message| message.contains("all render workers") && message.contains("reopen"))); assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 2); assert_eq!(lock(&pool._workers).len(), 2, "no threads beyond the original replacement cap"); - assert!(!lock(&pool.shared.stuck).contains(&(1, RequestKind::Pixels)), "the healthy page itself is not blacklisted"); + assert!(!lock(&pool.shared.stuck).contains(&RequestFailureKey::from(healthy)), "the healthy page itself is not blacklisted"); let later = RenderRequest { tag: 52, ..healthy }; pool.set_queue(vec![later]); let result = receive_before_deadline(&pool); @@ -1101,6 +1271,26 @@ mod tests { assert!(pool.try_recv().is_none()); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_failed_page_stays_refused_at_every_scale_and_generation_but_other_pages_render() { + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + let bad = RenderRequest { page: 0, scale: 1.0, tag: 70, ..Default::default() }; + lock(&pool.shared.stuck).insert(bad.into()); + for req in [bad, RenderRequest { scale: 0.5, tag: 71, ..bad }, RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), tag: 72, ..bad }] + { + pool.set_queue(vec![req]); + let failed = receive_before_deadline(&pool); + assert_eq!(failed.request, req); + assert!(failed.error.as_deref().is_some_and(|message| message.contains("skipped earlier")), "{req:?}: {:?}", failed.error); + } + let other = RenderRequest { page: 1, scale: 0.5, tag: 73, ..Default::default() }; + pool.set_queue(vec![other]); + let rendered = receive_before_deadline(&pool); + assert_eq!(rendered.request, other); + assert!(rendered.error.is_none(), "another page still renders: {:?}", rendered.error); + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn replacing_a_request_during_initialization_skips_its_obsolete_render() { @@ -1132,7 +1322,7 @@ mod tests { if skipped { let req = RenderRequest { page: 0, ..Default::default() }; shared.queue.replace(vec![req]); - lock(&shared.stuck).insert((req.page, req.kind)); + lock(&shared.stuck).insert(req.into()); } let bytes = Arc::new(ONE_PAGE.to_vec()); let source = Arc::downgrade(&bytes); @@ -1170,6 +1360,17 @@ mod tests { } assert_eq!(pool.shared.parses.load(std::sync::atomic::Ordering::Relaxed), 1, "requests reuse one parser"); } + assert_eq!( + pool.stats(), + RenderStats { + parser_builds: 1, + page_interpretations: 2, + render_requests: 3, + tile_requests: 0, + text_interpretations: 1, + rasterizations: 2 + } + ); drop(pool); let until = std::time::Instant::now() + std::time::Duration::from_secs(8); while source.upgrade().is_some() { @@ -1655,6 +1856,51 @@ endstream endobj trailer << /Root 1 0 R >> %%EOF"; + fn heavy_vector_pdf(commands: usize, text_at_end: bool) -> Vec { + let mut body = String::with_capacity(commands * 42 + 64); + for i in 0..commands { + let x = (i % 280) as u32; + let y = ((i / 280) % 280) as u32; + body.push_str(&format!("0 0 0 rg {x} {y} 1 1 re f\n")); + } + if text_at_end { + body.push_str("BT /F1 18 Tf 20 280 Td (TRAILING_MARKER) Tj ET\n"); + } + format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + body.len() + ) + .into_bytes() + } + + #[test] + fn heavy_vector_fixture_preserves_trailing_text_and_raster_output() { + let mut renderer = PageRenderer::new(Arc::new(heavy_vector_pdf(5_000, true)), RenderConfig::default()); + let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(pixels.error.is_none(), "heavy vector render failed: {:?}", pixels.error); + assert!(pixels.rgba.as_chunks::<4>().0.iter().any(|pixel| *pixel != [255, 255, 255, 255])); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "heavy vector text failed: {:?}", text.error); + assert!(text.text.is_some_and(|page| page.plain_text().contains("TRAILING_MARKER"))); + assert_eq!( + renderer.stats(), + RenderStats { + parser_builds: 1, + page_interpretations: 1, + render_requests: 2, + tile_requests: 0, + text_interpretations: 1, + rasterizations: 1 + } + ); + } + #[test] fn large_resource_indexes_preserve_pixels_and_text() { // #307: opening pages used to retain a hash table for every large resource @@ -1774,6 +2020,25 @@ trailer << /Root 1 0 R >> assert!((14_400.0 * s).powi(2) <= MAX_PIXELS * 1.01); } + #[test] + fn strict_whole_page_render_refuses_cap_reduction_but_tiles_remain_available() { + let pdf = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 14400 14400] /Contents 4 0 R >> endobj +4 0 obj << /Length 0 >> stream +endstream endobj +trailer << /Root 1 0 R >> +%%EOF"; + let config = RenderConfig { reject_oversize: true, ..RenderConfig::default() }; + let mut renderer = PageRenderer::new(Arc::new(pdf.to_vec()), config); + let whole = renderer.render(RenderRequest { page: 0, scale: 4.0, ..Default::default() }); + assert!(whole.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits"))); + let tile = renderer.render(RenderRequest { page: 0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), scale: 4.0, ..Default::default() }); + assert!(tile.error.is_none(), "tiled rendering must remain available: {:?}", tile.error); + assert_eq!((tile.width, tile.height), (32, 32)); + } + /// Issue #102: an A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, dropping the last /// partial row and column. Like pdftoppm it is now 596×842, the edge pixels partly covered. #[test] @@ -2967,6 +3232,12 @@ trailer << /Root 1 0 R >> }); let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer"); assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the skipped nested content is observable"); + assert_eq!( + page.warnings.iter().filter(|w| **w == RenderWarning::ContentTruncated).count(), + 1, + "reported once per page, not once per nested paint" + ); assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws"); } diff --git a/crates/render/src/raster/work_queue.rs b/crates/render/src/raster/work_queue.rs index fd4d40f40..fd61c0742 100644 --- a/crates/render/src/raster/work_queue.rs +++ b/crates/render/src/raster/work_queue.rs @@ -296,7 +296,16 @@ mod tests { } fn page(req: RenderRequest, bytes: usize) -> RenderedPage { - RenderedPage { request: req, width: 1, height: 1, rgba: Pixels::from(vec![0u32; bytes / 4]), error: None, text: None, millis: 0 } + RenderedPage { + request: req, + width: 1, + height: 1, + rgba: Pixels::from(vec![0u32; bytes / 4]), + error: None, + text: None, + warnings: Vec::new(), + millis: 0, + } } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2b61add70..67e15f045 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3645,6 +3645,7 @@ trailer << /Root 1 0 R >> rgba: pdfcraft_render::Pixels::from(vec![u32::MAX; rgba_bytes(w as usize, h as usize) / 4]), text: None, error: None, + warnings: Vec::new(), millis: 0, } } diff --git a/crates/ui-egui/src/export_ui.rs b/crates/ui-egui/src/export_ui.rs index 03f322c7b..f1d9b9263 100644 --- a/crates/ui-egui/src/export_ui.rs +++ b/crates/ui-egui/src/export_ui.rs @@ -181,11 +181,21 @@ fn run( return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); } } - if total == 1 { + let mut msg = if total == 1 { crate::i18n::fmt(tl!("Exported 1 image"), &[]) } else { crate::i18n::fmt(tl!("Exported {n} images"), &[("n", &total.to_string())]) + }; + // Pages too large for the renderer at the chosen resolution are drawn at the most it + // allows: say so, rather than leave a smaller image unexplained. + let lowered: Vec = pages.iter().map(|p| ex.dpi_used(*p, dpi)).filter(|used| *used < dpi.clamp(18.0, 1200.0) - 0.5).collect(); + if let Some(lowest) = lowered.iter().copied().reduce(f64::min) { + msg.push_str(&crate::i18n::fmt( + tl!(" ({n} at {dpi} dpi, the largest size they can be drawn at)"), + &[("n", &lowered.len().to_string()), ("dpi", &format!("{}", lowest.floor()))], + )); } + msg } ExportKind::Text => { set(0, None); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f17eac81a..2556b4d27 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2095,3 +2095,4 @@ action wizard Start Старт The selected fields could not be found. Select them again. Избраните полета не бяха намерени. Изберете ги отново. The field {name} no longer exists. Полето {name} вече не съществува. A selected field has no widget on any page, so the fields can't be edited together. Едно от избраните полета няма елемент на нито една страница, затова полетата не могат да се редактират заедно. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} с {dpi} dpi, най-голямата резолюция, с която могат да бъдат изобразени) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e0f89dff4..c418d621d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2133,3 +2133,4 @@ action wizard Start Starten The selected fields could not be found. Select them again. Die ausgewählten Felder wurden nicht gefunden. Wähle sie erneut aus. The field {name} no longer exists. Das Feld {name} existiert nicht mehr. A selected field has no widget on any page, so the fields can't be edited together. Ein ausgewähltes Feld hat auf keiner Seite ein Widget, daher können die Felder nicht gemeinsam bearbeitet werden. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} mit {dpi} dpi, der größten Auflösung, in der sie gezeichnet werden können) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 011837c0d..9bcf66c3d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2049,3 +2049,4 @@ The selected fields could not be found. Select them again. No se encontraron los campos seleccionados. Vuelve a seleccionarlos. The field {name} no longer exists. El campo {name} ya no existe. A selected field has no widget on any page, so the fields can't be edited together. Un campo seleccionado no tiene ningún widget en ninguna página, así que los campos no se pueden editar juntos. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} ppp, el mayor tamaño al que se pueden dibujar) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 81c24252d..c4dc68f08 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2133,3 +2133,4 @@ signature pad Type Saisir The selected fields could not be found. Select them again. Les champs sélectionnés sont introuvables. Sélectionnez-les de nouveau. The field {name} no longer exists. Le champ {name} n'existe plus. A selected field has no widget on any page, so the fields can't be edited together. Un champ sélectionné n'a de widget sur aucune page : les champs ne peuvent pas être modifiés ensemble. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} à {dpi} ppp, la plus grande taille possible pour les dessiner) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c69943d6e..d88294b87 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2131,3 +2131,4 @@ action wizard Start Indítás The selected fields could not be found. Select them again. A kijelölt mezők nem találhatók. Jelöld ki őket újra. The field {name} no longer exists. A(z) {name} mező már nem létezik. A selected field has no widget on any page, so the fields can't be edited together. Az egyik kijelölt mezőnek egyik oldalon sincs widgetje, ezért a mezők nem szerkeszthetők együtt. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} {dpi} dpi felbontással, a legnagyobb méretben, amelyben megjeleníthetők) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cd410703d..e352c2c80 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2142,3 +2142,4 @@ signature pad Type Digita The selected fields could not be found. Select them again. I campi selezionati non sono stati trovati. Selezionali di nuovo. The field {name} no longer exists. Il campo {name} non esiste più. A selected field has no widget on any page, so the fields can't be edited together. Un campo selezionato non ha widget in nessuna pagina, quindi i campi non possono essere modificati insieme. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} dpi, la dimensione massima a cui si possono disegnare) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aaf1138f9..910b29912 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2133,3 +2133,4 @@ signature pad Type 入力 The selected fields could not be found. Select them again. 選択したフィールドが見つかりません。もう一度選択してください。 The field {name} no longer exists. フィールド {name} はもう存在しません。 A selected field has no widget on any page, so the fields can't be edited together. 選択したフィールドの一つがどのページにもウィジェットを持たないため、まとめて編集できません。 + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 件は描画できる最大サイズの {dpi} dpi で書き出しました) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ef6780814..653cf7c63 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2134,3 +2134,4 @@ comment menu Edit Editar The selected fields could not be found. Select them again. Os campos selecionados não foram encontrados. Selecione-os novamente. The field {name} no longer exists. O campo {name} não existe mais. A selected field has no widget on any page, so the fields can't be edited together. Um campo selecionado não tem widget em nenhuma página, então os campos não podem ser editados juntos. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} a {dpi} dpi, o maior tamanho em que podem ser desenhadas) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5f9447f41..4464fa80c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2046,3 +2046,4 @@ The selected fields could not be found. Select them again. Выбранные поля не найдены. Выберите их снова. The field {name} no longer exists. Поле {name} больше не существует. A selected field has no widget on any page, so the fields can't be edited together. У одного из выбранных полей нет виджета ни на одной странице, поэтому поля нельзя изменить вместе. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} — с разрешением {dpi} dpi, максимальным, с каким их можно отрисовать) diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 373a6ea77..92ddf3a1e 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2064,3 +2064,4 @@ action wizard Start ప్రారంభించు The selected fields could not be found. Select them again. ఎంచుకున్న ఫీల్డ్‌లు కనుగొనబడలేదు. వాటిని మళ్ళీ ఎంచుకోండి. The field {name} no longer exists. {name} ఫీల్డ్ ఇకపై లేదు. A selected field has no widget on any page, so the fields can't be edited together. ఎంచుకున్న ఒక ఫీల్డ్‌కు ఏ పేజీలోనూ విడ్జెట్ లేదు, కాబట్టి ఫీల్డ్‌లను కలిపి సవరించలేము. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} {dpi} dpi వద్ద, అవి గీయగల అతిపెద్ద పరిమాణం) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 42bb556f8..292b9a6f5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2156,3 +2156,4 @@ certificate Subject Власник сертифіката The selected fields could not be found. Select them again. Вибрані поля не знайдено. Виберіть їх знову. The field {name} no longer exists. Поле {name} більше не існує. A selected field has no widget on any page, so the fields can't be edited together. Одне з вибраних полів не має віджета на жодній сторінці, тому поля не можна редагувати разом. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} — з роздільністю {dpi} dpi, найбільшою, з якою їх можна відобразити) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4a3644896..26e4e4280 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2097,3 +2097,4 @@ The selected fields could not be found. Select them again. 找不到所选字段。请重新选择。 The field {name} no longer exists. 字段 {name} 已不存在。 A selected field has no widget on any page, so the fields can't be edited together. 某个所选字段在任何页面上都没有控件,因此无法一起编辑这些字段。 + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 张以可绘制的最大尺寸 {dpi} dpi 导出) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ce5257376..58eefc263 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2019,3 +2019,4 @@ The selected fields could not be found. Select them again. 找不到所選欄位。請重新選取。 The field {name} no longer exists. 欄位 {name} 已不存在。 A selected field has no widget on any page, so the fields can't be edited together. 某個所選欄位在任何頁面上都沒有控制項,因此無法一起編輯這些欄位。 + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} 張以可繪製的最大尺寸 {dpi} dpi 匯出) diff --git a/vendor/README.md b/vendor/README.md index 9b1a4ac19..57389ee9a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,7 +7,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | 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`; 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 | (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` +| 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 | (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` | 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 | (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` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 222f74b45..abf38e314 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -34,6 +34,9 @@ pub(crate) const MAX_NESTED_PAINTS: u32 = 50_000; std::thread_local! { static NESTED_PAINTS: core::cell::Cell = const { core::cell::Cell::new(0) }; static CONTENT_LEFT: core::cell::Cell = const { core::cell::Cell::new(MAX_PAGE_CONTENT) }; + // PdfCraft patch (18): whether this page's content budget ran out, reported as + // `InterpreterWarning::ContentTruncated`. + static CONTENT_TRUNCATED: core::cell::Cell = const { core::cell::Cell::new(false) }; } /// PdfCraft patch: decoded content-stream bytes one page may hold at once: its contents, and @@ -70,6 +73,7 @@ impl Drop for DecodedContent<'_> { pub(crate) fn decode_content<'a>(stream: &Stream<'a>) -> Option> { let left = CONTENT_LEFT.with(core::cell::Cell::get); if left == 0 { + CONTENT_TRUNCATED.with(|truncated| truncated.set(true)); warn!("page content budget exceeded"); return None; } @@ -87,6 +91,11 @@ pub(crate) fn charge_content(len: usize) { CONTENT_LEFT.with(|c| c.set(c.get().saturating_sub(len))); } +/// PdfCraft patch (18): whether content was skipped because the page's budget ran out. +pub(crate) fn content_was_truncated() -> bool { + CONTENT_TRUNCATED.with(core::cell::Cell::get) +} + /// PdfCraft patch: count a paint at `depth` (top-level ones are free); `false` once this /// page's budget is spent, and the paint is skipped. pub(crate) fn take_nested_paint(depth: u32) -> bool { @@ -158,6 +167,7 @@ impl<'a> Context<'a> { // PdfCraft patch: a page (or other top-level content) starts with fresh budgets. NESTED_PAINTS.with(|n| n.set(0)); CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT)); + CONTENT_TRUNCATED.with(|truncated| truncated.set(false)); crate::encode::reset_mesh_budget(); Self::new_with(initial_transform, bbox, cache, xref, settings, state, 0) diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 44850a3b9..8f5e7ce77 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -148,6 +148,8 @@ pub enum InterpreterWarning { UnsupportedFont, /// An image failed to decode. ImageDecodeFailure, + /// PdfCraft patch (18): a page content stream was skipped after exhausting its safety budget. + ContentTruncated, } /// interpret the contents of the page and render them into the device. @@ -844,4 +846,8 @@ pub fn interpret<'a>( while context.num_states() > num_states { context.restore_state(device); } + // PdfCraft patch (18): say that content was skipped, so a truncated page isn't a clean render. + if crate::context::content_was_truncated() { + (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); + } }